Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 36 additions & 4 deletions packages/message/message_queue.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -192,10 +192,13 @@ describe("MessageQueueGroup", () => {
const sendSpy = vi.spyOn(chrome.runtime, "sendMessage");
group.publish("test-sendChromeMessage", { data: "test-sendChromeMessage" });

expect(sendSpy).toHaveBeenCalledWith({
msgQueue: "api-sendChromeMessage/test-sendChromeMessage",
data: { action: "message", message: { data: "test-sendChromeMessage" } },
});
expect(sendSpy).toHaveBeenCalledWith(
{
msgQueue: "api-sendChromeMessage/test-sendChromeMessage",
data: { action: "message", message: { data: "test-sendChromeMessage" } },
},
expect.any(Function)
);
});

it("emit 方法应该只在本地发布", () => {
Expand All @@ -209,6 +212,35 @@ describe("MessageQueueGroup", () => {
expect(handler).toHaveBeenCalledWith({ data: "test-emitLocal" });
expect(sendSpy).not.toHaveBeenCalled();
});

it("publish 应传入回调并在回调中读取 chrome.runtime.lastError(接收端不存在时不产生未检查错误)", () => {
// 模拟浏览器行为: 没有任何接收方时, 回调执行期间 lastError 被设置;
// 若回调中不读取 lastError(或根本没传回调), 浏览器会报
// "Unchecked runtime.lastError: Could not establish connection. Receiving end does not exist."
const lastErrorGetter = vi.fn(() => ({
message: "Could not establish connection. Receiving end does not exist.",
}));
const sendSpy = vi.spyOn(chrome.runtime, "sendMessage").mockImplementation(((
_message: any,
callback?: (resp: any) => void
) => {
Object.defineProperty(chrome.runtime, "lastError", { get: lastErrorGetter, configurable: true });
try {
callback?.(undefined);
} finally {
delete (chrome.runtime as any).lastError;
}
}) as any);

const group = messageQueue.group("api-lastError");
expect(() => group.publish("test-lastError", { data: "test-lastError" })).not.toThrow();

expect(sendSpy).toHaveBeenCalledTimes(1);
// 必须传入回调, 否则 MV3 下无回调的 sendMessage 返回的 Promise 会产生 unhandled rejection
expect(typeof sendSpy.mock.calls[0]![1]).toBe("function");
// 回调中必须读取 lastError, 否则浏览器会报 Unchecked runtime.lastError
expect(lastErrorGetter).toHaveBeenCalled();
});
});

describe("取消订阅功能", () => {
Expand Down
20 changes: 16 additions & 4 deletions packages/message/message_queue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,10 +73,22 @@ export class MessageQueue implements IMessageQueue {
}

publish<T>(topic: string, message: NonNullable<T>) {
chrome.runtime.sendMessage({
msgQueue: topic,
data: { action: "message", message },
});
chrome.runtime.sendMessage(
{
msgQueue: topic,
data: { action: "message", message },
},
() => {
// 广播为 fire-and-forget: 其他上下文(offscreen/popup/options)可能不存在或不回应,
// 必须传入回调并读取 lastError, 否则产生 "Unchecked runtime.lastError" / unhandled rejection
const lastError = chrome.runtime.lastError;
if (lastError) {
LoggerCore.getInstance()
.logger({ service: "messageQueue" })
.trace("publish lastError", { topic, error: lastError.message });
}
}
);
this.EE.emit(topic, message);
//@ts-ignore
LoggerCore.getInstance().logger({ service: "messageQueue" }).trace("publish", { topic, message });
Expand Down
86 changes: 86 additions & 0 deletions packages/message/window_message.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,92 @@ describe("ServiceWorkerMessageSend", () => {
});
});

describe("ServiceWorkerMessageSend 目标客户端解析", () => {
const offscreenUrl = "chrome-extension://mock-extension-id/src/offscreen.html";

afterEach(() => {
vi.useRealTimers();
});

it("offscreen 客户端尚未出现时 sendMessage 应等待客户端出现后再发送", async () => {
vi.useFakeTimers();
vi.spyOn(chrome.runtime, "getURL").mockReturnValue(offscreenUrl);

const swSend = new ServiceWorkerMessageSend();
const clientPostMessage = vi.fn((body: WindowMessageBody) => {
// offscreen 收到请求后回复响应
swSend.messageHandle({ messageId: body.messageId, type: "respMessage", data: { code: 0, data: "pong" } });
});

// 前两次查询 offscreen 文档尚未创建完成, 之后出现(模拟 SW 启动早于 offscreen 创建的竞态)
const matchAll = (self as any).clients.matchAll as ReturnType<typeof vi.fn>;
matchAll
.mockResolvedValueOnce([])
.mockResolvedValueOnce([])
.mockResolvedValue([{ url: offscreenUrl, postMessage: clientPostMessage }]);

const promise = swSend.sendMessage({ action: "offscreen/test", data: "ping" });
const assertion = expect(promise).resolves.toEqual({ code: 0, data: "pong" });
await vi.advanceTimersByTimeAsync(3000);
await assertion;
expect(clientPostMessage).toHaveBeenCalledTimes(1);
});

it("offscreen 客户端始终不可用时应以描述性错误拒绝而不是抛 TypeError", async () => {
vi.useFakeTimers();
vi.spyOn(chrome.runtime, "getURL").mockReturnValue(offscreenUrl);

const swSend = new ServiceWorkerMessageSend();
// matchAll 始终返回 [](offscreen 一直不存在)
const promise = swSend.sendMessage({ action: "offscreen/test", data: "ping" });
const assertion = expect(promise).rejects.toThrow(/offscreen/i);
await vi.advanceTimersByTimeAsync(60_000);
await assertion;
});

it("收到来自 offscreen 的消息后应刷新 target, 无需 matchAll 即可发送", async () => {
vi.spyOn(chrome.runtime, "getURL").mockReturnValue(offscreenUrl);

const swSend = new ServiceWorkerMessageSend();
const clientPostMessage = vi.fn((body: WindowMessageBody) => {
if (body.type === "sendMessage") {
swSend.messageHandle({ messageId: body.messageId, type: "respMessage", data: { code: 0, data: "ok" } });
}
});

// offscreen 先主动发来一条请求(例如 preparationOffscreen), source 即当前有效的 offscreen 客户端
swSend.messageHandle({ messageId: "m1", type: "sendMessage", data: { action: "x" } }, {
url: offscreenUrl,
postMessage: clientPostMessage,
} as any);

// SW 主动发消息应直接使用刷新后的 target(即使 matchAll 查不到客户端)
await expect(swSend.sendMessage({ action: "offscreen/test", data: 1 })).resolves.toEqual({
code: 0,
data: "ok",
});
expect((self as any).clients.matchAll).not.toHaveBeenCalled();
});

it("非 offscreen 页面的消息来源不应污染 target", async () => {
vi.useFakeTimers();
vi.spyOn(chrome.runtime, "getURL").mockReturnValue(offscreenUrl);

const swSend = new ServiceWorkerMessageSend();
const strangerPostMessage = vi.fn();
swSend.messageHandle({ messageId: "m2", type: "sendMessage", data: { action: "x" } }, {
url: "chrome-extension://mock-extension-id/src/options.html",
postMessage: strangerPostMessage,
} as any);

const promise = swSend.sendMessage({ action: "offscreen/test", data: 1 });
const assertion = expect(promise).rejects.toThrow(/offscreen/i);
await vi.advanceTimersByTimeAsync(60_000);
await assertion;
expect(strangerPostMessage).not.toHaveBeenCalledWith(expect.objectContaining({ type: "sendMessage" }));
});
});

describe("ServiceWorkerClientMessage", () => {
it("controller 可用时直接使用", () => {
const clientMsg = new ServiceWorkerClientMessage();
Expand Down
56 changes: 49 additions & 7 deletions packages/message/window_message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -217,10 +217,17 @@ export class WindowMessageConnect implements MessageConnect {
// 不进行监听的话又无法从service_worker主动发送消息
// 所以service_worker与offscreen使用ServiceWorker的方式进行通信
// 现在同时支持接收来自offscreen的请求(实现完整Message接口),使双向通道都走postMessage(结构化克隆,支持Blob)

const OFFSCREEN_DOCUMENT_PATH = "src/offscreen.html";
// offscreen客户端出现前的轮询间隔与等待上限(SW启动早于offscreen文档创建完成的竞态窗口)
const OFFSCREEN_TARGET_POLL_INTERVAL = 200;
const OFFSCREEN_TARGET_WAIT_TIMEOUT = 30_000;

export class ServiceWorkerMessageSend implements Message {
EE = new EventEmitter<string, any>();

private target: PostMessage | undefined = undefined;
private targetWaiter: Promise<PostMessage> | undefined = undefined;

constructor() {
// 在构造函数中设置监听,确保能接收来自offscreen的请求
Expand All @@ -229,15 +236,50 @@ export class ServiceWorkerMessageSend implements Message {
});
}

async init() {
private async queryTarget() {
if (!this.target && self.clients) {
const list = await self.clients.matchAll({ includeUncontrolled: true, type: "window" });
// 找到offscreen.html窗口
this.target = list.find((client) => client.url == chrome.runtime.getURL("src/offscreen.html")) as PostMessage;
this.target = list.find((client) => client.url == chrome.runtime.getURL(OFFSCREEN_DOCUMENT_PATH)) as PostMessage;
}
}

async init() {
await this.queryTarget();
}

// offscreen文档可能尚未创建完成,轮询等待其出现;超时则以可诊断的错误拒绝,而不是对undefined调用postMessage
private waitForTarget(): Promise<PostMessage> {
if (this.target) {
return Promise.resolve(this.target);
}
if (!this.targetWaiter) {
this.targetWaiter = (async () => {
const deadline = Date.now() + OFFSCREEN_TARGET_WAIT_TIMEOUT;
for (;;) {
await this.queryTarget();
if (this.target) {
return this.target;
}
if (Date.now() >= deadline) {
throw new Error("ServiceWorkerMessageSend: offscreen document client not found (timeout)");
}
await new Promise((resolve) => setTimeout(resolve, OFFSCREEN_TARGET_POLL_INTERVAL));
}
})().finally(() => {
// 成功后走 this.target 快路径;失败后允许后续调用重新等待
this.targetWaiter = undefined;
});
}
return this.targetWaiter;
}

messageHandle(data: WindowMessageBody, source?: PostMessage) {
// 来自offscreen的消息自带最新的客户端引用,用于刷新target(offscreen重建后旧Client已失效)
const sourceUrl = (source as { url?: string } | undefined)?.url;
if (sourceUrl && sourceUrl === chrome.runtime.getURL(OFFSCREEN_DOCUMENT_PATH)) {
this.target = source;
}
// 处理消息
if (data.type === "sendMessage" && source) {
// 接收到来自offscreen的请求消息
Expand Down Expand Up @@ -281,19 +323,19 @@ export class ServiceWorkerMessageSend implements Message {
}

async connect(data: TMessage): Promise<MessageConnect> {
await this.init();
const target = await this.waitForTarget();
const body: WindowMessageBody<TMessage> = {
messageId: uuidv4(),
type: "connect",
data,
};
this.target!.postMessage(body);
return new WindowMessageConnect(body.messageId, this.EE, this.target!);
target.postMessage(body);
return new WindowMessageConnect(body.messageId, this.EE, target);
}

// 发送消息 注意不进行回调的内存泄漏
async sendMessage<T = any>(data: TMessage): Promise<T> {
await this.init();
const target = await this.waitForTarget();
return new Promise((resolve: ((value: T) => void) | null) => {
const messageId = uuidv4();
const body: WindowMessageBody<TMessage> = {
Expand All @@ -307,7 +349,7 @@ export class ServiceWorkerMessageSend implements Message {
resolve!(body.data as T);
resolve = null; // 设为 null 提醒JS引擎可以GC
});
this.target!.postMessage(body);
target.postMessage(body);
});
}
}
Expand Down
102 changes: 102 additions & 0 deletions src/app/service/service_worker/offscreen_setup.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";

const OFFSCREEN_URL = "chrome-extension://mock-extension-id/src/offscreen.html";

let getContextsMock: ReturnType<typeof vi.fn>;
let createDocumentMock: ReturnType<typeof vi.fn>;

// setupOffscreenDocument 使用模块级 latch, 每个用例用全新模块实例避免状态串扰
async function importModule() {
vi.resetModules();
return await import("./offscreen_setup.ts");
}

beforeEach(() => {
getContextsMock = vi.fn().mockResolvedValue([]);
createDocumentMock = vi.fn().mockResolvedValue(undefined);
(chrome.runtime as any).getContexts = getContextsMock;
(chrome.runtime as any).ContextType = { OFFSCREEN_DOCUMENT: "OFFSCREEN_DOCUMENT" };
vi.spyOn(chrome.runtime, "getURL").mockReturnValue(OFFSCREEN_URL);
(chrome as any).offscreen = {
createDocument: createDocumentMock,
Reason: { BLOBS: "BLOBS", CLIPBOARD: "CLIPBOARD", DOM_SCRAPING: "DOM_SCRAPING", LOCAL_STORAGE: "LOCAL_STORAGE" },
};
vi.spyOn(console, "error").mockImplementation(() => {});
});

afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
delete (chrome as any).offscreen;
delete (chrome.runtime as any).getContexts;
delete (chrome.runtime as any).ContextType;
});

describe("setupOffscreenDocument", () => {
it("文档已存在时不再调用 createDocument", async () => {
getContextsMock.mockResolvedValue([{ contextType: "OFFSCREEN_DOCUMENT" }]);
const { setupOffscreenDocument } = await importModule();

await expect(setupOffscreenDocument()).resolves.toBe(true);
expect(createDocumentMock).not.toHaveBeenCalled();
});

it("文档不存在时创建成功并返回 true", async () => {
const { setupOffscreenDocument } = await importModule();

await expect(setupOffscreenDocument()).resolves.toBe(true);
expect(createDocumentMock).toHaveBeenCalledTimes(1);
});

it("并发调用共享同一次创建", async () => {
const { setupOffscreenDocument } = await importModule();

const [a, b] = await Promise.all([setupOffscreenDocument(), setupOffscreenDocument()]);
expect(a).toBe(true);
expect(b).toBe(true);
expect(createDocumentMock).toHaveBeenCalledTimes(1);
});

it("createDocument 因文档已存在而失败时(TOCTOU 竞态)视为成功", async () => {
createDocumentMock.mockRejectedValue(new Error("Only a single offscreen document may be created."));
// 第一次检查(创建前)不存在, 失败后复查时已存在
getContextsMock.mockResolvedValueOnce([]).mockResolvedValue([{ contextType: "OFFSCREEN_DOCUMENT" }]);
const { setupOffscreenDocument } = await importModule();

await expect(setupOffscreenDocument()).resolves.toBe(true);
});

it("createDocument 暂时性失败后应退避重试并最终成功", async () => {
vi.useFakeTimers();
createDocumentMock.mockRejectedValueOnce(new Error("transient failure")).mockResolvedValue(undefined);
const { setupOffscreenDocument } = await importModule();

const promise = setupOffscreenDocument();
await vi.advanceTimersByTimeAsync(10_000);
await expect(promise).resolves.toBe(true);
expect(createDocumentMock).toHaveBeenCalledTimes(2);
});

it("createDocument 持续失败时返回 false, 且 latch 被重置允许后续重试成功", async () => {
vi.useFakeTimers();
createDocumentMock.mockRejectedValue(new Error("persistent failure"));
const { setupOffscreenDocument } = await importModule();

const promise = setupOffscreenDocument();
await vi.advanceTimersByTimeAsync(60_000);
await expect(promise).resolves.toBe(false);

// 失败后 latch 必须重置: 下一次调用可重新尝试并成功
createDocumentMock.mockResolvedValue(undefined);
const retryPromise = setupOffscreenDocument();
await vi.advanceTimersByTimeAsync(60_000);
await expect(retryPromise).resolves.toBe(true);
});

it("浏览器不支持 chrome.offscreen 时返回 false 而不抛错(Firefox)", async () => {
delete (chrome as any).offscreen;
const { setupOffscreenDocument } = await importModule();

await expect(setupOffscreenDocument()).resolves.toBe(false);
});
});
Loading
Loading