webext-messenger 0.35.0 → 0.37.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,11 +1,9 @@
1
- import { isExtensionContext } from "webext-detect";
1
+ import { isBackground, isExtensionContext } from "webext-detect";
2
2
  import { deserializeError } from "serialize-error";
3
3
  import { isObject, MessengerError, ExtensionNotFoundError, __webextMessenger, } from "./shared.js";
4
4
  import { log } from "./logging.js";
5
5
  import { handlers } from "./handlers.js";
6
6
  import { events } from "./events.js";
7
- import { compareTargets } from "./targetLogic.js";
8
- import { thisTarget } from "./thisTarget.js";
9
7
  const _errorNonExistingTarget = "Could not establish connection. Receiving end does not exist.";
10
8
  const _errorTargetClosedEarly = "A listener indicated an asynchronous response by returning true, but the message channel closed before a response was received";
11
9
  export const errorTargetClosedEarly = "The target was closed before receiving a response";
@@ -186,17 +184,16 @@ function messenger(type, options, target, ...args) {
186
184
  };
187
185
  return manageConnection(type, options, target, sendMessage);
188
186
  }
189
- // Use local methods if the target matches the current context
190
- if (compareTargets(target, thisTarget)) {
191
- const handler = handlers.get(type);
192
- if (handler) {
193
- log.warn(type, seq, "is being handled locally");
194
- return handler.apply({ trace: [] }, args);
195
- }
196
- throw new MessengerError("No handler registered locally for " + type);
197
- }
198
187
  // Message goes to extension page
199
188
  if ("page" in target) {
189
+ if (target.page === "background" && isBackground()) {
190
+ const handler = handlers.get(type);
191
+ if (handler) {
192
+ log.warn(type, seq, "is being handled locally");
193
+ return handler.apply({ trace: [] }, args);
194
+ }
195
+ throw new MessengerError("No handler registered locally for " + type);
196
+ }
200
197
  const sendMessage = async (attemptCount) => {
201
198
  log.debug(type, seq, "↗️ sending message to runtime", attemptLog(attemptCount));
202
199
  return chrome.runtime.sendMessage(makeMessage(type, args, target, options));
@@ -1,6 +1,7 @@
1
+ import { type MessengerMeta } from "./types.js";
1
2
  declare global {
2
3
  interface MessengerMethods {
3
- testMethod: () => Promise<string>;
4
+ /** Stand-in method, only ever registered/targeted by this file. */
5
+ senderTestMethod: (this: MessengerMeta, ...args: unknown[]) => Promise<unknown>;
4
6
  }
5
7
  }
6
- export {};
@@ -1,160 +1,352 @@
1
- /* eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-argument, @typescript-eslint/naming-convention */
2
- import { describe, test, expect, vi, beforeEach } from "vitest";
1
+ import { afterEach, beforeEach, describe, expect, test, vi, } from "vitest";
2
+ import { isBackground, isExtensionContext } from "webext-detect";
3
+ import { errorTabDoesntExist, errorTabWasDiscarded, errorTargetClosedEarly, getMethod, getNotifier, messenger, } from "./sender.js";
3
4
  import { handlers } from "./handlers.js";
4
- import * as thisTargetModule from "./thisTarget.js";
5
- // Mock dependencies
6
- vi.mock("webext-detect", () => ({
7
- isBackground: vi.fn(() => false),
8
- isExtensionContext: vi.fn(() => true),
9
- isOffscreenDocument: vi.fn(() => false),
10
- isContentScript: vi.fn(() => true),
11
- }));
12
- vi.mock("./logging.js", () => ({
13
- log: {
14
- debug: vi.fn(),
15
- warn: vi.fn(),
16
- },
17
- }));
18
- vi.mock("./events.js", () => ({
19
- events: {
20
- dispatchEvent: vi.fn(),
21
- },
22
- }));
23
- // Mock chrome APIs - simulate content script environment (no chrome.tabs)
24
- globalThis.chrome = {
25
- runtime: {
26
- id: "test-extension-id",
27
- sendMessage: vi.fn(),
28
- },
29
- // Note: chrome.tabs is undefined in content scripts
30
- };
31
- describe("messenger with tab targets and local methods", () => {
32
- beforeEach(() => {
33
- vi.clearAllMocks();
34
- handlers.clear();
35
- });
36
- test("should use local method when targeting same tab and frame", async () => {
37
- // Setup: Set thisTarget to have tabId: 1, frameId: 0
38
- const mockThisTarget = {
39
- tabId: 1,
40
- frameId: 0,
41
- page: "test",
42
- };
43
- vi.spyOn(thisTargetModule, "thisTarget", "get").mockReturnValue(mockThisTarget);
44
- // Register a local handler
45
- const mockHandler = vi.fn(async () => "local result");
46
- handlers.set("testMethod", mockHandler);
47
- // Import messenger after mocks are set up
48
- const { messenger } = await import("./sender.js");
49
- // Test: Send message to same tab and frame
50
- const result = await messenger("testMethod", {}, { tabId: 1, frameId: 0 });
51
- // Verify: Handler was called locally
52
- expect(mockHandler).toHaveBeenCalledOnce();
53
- expect(mockHandler).toHaveBeenCalledWith();
54
- expect(result).toBe("local result");
55
- // Verify: No message was sent via chrome.runtime.sendMessage
56
- expect(chrome.runtime.sendMessage).not.toHaveBeenCalled();
57
- });
58
- test("should send message when targeting different tab", async () => {
59
- // Setup: Set thisTarget to have tabId: 1, frameId: 0
60
- const mockThisTarget = {
61
- tabId: 1,
62
- frameId: 0,
63
- page: "test",
5
+ import { events } from "./events.js";
6
+ import { ExtensionNotFoundError, MessengerError } from "./shared.js";
7
+ vi.mock("webext-detect");
8
+ // The verbatim strings the browser throws. `sender.ts` matches on them to decide
9
+ // whether an error is retryable, so a change in either place must break a test.
10
+ const browserErrorNoReceiver = "Could not establish connection. Receiving end does not exist.";
11
+ const browserErrorClosedEarly = "A listener indicated an asynchronous response by returning true, but the message channel closed before a response was received";
12
+ /** `sender.ts` only unwraps responses carrying this marker; anything else is a third-party reply. */
13
+ function messengerReply(value) {
14
+ return { __webextMessenger: true, value };
15
+ }
16
+ function messengerErrorReply(name, message) {
17
+ return { __webextMessenger: true, error: { name, message } };
18
+ }
19
+ /**
20
+ * @param tabs Contexts without the tabs API (content scripts, offscreen documents)
21
+ * take a different route through `messenger()`, so this must be switchable.
22
+ * @param invalidated Drops `runtime.id`, which is how the browser signals that the
23
+ * extension was reloaded out from under this context.
24
+ */
25
+ function stubChrome({ tabs = true, invalidated = false } = {}) {
26
+ const chromeStub = {
27
+ runtime: {
28
+ id: invalidated ? undefined : "this-extension-id",
29
+ sendMessage: vi.fn(),
30
+ },
31
+ };
32
+ if (tabs) {
33
+ chromeStub.tabs = {
34
+ sendMessage: vi.fn(),
35
+ get: vi
36
+ .fn()
37
+ .mockResolvedValue({ discarded: false }),
64
38
  };
65
- vi.spyOn(thisTargetModule, "thisTarget", "get").mockReturnValue(mockThisTarget);
66
- // Register a local handler (should not be used)
67
- const mockHandler = vi.fn(async () => "local result");
68
- handlers.set("testMethod", mockHandler);
69
- // Mock chrome.runtime.sendMessage to return a messenger response
70
- vi.mocked(chrome.runtime.sendMessage).mockResolvedValue({
39
+ }
40
+ vi.stubGlobal("chrome", chromeStub);
41
+ return chromeStub;
42
+ }
43
+ /** The message envelope is always the last argument of a `runtime.sendMessage` call. */
44
+ function sentEnvelope(mock, index = 0) {
45
+ return mock.mock.calls[index].at(-1);
46
+ }
47
+ const eventCleanups = [];
48
+ function trackEvent(name) {
49
+ const listener = vi.fn();
50
+ events.addEventListener(name, listener);
51
+ eventCleanups.push(() => {
52
+ events.removeEventListener(name, listener);
53
+ });
54
+ return listener;
55
+ }
56
+ beforeEach(() => {
57
+ vi.useFakeTimers();
58
+ });
59
+ afterEach(() => {
60
+ vi.useRealTimers();
61
+ vi.unstubAllGlobals();
62
+ vi.resetAllMocks();
63
+ handlers.clear();
64
+ for (const cleanup of eventCleanups.splice(0)) {
65
+ cleanup();
66
+ }
67
+ });
68
+ describe("messenger() routing", () => {
69
+ test("sends to another extension via runtime.sendMessage(extensionId, message)", async () => {
70
+ const chromeStub = stubChrome();
71
+ chromeStub.runtime.sendMessage.mockResolvedValue(messengerReply(3));
72
+ await expect(messenger("senderTestMethod", {}, { extensionId: "other-extension" }, 1)).resolves.toBe(3);
73
+ expect(chromeStub.runtime.sendMessage).toHaveBeenCalledOnce();
74
+ expect(chromeStub.runtime.sendMessage).toHaveBeenCalledWith("other-extension", expect.objectContaining({
71
75
  __webextMessenger: true,
72
- value: "remote result",
76
+ type: "senderTestMethod",
77
+ args: [1],
78
+ target: { extensionId: "other-extension" },
79
+ }));
80
+ });
81
+ test("throws synchronously when the browser cannot message other extensions", () => {
82
+ vi.stubGlobal("chrome", {});
83
+ expect(() => {
84
+ void messenger("senderTestMethod", {}, { extensionId: "other-extension" }, 1);
85
+ }).toThrow(ExtensionNotFoundError);
86
+ });
87
+ test("invokes the local handler when the background page targets itself", async () => {
88
+ const chromeStub = stubChrome();
89
+ vi.mocked(isBackground).mockReturnValue(true);
90
+ const handler = vi.fn().mockResolvedValue(42);
91
+ handlers.set("senderTestMethod", handler);
92
+ await expect(messenger("senderTestMethod", {}, { page: "background" }, 1, 2)).resolves.toBe(42);
93
+ expect(handler).toHaveBeenCalledWith(1, 2);
94
+ expect(chromeStub.runtime.sendMessage).not.toHaveBeenCalled();
95
+ });
96
+ test("throws when the background page targets itself without a registered handler", () => {
97
+ stubChrome();
98
+ vi.mocked(isBackground).mockReturnValue(true);
99
+ expect(() => {
100
+ void messenger("senderTestMethod", {}, { page: "background" }, 1);
101
+ }).toThrow("No handler registered locally for senderTestMethod");
102
+ });
103
+ test("sends to an extension page via runtime.sendMessage(message)", async () => {
104
+ const chromeStub = stubChrome();
105
+ chromeStub.runtime.sendMessage.mockResolvedValue(messengerReply("ok"));
106
+ await expect(messenger("senderTestMethod", {}, { page: "/options.html" }, 1)).resolves.toBe("ok");
107
+ expect(chromeStub.runtime.sendMessage).toHaveBeenCalledOnce();
108
+ // A single argument, otherwise the browser reads it as an extension id
109
+ expect(chromeStub.runtime.sendMessage.mock.calls[0]).toHaveLength(1);
110
+ });
111
+ test("routes tab targets through the runtime when the context has no tabs API", async () => {
112
+ const chromeStub = stubChrome({ tabs: false });
113
+ chromeStub.runtime.sendMessage.mockResolvedValue(messengerReply("ok"));
114
+ await expect(messenger("senderTestMethod", {}, { tabId: 1, frameId: 3 }, 1)).resolves.toBe("ok");
115
+ expect(chromeStub.runtime.sendMessage).toHaveBeenCalledOnce();
116
+ });
117
+ test.each([
118
+ ["defaults to the top frame", { tabId: 1 }, { frameId: 0 }],
119
+ ["forwards an explicit frame", { tabId: 1, frameId: 5 }, { frameId: 5 }],
120
+ [
121
+ "drops frameId to reach every frame",
122
+ { tabId: 1, frameId: "allFrames" },
123
+ {},
124
+ ],
125
+ ])("sends to a tab and %s", async (_name, target, expectedOptions) => {
126
+ const chromeStub = stubChrome();
127
+ chromeStub.tabs.sendMessage.mockResolvedValue(messengerReply("ok"));
128
+ await expect(messenger("senderTestMethod", {}, target, 1)).resolves.toBe("ok");
129
+ expect(chromeStub.tabs.sendMessage).toHaveBeenCalledWith(1, expect.objectContaining({ type: "senderTestMethod" }), expectedOptions);
130
+ });
131
+ test("stamps every message with the marker and an increasing seq", async () => {
132
+ const chromeStub = stubChrome({ tabs: false });
133
+ chromeStub.runtime.sendMessage.mockResolvedValue(messengerReply("ok"));
134
+ await messenger("senderTestMethod", {}, { page: "/options.html" }, 1);
135
+ await messenger("senderTestMethod", {}, { page: "/options.html" }, 2);
136
+ const first = sentEnvelope(chromeStub.runtime.sendMessage, 0);
137
+ const second = sentEnvelope(chromeStub.runtime.sendMessage, 1);
138
+ expect(first.__webextMessenger).toBe(true);
139
+ expect(second.options.seq).toBe(first.options.seq + 1);
140
+ });
141
+ });
142
+ /**
143
+ * Regression guard for #409: 0.35.0 hoisted a `compareTargets()` short-circuit onto
144
+ * the send path, which threw `TypeError: Invalid URL` in opaque-origin frames and
145
+ * mis-routed same-tab messages to the sending frame. Reverted in #410.
146
+ */
147
+ describe("sending from a content script (regression: #409)", () => {
148
+ beforeEach(() => {
149
+ // A sandboxed `about:srcdoc` frame has an opaque origin, so `location.origin`
150
+ // is the literal string "null" and `new URL(page, location.origin)` throws.
151
+ vi.stubGlobal("location", {
152
+ origin: "null",
153
+ protocol: "about:",
154
+ pathname: "srcdoc",
155
+ search: "",
73
156
  });
74
- // Import messenger after mocks are set up
75
- const { messenger } = await import("./sender.js");
76
- // Test: Send message to different tab
77
- const result = await messenger("testMethod", {}, { tabId: 2, frameId: 0 });
78
- // Verify: Message was sent via chrome.runtime.sendMessage
79
- expect(chrome.runtime.sendMessage).toHaveBeenCalledOnce();
80
- expect(result).toBe("remote result");
81
- // Verify: Local handler was not called
82
- expect(mockHandler).not.toHaveBeenCalled();
83
- });
84
- test("should send message when targeting different frame in same tab", async () => {
85
- // Setup: Set thisTarget to have tabId: 1, frameId: 0
86
- const mockThisTarget = {
87
- tabId: 1,
88
- frameId: 0,
89
- page: "test",
90
- };
91
- vi.spyOn(thisTargetModule, "thisTarget", "get").mockReturnValue(mockThisTarget);
92
- // Register a local handler (should not be used)
93
- const mockHandler = vi.fn(async () => "local result");
94
- handlers.set("testMethod", mockHandler);
95
- // Mock chrome.runtime.sendMessage to return a messenger response
96
- vi.mocked(chrome.runtime.sendMessage).mockResolvedValue({
97
- __webextMessenger: true,
98
- value: "remote result",
157
+ });
158
+ test("reaches the runtime from an opaque-origin frame", async () => {
159
+ const chromeStub = stubChrome({ tabs: false });
160
+ chromeStub.runtime.sendMessage.mockResolvedValue(messengerReply("ok"));
161
+ const localHandler = vi.fn();
162
+ handlers.set("senderTestMethod", localHandler);
163
+ // `{page: "any"}` is the target used by `storeTabData()`, the first message
164
+ // every content script sends; in 0.35.0 it threw before routing.
165
+ await expect(messenger("senderTestMethod", {}, { page: "any" }, 1)).resolves.toBe("ok");
166
+ expect(chromeStub.runtime.sendMessage).toHaveBeenCalledOnce();
167
+ expect(localHandler).not.toHaveBeenCalled();
168
+ });
169
+ test("does not handle a same-tab target locally", async () => {
170
+ const chromeStub = stubChrome({ tabs: false });
171
+ chromeStub.runtime.sendMessage.mockResolvedValue(messengerReply("ok"));
172
+ const localHandler = vi.fn();
173
+ handlers.set("senderTestMethod", localHandler);
174
+ // A frame sending to `{tabId}` means the *top* frame, never itself.
175
+ await expect(messenger("senderTestMethod", {}, { tabId: 1 }, 1)).resolves.toBe("ok");
176
+ expect(chromeStub.runtime.sendMessage).toHaveBeenCalledOnce();
177
+ expect(localHandler).not.toHaveBeenCalled();
178
+ });
179
+ });
180
+ describe("messenger() responses", () => {
181
+ test("returns the value from a messenger reply", async () => {
182
+ const chromeStub = stubChrome({ tabs: false });
183
+ chromeStub.runtime.sendMessage.mockResolvedValue(messengerReply({ a: 1 }));
184
+ await expect(messenger("senderTestMethod", {}, { page: "/options.html" }, 1)).resolves.toEqual({ a: 1 });
185
+ });
186
+ test("rethrows a deserialized error from a messenger reply", async () => {
187
+ const chromeStub = stubChrome({ tabs: false });
188
+ chromeStub.runtime.sendMessage.mockResolvedValue(messengerErrorReply("TypeError", "Handler exploded"));
189
+ await expect(messenger("senderTestMethod", {}, { page: "/options.html" }, 1)).rejects.toThrow("Handler exploded");
190
+ // A genuine error from the target must not be retried
191
+ expect(chromeStub.runtime.sendMessage).toHaveBeenCalledOnce();
192
+ });
193
+ test("reports a missing page target when nobody answers", async () => {
194
+ const chromeStub = stubChrome({ tabs: false });
195
+ chromeStub.runtime.sendMessage.mockResolvedValue(undefined);
196
+ await expect(messenger("senderTestMethod", { retry: false }, { page: "/options.html" }, 1)).rejects.toThrow('The target {"page":"/options.html"} for senderTestMethod was not found');
197
+ });
198
+ test("reports a messenger-less tab when nobody answers", async () => {
199
+ const chromeStub = stubChrome();
200
+ chromeStub.tabs.sendMessage.mockResolvedValue(undefined);
201
+ await expect(messenger("senderTestMethod", { retry: false }, { tabId: 1 }, 1)).rejects.toThrow("Messenger was not available in the target");
202
+ });
203
+ test("reports a conflict when a third-party listener answers", async () => {
204
+ const chromeStub = stubChrome({ tabs: false });
205
+ chromeStub.runtime.sendMessage.mockResolvedValue("not a messenger reply");
206
+ await expect(messenger("senderTestMethod", { retry: false }, { page: "/options.html" }, 1)).rejects.toThrow("Conflict: The message senderTestMethod was handled by a third-party listener");
207
+ });
208
+ });
209
+ describe("retry behaviour", () => {
210
+ test("retries until the receiving end exists", async () => {
211
+ const chromeStub = stubChrome({ tabs: false });
212
+ chromeStub.runtime.sendMessage
213
+ .mockRejectedValueOnce(new Error(browserErrorNoReceiver))
214
+ .mockResolvedValueOnce(messengerReply("ok"));
215
+ const promise = messenger("senderTestMethod", {}, { page: "/options.html" }, 1);
216
+ const assertion = expect(promise).resolves.toBe("ok");
217
+ await vi.advanceTimersByTimeAsync(500);
218
+ await assertion;
219
+ expect(chromeStub.runtime.sendMessage).toHaveBeenCalledTimes(2);
220
+ });
221
+ test("retries a MessengerError, which means the target is still booting", async () => {
222
+ const chromeStub = stubChrome({ tabs: false });
223
+ chromeStub.runtime.sendMessage
224
+ .mockResolvedValueOnce(messengerErrorReply("MessengerError", "No handlers registered in x"))
225
+ .mockResolvedValueOnce(messengerReply("ok"));
226
+ const promise = messenger("senderTestMethod", {}, { page: "/options.html" }, 1);
227
+ const assertion = expect(promise).resolves.toBe("ok");
228
+ await vi.advanceTimersByTimeAsync(500);
229
+ await assertion;
230
+ expect(chromeStub.runtime.sendMessage).toHaveBeenCalledTimes(2);
231
+ });
232
+ test("makes a single attempt when retry is disabled", async () => {
233
+ const chromeStub = stubChrome({ tabs: false });
234
+ chromeStub.runtime.sendMessage.mockRejectedValue(new Error(browserErrorNoReceiver));
235
+ await expect(messenger("senderTestMethod", { retry: false }, { page: "/options.html" }, 1)).rejects.toThrow('The target {"page":"/options.html"} for senderTestMethod was not found');
236
+ expect(chromeStub.runtime.sendMessage).toHaveBeenCalledOnce();
237
+ });
238
+ test("does not retry once the target has closed early", async () => {
239
+ const chromeStub = stubChrome({ tabs: false });
240
+ chromeStub.runtime.sendMessage.mockRejectedValue(new Error(browserErrorClosedEarly));
241
+ await expect(messenger("senderTestMethod", {}, { page: "/options.html" }, 1)).rejects.toThrow(errorTargetClosedEarly);
242
+ expect(chromeStub.runtime.sendMessage).toHaveBeenCalledOnce();
243
+ });
244
+ test("does not retry a missing external extension", async () => {
245
+ const chromeStub = stubChrome({ tabs: false });
246
+ chromeStub.runtime.sendMessage.mockRejectedValue(new Error(browserErrorNoReceiver));
247
+ await expect(messenger("senderTestMethod", {}, { extensionId: "other-extension" }, 1)).rejects.toThrow(ExtensionNotFoundError);
248
+ expect(chromeStub.runtime.sendMessage).toHaveBeenCalledOnce();
249
+ });
250
+ test("stops as soon as the extension context is invalidated", async () => {
251
+ const chromeStub = stubChrome({ tabs: false, invalidated: true });
252
+ vi.mocked(isExtensionContext).mockReturnValue(true);
253
+ chromeStub.runtime.sendMessage.mockRejectedValue(new Error(browserErrorNoReceiver));
254
+ await expect(messenger("senderTestMethod", {}, { page: "/options.html" }, 1)).rejects.toThrow("Extension context invalidated.");
255
+ expect(chromeStub.runtime.sendMessage).toHaveBeenCalledOnce();
256
+ });
257
+ test("stops retrying when the target tab is gone", async () => {
258
+ const chromeStub = stubChrome();
259
+ chromeStub.tabs.sendMessage.mockRejectedValue(new Error(browserErrorNoReceiver));
260
+ chromeStub.tabs.get.mockRejectedValue(new Error("No tab with id: 1."));
261
+ await expect(messenger("senderTestMethod", {}, { tabId: 1 }, 1)).rejects.toThrow(errorTabDoesntExist);
262
+ expect(chromeStub.tabs.sendMessage).toHaveBeenCalledOnce();
263
+ });
264
+ test("stops retrying when the target tab was discarded", async () => {
265
+ const chromeStub = stubChrome();
266
+ chromeStub.tabs.sendMessage.mockRejectedValue(new Error(browserErrorNoReceiver));
267
+ chromeStub.tabs.get.mockResolvedValue({ discarded: true });
268
+ await expect(messenger("senderTestMethod", {}, { tabId: 1 }, 1)).rejects.toThrow(errorTabWasDiscarded);
269
+ expect(chromeStub.tabs.sendMessage).toHaveBeenCalledOnce();
270
+ });
271
+ test("gives up after the retry window and announces attempts-exhausted", async () => {
272
+ const chromeStub = stubChrome({ tabs: false });
273
+ chromeStub.runtime.sendMessage.mockRejectedValue(new Error("No handlers registered in background"));
274
+ const failedAttempt = trackEvent("failed-attempt");
275
+ const attemptsExhausted = trackEvent("attempts-exhausted");
276
+ const promise = messenger("senderTestMethod", {}, { page: "/options.html" }, 1);
277
+ const assertion = expect(promise).rejects.toThrow("No handlers registered in background");
278
+ await vi.advanceTimersByTimeAsync(10_000);
279
+ await assertion;
280
+ expect(attemptsExhausted).toHaveBeenCalledOnce();
281
+ expect(failedAttempt.mock.calls.length).toBe(chromeStub.runtime.sendMessage.mock.calls.length);
282
+ expect(chromeStub.runtime.sendMessage.mock.calls.length).toBeGreaterThan(1);
283
+ });
284
+ test("backs off exponentially rather than hammering the target", async () => {
285
+ const chromeStub = stubChrome({ tabs: false });
286
+ chromeStub.runtime.sendMessage.mockRejectedValue(new Error(browserErrorNoReceiver));
287
+ const promise = messenger("senderTestMethod", {}, { page: "/options.html" }, 1);
288
+ const assertion = expect(promise).rejects.toThrow(MessengerError);
289
+ await vi.advanceTimersByTimeAsync(100);
290
+ expect(chromeStub.runtime.sendMessage).toHaveBeenCalledTimes(2);
291
+ // The second wait is longer than the first, so no third attempt yet
292
+ await vi.advanceTimersByTimeAsync(100);
293
+ expect(chromeStub.runtime.sendMessage).toHaveBeenCalledTimes(2);
294
+ await vi.advanceTimersByTimeAsync(10_000);
295
+ await assertion;
296
+ // The safety cap in `manageMessage` must never be the reason we stopped
297
+ expect(chromeStub.runtime.sendMessage.mock.calls.length).toBeLessThan(15);
298
+ });
299
+ });
300
+ describe("notifications", () => {
301
+ test("return undefined, send once, and swallow errors", async () => {
302
+ const chromeStub = stubChrome({ tabs: false });
303
+ chromeStub.runtime.sendMessage.mockRejectedValue(new Error(browserErrorNoReceiver));
304
+ // Re-typed to capture the runtime value: notifications are typed `void`, and
305
+ // callers rely on nothing awaitable coming back.
306
+ const send = messenger;
307
+ const result = send("senderTestMethod", { isNotification: true }, { page: "/options.html" }, 1);
308
+ expect(result).toBeUndefined();
309
+ await vi.advanceTimersByTimeAsync(10_000);
310
+ expect(chromeStub.runtime.sendMessage).toHaveBeenCalledOnce();
311
+ });
312
+ });
313
+ describe("getMethod() and getNotifier()", () => {
314
+ test("getMethod binds a fixed target", async () => {
315
+ const chromeStub = stubChrome({ tabs: false });
316
+ chromeStub.runtime.sendMessage.mockResolvedValue(messengerReply("ok"));
317
+ const method = getMethod("senderTestMethod", { page: "/options.html" });
318
+ await expect(method(1, 2)).resolves.toBe("ok");
319
+ expect(sentEnvelope(chromeStub.runtime.sendMessage)).toMatchObject({
320
+ args: [1, 2],
321
+ target: { page: "/options.html" },
99
322
  });
100
- // Import messenger after mocks are set up
101
- const { messenger } = await import("./sender.js");
102
- // Test: Send message to different frame in same tab
103
- const result = await messenger("testMethod", {}, { tabId: 1, frameId: 1 });
104
- // Verify: Message was sent via chrome.runtime.sendMessage
105
- expect(chrome.runtime.sendMessage).toHaveBeenCalledOnce();
106
- expect(result).toBe("remote result");
107
- // Verify: Local handler was not called
108
- expect(mockHandler).not.toHaveBeenCalled();
109
- });
110
- test("should send message when targeting allFrames", async () => {
111
- // Setup: Set thisTarget to have tabId: 1, frameId: 0
112
- const mockThisTarget = {
113
- tabId: 1,
114
- frameId: 0,
115
- page: "test",
116
- };
117
- vi.spyOn(thisTargetModule, "thisTarget", "get").mockReturnValue(mockThisTarget);
118
- // Register a local handler (should not be used for allFrames)
119
- const mockHandler = vi.fn(async () => "local result");
120
- handlers.set("testMethod", mockHandler);
121
- // Mock chrome.runtime.sendMessage to return a messenger response
122
- vi.mocked(chrome.runtime.sendMessage).mockResolvedValue({
123
- __webextMessenger: true,
124
- value: "remote result",
323
+ });
324
+ test("getMethod awaits a promised target", async () => {
325
+ const chromeStub = stubChrome({ tabs: false });
326
+ chromeStub.runtime.sendMessage.mockResolvedValue(messengerReply("ok"));
327
+ const method = getMethod("senderTestMethod", Promise.resolve({ tabId: 1, frameId: 2 }));
328
+ await expect(method(1)).resolves.toBe("ok");
329
+ expect(sentEnvelope(chromeStub.runtime.sendMessage)).toMatchObject({
330
+ target: { tabId: 1, frameId: 2 },
125
331
  });
126
- // Import messenger after mocks are set up
127
- const { messenger } = await import("./sender.js");
128
- // Test: Send message to allFrames in same tab
129
- const result = await messenger("testMethod", {}, { tabId: 1, frameId: "allFrames" });
130
- // Verify: Message was sent via chrome.runtime.sendMessage
131
- expect(chrome.runtime.sendMessage).toHaveBeenCalledOnce();
132
- expect(result).toBe("remote result");
133
- // Verify: Local handler was not called
134
- expect(mockHandler).not.toHaveBeenCalled();
135
- });
136
- test("should throw error when no local handler registered for same tab/frame", async () => {
137
- // Setup: Set thisTarget to have tabId: 1, frameId: 0
138
- const mockThisTarget = {
139
- tabId: 1,
140
- frameId: 0,
141
- page: "test",
142
- };
143
- vi.spyOn(thisTargetModule, "thisTarget", "get").mockReturnValue(mockThisTarget);
144
- // No handler registered
145
- // Import messenger and MessengerError after mocks are set up
146
- const { messenger } = await import("./sender.js");
147
- const { MessengerError } = await import("./shared.js");
148
- // Test: Send message to same tab and frame without handler
149
- try {
150
- await messenger("testMethod", {}, { tabId: 1, frameId: 0 });
151
- expect.fail("Should have thrown an error");
152
- }
153
- catch (error) {
154
- expect(error).toBeInstanceOf(MessengerError);
155
- expect(error.message).toBe("No handler registered locally for testMethod");
156
- }
157
- // Verify: No message was sent via chrome.runtime.sendMessage
158
- expect(chrome.runtime.sendMessage).not.toHaveBeenCalled();
332
+ });
333
+ test("getMethod without a target takes it as the first argument", async () => {
334
+ const chromeStub = stubChrome({ tabs: false });
335
+ chromeStub.runtime.sendMessage.mockResolvedValue(messengerReply("ok"));
336
+ const method = getMethod("senderTestMethod");
337
+ await expect(method({ page: "/options.html" }, 1)).resolves.toBe("ok");
338
+ expect(sentEnvelope(chromeStub.runtime.sendMessage)).toMatchObject({
339
+ args: [1],
340
+ target: { page: "/options.html" },
341
+ });
342
+ });
343
+ test("getNotifier returns void and never rejects", async () => {
344
+ const chromeStub = stubChrome({ tabs: false });
345
+ chromeStub.runtime.sendMessage.mockRejectedValue(new Error("boom"));
346
+ const notify = getNotifier("senderTestMethod", { page: "/options.html" });
347
+ const result = notify(1);
348
+ expect(result).toBeUndefined();
349
+ await vi.advanceTimersByTimeAsync(10_000);
350
+ expect(chromeStub.runtime.sendMessage).toHaveBeenCalledOnce();
159
351
  });
160
352
  });
@@ -16,6 +16,7 @@ const tab = {
16
16
  selected: true,
17
17
  autoDiscardable: false,
18
18
  groupId: -1,
19
+ lastAccessed: 0,
19
20
  };
20
21
  const senders = {
21
22
  contentScript: { tab },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "webext-messenger",
3
- "version": "0.35.0",
3
+ "version": "0.37.0",
4
4
  "description": "Browser Extension component messaging framework",
5
5
  "keywords": [],
6
6
  "repository": "pixiebrix/webext-messenger",
@@ -27,18 +27,18 @@
27
27
  "watch": "tsc --watch"
28
28
  },
29
29
  "dependencies": {
30
- "one-event": "^4.3.0",
31
- "serialize-error": "^12.0.0",
32
- "type-fest": "^5.0.1",
33
- "webext-detect": "^5.3.2"
30
+ "one-event": "^4.4.0",
31
+ "serialize-error": "^13.0.2",
32
+ "type-fest": "^5.10.0",
33
+ "webext-detect": "^6.0.0"
34
34
  },
35
35
  "@parcel/resolver-default": {
36
36
  "packageExports": true
37
37
  },
38
38
  "devDependencies": {
39
- "@parcel/config-webextension": "^2.16.0",
40
- "@sindresorhus/tsconfig": "^8.0.1",
41
- "@types/chrome": "^0.1.22",
39
+ "@parcel/config-webextension": "^2.16.3",
40
+ "@sindresorhus/tsconfig": "^8.1.0",
41
+ "@types/chrome": "^0.3.0",
42
42
  "@types/tape": "^5.8.1",
43
43
  "buffer": "^6.0.3",
44
44
  "eslint": "^8.57.0",
@@ -49,9 +49,9 @@
49
49
  "path-browserify": "^1.0.1",
50
50
  "process": "^0.11.10",
51
51
  "stream-browserify": "^3.0.0",
52
- "tape": "^5.9.0",
53
- "typescript": "^5.9.3",
54
- "vitest": "^3.2.4"
52
+ "tape": "^5.10.2",
53
+ "typescript": "^6.0.3",
54
+ "vitest": "^5.0.2"
55
55
  },
56
56
  "targets": {
57
57
  "main": false,