Skip to content

Commit afd2d88

Browse files
committed
test(extension): cover background.js message routing and options.js session UI
background.js (the service-worker onMessage router) and options.js (the options-page controller) had no unit tests, unlike auth.js and content.js. A regression in either -- background.js returning false instead of true and silently breaking every async response, or refreshSettings() misclassifying an expired session as valid -- would only surface by manually loading the unpacked extension. Add extension-background.test.ts and extension-options.test.ts using the vm Script/createContext technique extension-content.test.ts established. Both files statically import from auth.js, which a plain vm Script cannot execute, so the import line is stripped and the handlers/auth helpers are injected as context globals (the host Error is shared so 'error instanceof Error' matches the single-realm extension). Covers background.js's pull-context/logout dispatch, its Error-vs-stringified error mapping, and the unrecognized/nullish return false paths; and options.js's refreshSettings() three display branches, the conditional token-store submit path, and the logout error-recovery fallback. Closes #7461
1 parent c18b123 commit afd2d88

2 files changed

Lines changed: 460 additions & 0 deletions

File tree

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
import { readFileSync } from "node:fs";
2+
import { Script, createContext } from "node:vm";
3+
import { describe, expect, it, vi } from "vitest";
4+
5+
// background.js statically imports its two handlers from ./auth.js. The vm `Script` runner cannot
6+
// execute a top-level ESM `import`, so we strip that line and inject stubbed handlers as context
7+
// globals -- the free identifiers `requestPullContext`/`logoutExtensionSession` resolve from them.
8+
// This mirrors content.js's `__LOOPOVER_EXTENSION_TEST__` seam while keeping background.js's real
9+
// message-routing and error-to-response mapping (lines 3-8) under test.
10+
const backgroundSource = readFileSync(
11+
"apps/loopover-extension/background.js",
12+
"utf8",
13+
).replace(/^import\s*\{[\s\S]*?\}\s*from\s*["']\.\/auth\.js["'];?\n?/, "");
14+
15+
const flush = () => new Promise((resolve) => setTimeout(resolve, 0));
16+
17+
describe("extension background message router", () => {
18+
it("resolves loopover:pull-context via requestPullContext and responds { ok: true, payload }", async () => {
19+
const requestPullContext = vi.fn(async () => ({ panels: [] }));
20+
const logoutExtensionSession = vi.fn();
21+
const { listener } = loadBackground({
22+
requestPullContext,
23+
logoutExtensionSession,
24+
});
25+
const sendResponse = vi.fn();
26+
27+
const message = {
28+
type: "loopover:pull-context",
29+
owner: "JSONbored",
30+
repo: "loopover",
31+
pullNumber: 148,
32+
};
33+
const returned = listener(message, {}, sendResponse);
34+
await flush();
35+
36+
expect(returned).toBe(true);
37+
expect(requestPullContext).toHaveBeenCalledWith(message);
38+
expect(logoutExtensionSession).not.toHaveBeenCalled();
39+
expect(sendResponse).toHaveBeenCalledWith({
40+
ok: true,
41+
payload: { panels: [] },
42+
});
43+
});
44+
45+
it("resolves loopover:logout via logoutExtensionSession and responds { ok: true, payload }", async () => {
46+
const requestPullContext = vi.fn();
47+
const logoutExtensionSession = vi.fn(async () => ({ ok: true }));
48+
const { listener } = loadBackground({
49+
requestPullContext,
50+
logoutExtensionSession,
51+
});
52+
const sendResponse = vi.fn();
53+
54+
const returned = listener({ type: "loopover:logout" }, {}, sendResponse);
55+
await flush();
56+
57+
expect(returned).toBe(true);
58+
expect(logoutExtensionSession).toHaveBeenCalledTimes(1);
59+
expect(requestPullContext).not.toHaveBeenCalled();
60+
expect(sendResponse).toHaveBeenCalledWith({
61+
ok: true,
62+
payload: { ok: true },
63+
});
64+
});
65+
66+
it("maps a rejected Error to { ok: false, error: message }", async () => {
67+
const requestPullContext = vi.fn(async () => {
68+
throw new Error("pull context unavailable");
69+
});
70+
const { listener } = loadBackground({
71+
requestPullContext,
72+
logoutExtensionSession: vi.fn(),
73+
});
74+
const sendResponse = vi.fn();
75+
76+
listener({ type: "loopover:pull-context" }, {}, sendResponse);
77+
await flush();
78+
79+
expect(sendResponse).toHaveBeenCalledWith({
80+
ok: false,
81+
error: "pull context unavailable",
82+
});
83+
});
84+
85+
it("maps a rejected non-Error value to { ok: false, error: String(value) }", async () => {
86+
const logoutExtensionSession = vi.fn(async () => {
87+
// eslint-disable-next-line no-throw-literal
88+
throw "session gone";
89+
});
90+
const { listener } = loadBackground({
91+
requestPullContext: vi.fn(),
92+
logoutExtensionSession,
93+
});
94+
const sendResponse = vi.fn();
95+
96+
listener({ type: "loopover:logout" }, {}, sendResponse);
97+
await flush();
98+
99+
expect(sendResponse).toHaveBeenCalledWith({
100+
ok: false,
101+
error: "session gone",
102+
});
103+
});
104+
105+
it("returns false for an unrecognized message type without dispatching either handler", () => {
106+
const requestPullContext = vi.fn();
107+
const logoutExtensionSession = vi.fn();
108+
const { listener } = loadBackground({
109+
requestPullContext,
110+
logoutExtensionSession,
111+
});
112+
const sendResponse = vi.fn();
113+
114+
expect(listener({ type: "loopover:unknown" }, {}, sendResponse)).toBe(
115+
false,
116+
);
117+
expect(requestPullContext).not.toHaveBeenCalled();
118+
expect(logoutExtensionSession).not.toHaveBeenCalled();
119+
expect(sendResponse).not.toHaveBeenCalled();
120+
});
121+
122+
it("returns false for a nullish message without dispatching either handler", () => {
123+
const requestPullContext = vi.fn();
124+
const logoutExtensionSession = vi.fn();
125+
const { listener } = loadBackground({
126+
requestPullContext,
127+
logoutExtensionSession,
128+
});
129+
130+
expect(listener(null, {}, vi.fn())).toBe(false);
131+
expect(requestPullContext).not.toHaveBeenCalled();
132+
expect(logoutExtensionSession).not.toHaveBeenCalled();
133+
});
134+
});
135+
136+
function loadBackground(handlers: {
137+
requestPullContext: (...args: unknown[]) => unknown;
138+
logoutExtensionSession: (...args: unknown[]) => unknown;
139+
}) {
140+
let listener:
141+
| ((
142+
message: unknown,
143+
sender: unknown,
144+
sendResponse: (response: unknown) => void,
145+
) => unknown)
146+
| undefined;
147+
const context: Record<string, unknown> = {
148+
// Share the host Error so background.js's `error instanceof Error` matches the errors our stubs
149+
// throw -- a contextified vm has its own Error intrinsic, unlike the extension's single realm.
150+
Error,
151+
chrome: {
152+
runtime: {
153+
onMessage: {
154+
addListener: (fn: typeof listener) => {
155+
listener = fn;
156+
},
157+
},
158+
},
159+
},
160+
requestPullContext: handlers.requestPullContext,
161+
logoutExtensionSession: handlers.logoutExtensionSession,
162+
};
163+
context.globalThis = context;
164+
const vmContext = createContext(context);
165+
new Script(backgroundSource).runInContext(vmContext);
166+
if (!listener)
167+
throw new Error("background.js did not register an onMessage listener");
168+
return { listener };
169+
}

0 commit comments

Comments
 (0)