Skip to content

Commit 5db59ed

Browse files
test(miner-extension): bring the browser extension under a real coverage gate (#4865) (#5644)
Co-authored-by: Andriy Polanski <andriy.polanski@gmail.com>
1 parent dbcb7a3 commit 5db59ed

11 files changed

Lines changed: 656 additions & 5 deletions

File tree

apps/gittensory-miner-extension/README.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,17 @@ The extension does not request the `unlimitedStorage` permission, so a paste is
2828
being parsed or saved once it exceeds a conservative size bound well under `chrome.storage.local`'s default 10 MiB
2929
quota, instead of silently failing to save or leaving storage partially written.
3030

31+
## Test coverage
32+
33+
`npm test` runs with `--coverage` enabled (v8 provider) and enforces `vitest.config.ts`'s
34+
`coverage.thresholds` — a measured baseline (#4865), not an aspirational target. The suite imports
35+
`background.js`, `opportunity-badge.js`, and `toolbar-badge.js` directly (via the existing
36+
`__GITTENSORY_MINER_EXTENSION_TEST__` hook) so v8 can attribute coverage; the root `test/unit/miner-*.test.ts`
37+
files remain as broader behavior tests through the `node:vm` harness.
38+
39+
`content.js` and `options.js` are deliberately deferred — they need a jsdom mount harness before
40+
coverage attribution is meaningful. Raise thresholds per-PR as those scripts get covered.
41+
3142
## Host permissions
3243

3344
`manifest.json` grants `https://github.com/*` (for the issue-page content script) plus loopback host permissions —

apps/gittensory-miner-extension/package.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@
77
"scripts": {
88
"build": "node ../../scripts/build-miner-extension.mjs",
99
"lint": "node --check background.js && node --check content.js && node --check opportunity-badge.js && node --check options.js && node --check toolbar-badge.js",
10-
"typecheck": "npm run lint"
10+
"typecheck": "npm run lint",
11+
"test": "vitest run --coverage"
12+
},
13+
"devDependencies": {
14+
"vitest": "^4.1.9"
1115
}
1216
}
Lines changed: 271 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,271 @@
1+
import { afterEach, describe, expect, it, vi } from "vitest";
2+
3+
import { flush, jsonFetch, loadExtensionModules } from "./helpers.js";
4+
import {
5+
TOOLBAR_BADGE_EMPTY_COLOR,
6+
TOOLBAR_BADGE_HAS_DATA_COLOR,
7+
TOOLBAR_BADGE_NO_DATA_TEXT,
8+
} from "../toolbar-badge.js";
9+
10+
const rankedEntry = {
11+
repoFullName: "JSONbored/gittensory",
12+
issueNumber: 145,
13+
rankScore: 0.82,
14+
laneFit: 0.9,
15+
freshness: 0.8,
16+
potential: 0.7,
17+
feasibility: 0.75,
18+
dupRisk: 0.1,
19+
};
20+
21+
describe("background service worker", () => {
22+
afterEach(() => {
23+
vi.unstubAllGlobals();
24+
vi.resetModules();
25+
vi.restoreAllMocks();
26+
});
27+
28+
it("returns ready issue context for a watched repo with a cached ranked candidate", async () => {
29+
const { backgroundInternals } = await loadExtensionModules({
30+
watchedRepos: ["JSONbored/gittensory"],
31+
rankedCandidates: [rankedEntry],
32+
rankedCandidatesSavedAt: Date.parse("2026-07-10T11:00:00.000Z"),
33+
});
34+
35+
const payload = await backgroundInternals.loadIssueOpportunityContext({
36+
owner: "JSONbored",
37+
repo: "gittensory",
38+
issueNumber: 145,
39+
});
40+
41+
expect(payload.status).toBe("ready");
42+
expect(payload.savedAt).toBe(Date.parse("2026-07-10T11:00:00.000Z"));
43+
expect((payload.badge as { tier: string }).tier).toBe("High");
44+
});
45+
46+
it("returns repo-not-watched and no-signal states", async () => {
47+
const unwatched = await loadExtensionModules({ watchedRepos: ["other/repo"] });
48+
const notWatched = await unwatched.backgroundInternals.loadIssueOpportunityContext({
49+
owner: "JSONbored",
50+
repo: "gittensory",
51+
issueNumber: 145,
52+
});
53+
expect(notWatched.status).toBe("repo-not-watched");
54+
expect(notWatched.badge).toBeNull();
55+
56+
const empty = await loadExtensionModules({
57+
watchedRepos: ["JSONbored/gittensory"],
58+
rankedCandidates: [],
59+
});
60+
const noSignal = await empty.backgroundInternals.loadIssueOpportunityContext({
61+
owner: "JSONbored",
62+
repo: "gittensory",
63+
issueNumber: 145,
64+
});
65+
expect(noSignal.status).toBe("no-signal");
66+
expect(noSignal.badge).toBeNull();
67+
});
68+
69+
it("normalizes watched repos and degrades malformed ranked-candidate storage", async () => {
70+
const { backgroundInternals } = await loadExtensionModules({
71+
watchedRepos: [" JSONbored/gittensory ", "", 42 as unknown as string],
72+
rankedCandidates: "bad" as unknown as unknown[],
73+
rankedCandidatesSavedAt: "not-a-number" as unknown as number,
74+
});
75+
76+
expect(await backgroundInternals.loadMinerExtensionSettings()).toEqual({
77+
watchedRepos: ["JSONbored/gittensory", "42"],
78+
});
79+
expect(await backgroundInternals.loadRankedCandidates()).toEqual({
80+
rankedCandidates: [],
81+
savedAt: null,
82+
});
83+
84+
const malformedSettings = await loadExtensionModules({
85+
syncGetResult: { watchedRepos: "not-an-array" },
86+
});
87+
expect(await malformedSettings.backgroundInternals.loadMinerExtensionSettings()).toEqual({
88+
watchedRepos: [],
89+
});
90+
});
91+
92+
it("syncs ranked candidates from the miner UI and leaves storage untouched on failure", async () => {
93+
const candidates = [{ repoFullName: "acme/widgets", issueNumber: 1, rankScore: 0.8 }];
94+
const success = await loadExtensionModules({
95+
fetchImpl: jsonFetch(200, { candidates }),
96+
});
97+
const ok = await success.backgroundInternals.syncRankedCandidatesFromMinerUi();
98+
expect(ok.ok).toBe(true);
99+
expect(ok.count).toBe(1);
100+
expect(success.localSetCalls).toHaveLength(1);
101+
102+
const httpError = await loadExtensionModules({ fetchImpl: jsonFetch(401, {}) });
103+
const unauthorized = await httpError.backgroundInternals.syncRankedCandidatesFromMinerUi();
104+
expect(unauthorized).toMatchObject({ ok: false, error: "miner UI responded 401" });
105+
expect(httpError.localSetCalls).toHaveLength(0);
106+
107+
const malformed = await loadExtensionModules({ fetchImpl: jsonFetch(200, { candidates: "nope" }) });
108+
const badShape = await malformed.backgroundInternals.syncRankedCandidatesFromMinerUi();
109+
expect(badShape).toMatchObject({
110+
ok: false,
111+
error: "miner UI returned an unexpected payload shape",
112+
});
113+
114+
const network = await loadExtensionModules({
115+
fetchImpl: (async () => {
116+
throw new Error("connection refused");
117+
}) as typeof fetch,
118+
});
119+
const failed = await network.backgroundInternals.syncRankedCandidatesFromMinerUi();
120+
expect(failed).toMatchObject({ ok: false, error: "connection refused" });
121+
});
122+
123+
it("falls back to the default miner UI URL when sync storage is empty or malformed", async () => {
124+
const empty = await loadExtensionModules({ minerUiUrl: "" });
125+
expect(await empty.backgroundInternals.loadMinerUiUrl()).toBe(
126+
empty.backgroundInternals.DEFAULT_MINER_UI_URL,
127+
);
128+
129+
const malformed = await loadExtensionModules({ minerUiUrl: 123 as unknown as string });
130+
expect(await malformed.backgroundInternals.loadMinerUiUrl()).toBe(
131+
malformed.backgroundInternals.DEFAULT_MINER_UI_URL,
132+
);
133+
});
134+
135+
it("stringifies non-Error sync failures and issue-context rejections", async () => {
136+
const syncFail = await loadExtensionModules({
137+
fetchImpl: (async () => {
138+
throw "offline";
139+
}) as typeof fetch,
140+
});
141+
const syncResult = await syncFail.backgroundInternals.syncRankedCandidatesFromMinerUi();
142+
expect(syncResult).toMatchObject({ ok: false, error: "offline" });
143+
144+
const mod = await loadExtensionModules({
145+
watchedRepos: ["JSONbored/gittensory"],
146+
rankedCandidates: [rankedEntry],
147+
syncGetThrows: true,
148+
syncGetRejectsWith: "storage blew up",
149+
});
150+
const response = await mod.dispatchMessage({
151+
type: mod.backgroundInternals.ISSUE_CONTEXT_MESSAGE,
152+
owner: "JSONbored",
153+
repo: "gittensory",
154+
issueNumber: 145,
155+
});
156+
expect((response as { ok: boolean; error: string }).error).toBeTruthy();
157+
});
158+
159+
it("matches watched repos case-insensitively", async () => {
160+
const { backgroundInternals } = await loadExtensionModules({
161+
watchedRepos: ["jsonbored/gittensory"],
162+
rankedCandidates: [rankedEntry],
163+
});
164+
const payload = await backgroundInternals.loadIssueOpportunityContext({
165+
owner: "JSONbored",
166+
repo: "gittensory",
167+
issueNumber: 145,
168+
});
169+
expect(payload.status).toBe("ready");
170+
});
171+
172+
it("routes runtime messages for ping, issue context, and sync", async () => {
173+
const mod = await loadExtensionModules({
174+
watchedRepos: ["JSONbored/gittensory"],
175+
rankedCandidates: [rankedEntry],
176+
fetchImpl: jsonFetch(200, { candidates: [rankedEntry] }),
177+
});
178+
179+
const ping = await mod.dispatchMessage({ type: mod.backgroundInternals.PING_MESSAGE });
180+
expect(ping).toEqual({ ok: true, payload: { ready: true } });
181+
182+
const context = await mod.dispatchMessage({
183+
type: mod.backgroundInternals.ISSUE_CONTEXT_MESSAGE,
184+
owner: "JSONbored",
185+
repo: "gittensory",
186+
issueNumber: 145,
187+
});
188+
expect((context as { payload: { status: string } }).payload.status).toBe("ready");
189+
190+
const sync = await mod.dispatchMessage({
191+
type: mod.backgroundInternals.SYNC_RANKED_CANDIDATES_MESSAGE,
192+
});
193+
expect((sync as { payload: { ok: boolean } }).payload.ok).toBe(true);
194+
195+
const ignored = await mod.dispatchMessage({ type: "unknown" });
196+
expect(ignored).toBeUndefined();
197+
expect(await mod.dispatchMessage(null)).toBeUndefined();
198+
});
199+
200+
it("paints and repaints the toolbar badge from storage changes", async () => {
201+
const mod = await loadExtensionModules({ rankedCandidates: [1, 2] });
202+
await flush();
203+
expect(mod.setBadgeText).toHaveBeenCalledWith({ text: "2" });
204+
expect(mod.setBadgeBackgroundColor).toHaveBeenCalledWith({
205+
color: TOOLBAR_BADGE_HAS_DATA_COLOR,
206+
});
207+
208+
mod.setBadgeText.mockClear();
209+
await mod.backgroundInternals.refreshToolbarBadge();
210+
expect(mod.setBadgeText).toHaveBeenLastCalledWith({ text: "2" });
211+
212+
const never = await loadExtensionModules({ rankedCandidates: undefined });
213+
await flush();
214+
never.setBadgeText.mockClear();
215+
await never.backgroundInternals.refreshToolbarBadge();
216+
expect(never.setBadgeText).toHaveBeenLastCalledWith({ text: TOOLBAR_BADGE_NO_DATA_TEXT });
217+
218+
const empty = await loadExtensionModules({ rankedCandidates: [] });
219+
await flush();
220+
empty.setBadgeText.mockClear();
221+
await empty.backgroundInternals.refreshToolbarBadge();
222+
expect(empty.setBadgeText).toHaveBeenLastCalledWith({ text: "" });
223+
expect(empty.setBadgeBackgroundColor).toHaveBeenLastCalledWith({
224+
color: TOOLBAR_BADGE_EMPTY_COLOR,
225+
});
226+
227+
const live = await loadExtensionModules({ rankedCandidates: [9] });
228+
await flush();
229+
live.setBadgeText.mockClear();
230+
live.fireChange({ rankedCandidates: { newValue: [9] } }, "local");
231+
await flush();
232+
expect(live.setBadgeText).toHaveBeenCalledTimes(1);
233+
234+
live.setBadgeText.mockClear();
235+
live.fireChange({ rankedCandidates: { newValue: [9] } }, "sync");
236+
live.fireChange({ watchedRepos: { newValue: [] } }, "local");
237+
await flush();
238+
expect(live.setBadgeText).not.toHaveBeenCalled();
239+
});
240+
241+
it("swallows chrome.action failures during toolbar refresh", async () => {
242+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
243+
const mod = await loadExtensionModules({ rankedCandidates: [1], failAction: true });
244+
await flush();
245+
await expect(mod.backgroundInternals.refreshToolbarBadge()).resolves.toBeUndefined();
246+
expect(warn).toHaveBeenCalled();
247+
});
248+
249+
it("registers ambient sync alarms and lifecycle hooks when chrome surfaces exist", async () => {
250+
const mod = await loadExtensionModules({
251+
withAlarms: true,
252+
withLifecycle: true,
253+
fetchImpl: jsonFetch(200, { candidates: [] }),
254+
});
255+
256+
expect(mod.alarmCreateCalls[0]?.[0]).toBe("gittensory-miner:sync-ranked-candidates");
257+
mod.dispatchStartup();
258+
mod.dispatchInstalled();
259+
mod.dispatchAlarm("gittensory-miner:sync-ranked-candidates");
260+
mod.dispatchAlarm("other-alarm");
261+
await flush();
262+
expect(mod.localSetCalls.length).toBeGreaterThan(0);
263+
});
264+
265+
it("no-ops toolbar wiring when chrome.action is unavailable", async () => {
266+
const mod = await loadExtensionModules({ rankedCandidates: [1, 2, 3], withAction: false });
267+
await flush();
268+
expect(typeof mod.backgroundInternals.refreshToolbarBadge).toBe("function");
269+
expect(mod.setBadgeText).not.toHaveBeenCalled();
270+
});
271+
});

0 commit comments

Comments
 (0)