From 76285dca8d26dd84d7f9a3e323a9f95288f1b079 Mon Sep 17 00:00:00 2001 From: Andriy Polanski Date: Mon, 13 Jul 2026 18:24:25 +0000 Subject: [PATCH] test(miner-extension): bring the browser extension under a real coverage gate (#4865) --- apps/gittensory-miner-extension/README.md | 11 + apps/gittensory-miner-extension/package.json | 6 +- .../test/background.test.ts | 271 ++++++++++++++++++ .../test/helpers.ts | 195 +++++++++++++ .../test/opportunity-badge.test.ts | 85 ++++++ .../test/toolbar-badge.test.ts | 39 +++ .../vitest.config.ts | 23 ++ apps/gittensory-miner-ui/README.md | 4 +- package-lock.json | 5 +- package.json | 2 +- test/unit/codecov-policy.test.ts | 20 ++ 11 files changed, 656 insertions(+), 5 deletions(-) create mode 100644 apps/gittensory-miner-extension/test/background.test.ts create mode 100644 apps/gittensory-miner-extension/test/helpers.ts create mode 100644 apps/gittensory-miner-extension/test/opportunity-badge.test.ts create mode 100644 apps/gittensory-miner-extension/test/toolbar-badge.test.ts create mode 100644 apps/gittensory-miner-extension/vitest.config.ts diff --git a/apps/gittensory-miner-extension/README.md b/apps/gittensory-miner-extension/README.md index 02506e3968..624eb88cdf 100644 --- a/apps/gittensory-miner-extension/README.md +++ b/apps/gittensory-miner-extension/README.md @@ -28,6 +28,17 @@ The extension does not request the `unlimitedStorage` permission, so a paste is being parsed or saved once it exceeds a conservative size bound well under `chrome.storage.local`'s default 10 MiB quota, instead of silently failing to save or leaving storage partially written. +## Test coverage + +`npm test` runs with `--coverage` enabled (v8 provider) and enforces `vitest.config.ts`'s +`coverage.thresholds` — a measured baseline (#4865), not an aspirational target. The suite imports +`background.js`, `opportunity-badge.js`, and `toolbar-badge.js` directly (via the existing +`__GITTENSORY_MINER_EXTENSION_TEST__` hook) so v8 can attribute coverage; the root `test/unit/miner-*.test.ts` +files remain as broader behavior tests through the `node:vm` harness. + +`content.js` and `options.js` are deliberately deferred — they need a jsdom mount harness before +coverage attribution is meaningful. Raise thresholds per-PR as those scripts get covered. + ## Host permissions `manifest.json` grants `https://github.com/*` (for the issue-page content script) plus loopback host permissions — diff --git a/apps/gittensory-miner-extension/package.json b/apps/gittensory-miner-extension/package.json index cb6e9488fc..67c63daf16 100644 --- a/apps/gittensory-miner-extension/package.json +++ b/apps/gittensory-miner-extension/package.json @@ -7,6 +7,10 @@ "scripts": { "build": "node ../../scripts/build-miner-extension.mjs", "lint": "node --check background.js && node --check content.js && node --check opportunity-badge.js && node --check options.js && node --check toolbar-badge.js", - "typecheck": "npm run lint" + "typecheck": "npm run lint", + "test": "vitest run --coverage" + }, + "devDependencies": { + "vitest": "^4.1.9" } } diff --git a/apps/gittensory-miner-extension/test/background.test.ts b/apps/gittensory-miner-extension/test/background.test.ts new file mode 100644 index 0000000000..1c9d8b00af --- /dev/null +++ b/apps/gittensory-miner-extension/test/background.test.ts @@ -0,0 +1,271 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { flush, jsonFetch, loadExtensionModules } from "./helpers.js"; +import { + TOOLBAR_BADGE_EMPTY_COLOR, + TOOLBAR_BADGE_HAS_DATA_COLOR, + TOOLBAR_BADGE_NO_DATA_TEXT, +} from "../toolbar-badge.js"; + +const rankedEntry = { + repoFullName: "JSONbored/gittensory", + issueNumber: 145, + rankScore: 0.82, + laneFit: 0.9, + freshness: 0.8, + potential: 0.7, + feasibility: 0.75, + dupRisk: 0.1, +}; + +describe("background service worker", () => { + afterEach(() => { + vi.unstubAllGlobals(); + vi.resetModules(); + vi.restoreAllMocks(); + }); + + it("returns ready issue context for a watched repo with a cached ranked candidate", async () => { + const { backgroundInternals } = await loadExtensionModules({ + watchedRepos: ["JSONbored/gittensory"], + rankedCandidates: [rankedEntry], + rankedCandidatesSavedAt: Date.parse("2026-07-10T11:00:00.000Z"), + }); + + const payload = await backgroundInternals.loadIssueOpportunityContext({ + owner: "JSONbored", + repo: "gittensory", + issueNumber: 145, + }); + + expect(payload.status).toBe("ready"); + expect(payload.savedAt).toBe(Date.parse("2026-07-10T11:00:00.000Z")); + expect((payload.badge as { tier: string }).tier).toBe("High"); + }); + + it("returns repo-not-watched and no-signal states", async () => { + const unwatched = await loadExtensionModules({ watchedRepos: ["other/repo"] }); + const notWatched = await unwatched.backgroundInternals.loadIssueOpportunityContext({ + owner: "JSONbored", + repo: "gittensory", + issueNumber: 145, + }); + expect(notWatched.status).toBe("repo-not-watched"); + expect(notWatched.badge).toBeNull(); + + const empty = await loadExtensionModules({ + watchedRepos: ["JSONbored/gittensory"], + rankedCandidates: [], + }); + const noSignal = await empty.backgroundInternals.loadIssueOpportunityContext({ + owner: "JSONbored", + repo: "gittensory", + issueNumber: 145, + }); + expect(noSignal.status).toBe("no-signal"); + expect(noSignal.badge).toBeNull(); + }); + + it("normalizes watched repos and degrades malformed ranked-candidate storage", async () => { + const { backgroundInternals } = await loadExtensionModules({ + watchedRepos: [" JSONbored/gittensory ", "", 42 as unknown as string], + rankedCandidates: "bad" as unknown as unknown[], + rankedCandidatesSavedAt: "not-a-number" as unknown as number, + }); + + expect(await backgroundInternals.loadMinerExtensionSettings()).toEqual({ + watchedRepos: ["JSONbored/gittensory", "42"], + }); + expect(await backgroundInternals.loadRankedCandidates()).toEqual({ + rankedCandidates: [], + savedAt: null, + }); + + const malformedSettings = await loadExtensionModules({ + syncGetResult: { watchedRepos: "not-an-array" }, + }); + expect(await malformedSettings.backgroundInternals.loadMinerExtensionSettings()).toEqual({ + watchedRepos: [], + }); + }); + + it("syncs ranked candidates from the miner UI and leaves storage untouched on failure", async () => { + const candidates = [{ repoFullName: "acme/widgets", issueNumber: 1, rankScore: 0.8 }]; + const success = await loadExtensionModules({ + fetchImpl: jsonFetch(200, { candidates }), + }); + const ok = await success.backgroundInternals.syncRankedCandidatesFromMinerUi(); + expect(ok.ok).toBe(true); + expect(ok.count).toBe(1); + expect(success.localSetCalls).toHaveLength(1); + + const httpError = await loadExtensionModules({ fetchImpl: jsonFetch(401, {}) }); + const unauthorized = await httpError.backgroundInternals.syncRankedCandidatesFromMinerUi(); + expect(unauthorized).toMatchObject({ ok: false, error: "miner UI responded 401" }); + expect(httpError.localSetCalls).toHaveLength(0); + + const malformed = await loadExtensionModules({ fetchImpl: jsonFetch(200, { candidates: "nope" }) }); + const badShape = await malformed.backgroundInternals.syncRankedCandidatesFromMinerUi(); + expect(badShape).toMatchObject({ + ok: false, + error: "miner UI returned an unexpected payload shape", + }); + + const network = await loadExtensionModules({ + fetchImpl: (async () => { + throw new Error("connection refused"); + }) as typeof fetch, + }); + const failed = await network.backgroundInternals.syncRankedCandidatesFromMinerUi(); + expect(failed).toMatchObject({ ok: false, error: "connection refused" }); + }); + + it("falls back to the default miner UI URL when sync storage is empty or malformed", async () => { + const empty = await loadExtensionModules({ minerUiUrl: "" }); + expect(await empty.backgroundInternals.loadMinerUiUrl()).toBe( + empty.backgroundInternals.DEFAULT_MINER_UI_URL, + ); + + const malformed = await loadExtensionModules({ minerUiUrl: 123 as unknown as string }); + expect(await malformed.backgroundInternals.loadMinerUiUrl()).toBe( + malformed.backgroundInternals.DEFAULT_MINER_UI_URL, + ); + }); + + it("stringifies non-Error sync failures and issue-context rejections", async () => { + const syncFail = await loadExtensionModules({ + fetchImpl: (async () => { + throw "offline"; + }) as typeof fetch, + }); + const syncResult = await syncFail.backgroundInternals.syncRankedCandidatesFromMinerUi(); + expect(syncResult).toMatchObject({ ok: false, error: "offline" }); + + const mod = await loadExtensionModules({ + watchedRepos: ["JSONbored/gittensory"], + rankedCandidates: [rankedEntry], + syncGetThrows: true, + syncGetRejectsWith: "storage blew up", + }); + const response = await mod.dispatchMessage({ + type: mod.backgroundInternals.ISSUE_CONTEXT_MESSAGE, + owner: "JSONbored", + repo: "gittensory", + issueNumber: 145, + }); + expect((response as { ok: boolean; error: string }).error).toBeTruthy(); + }); + + it("matches watched repos case-insensitively", async () => { + const { backgroundInternals } = await loadExtensionModules({ + watchedRepos: ["jsonbored/gittensory"], + rankedCandidates: [rankedEntry], + }); + const payload = await backgroundInternals.loadIssueOpportunityContext({ + owner: "JSONbored", + repo: "gittensory", + issueNumber: 145, + }); + expect(payload.status).toBe("ready"); + }); + + it("routes runtime messages for ping, issue context, and sync", async () => { + const mod = await loadExtensionModules({ + watchedRepos: ["JSONbored/gittensory"], + rankedCandidates: [rankedEntry], + fetchImpl: jsonFetch(200, { candidates: [rankedEntry] }), + }); + + const ping = await mod.dispatchMessage({ type: mod.backgroundInternals.PING_MESSAGE }); + expect(ping).toEqual({ ok: true, payload: { ready: true } }); + + const context = await mod.dispatchMessage({ + type: mod.backgroundInternals.ISSUE_CONTEXT_MESSAGE, + owner: "JSONbored", + repo: "gittensory", + issueNumber: 145, + }); + expect((context as { payload: { status: string } }).payload.status).toBe("ready"); + + const sync = await mod.dispatchMessage({ + type: mod.backgroundInternals.SYNC_RANKED_CANDIDATES_MESSAGE, + }); + expect((sync as { payload: { ok: boolean } }).payload.ok).toBe(true); + + const ignored = await mod.dispatchMessage({ type: "unknown" }); + expect(ignored).toBeUndefined(); + expect(await mod.dispatchMessage(null)).toBeUndefined(); + }); + + it("paints and repaints the toolbar badge from storage changes", async () => { + const mod = await loadExtensionModules({ rankedCandidates: [1, 2] }); + await flush(); + expect(mod.setBadgeText).toHaveBeenCalledWith({ text: "2" }); + expect(mod.setBadgeBackgroundColor).toHaveBeenCalledWith({ + color: TOOLBAR_BADGE_HAS_DATA_COLOR, + }); + + mod.setBadgeText.mockClear(); + await mod.backgroundInternals.refreshToolbarBadge(); + expect(mod.setBadgeText).toHaveBeenLastCalledWith({ text: "2" }); + + const never = await loadExtensionModules({ rankedCandidates: undefined }); + await flush(); + never.setBadgeText.mockClear(); + await never.backgroundInternals.refreshToolbarBadge(); + expect(never.setBadgeText).toHaveBeenLastCalledWith({ text: TOOLBAR_BADGE_NO_DATA_TEXT }); + + const empty = await loadExtensionModules({ rankedCandidates: [] }); + await flush(); + empty.setBadgeText.mockClear(); + await empty.backgroundInternals.refreshToolbarBadge(); + expect(empty.setBadgeText).toHaveBeenLastCalledWith({ text: "" }); + expect(empty.setBadgeBackgroundColor).toHaveBeenLastCalledWith({ + color: TOOLBAR_BADGE_EMPTY_COLOR, + }); + + const live = await loadExtensionModules({ rankedCandidates: [9] }); + await flush(); + live.setBadgeText.mockClear(); + live.fireChange({ rankedCandidates: { newValue: [9] } }, "local"); + await flush(); + expect(live.setBadgeText).toHaveBeenCalledTimes(1); + + live.setBadgeText.mockClear(); + live.fireChange({ rankedCandidates: { newValue: [9] } }, "sync"); + live.fireChange({ watchedRepos: { newValue: [] } }, "local"); + await flush(); + expect(live.setBadgeText).not.toHaveBeenCalled(); + }); + + it("swallows chrome.action failures during toolbar refresh", async () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const mod = await loadExtensionModules({ rankedCandidates: [1], failAction: true }); + await flush(); + await expect(mod.backgroundInternals.refreshToolbarBadge()).resolves.toBeUndefined(); + expect(warn).toHaveBeenCalled(); + }); + + it("registers ambient sync alarms and lifecycle hooks when chrome surfaces exist", async () => { + const mod = await loadExtensionModules({ + withAlarms: true, + withLifecycle: true, + fetchImpl: jsonFetch(200, { candidates: [] }), + }); + + expect(mod.alarmCreateCalls[0]?.[0]).toBe("gittensory-miner:sync-ranked-candidates"); + mod.dispatchStartup(); + mod.dispatchInstalled(); + mod.dispatchAlarm("gittensory-miner:sync-ranked-candidates"); + mod.dispatchAlarm("other-alarm"); + await flush(); + expect(mod.localSetCalls.length).toBeGreaterThan(0); + }); + + it("no-ops toolbar wiring when chrome.action is unavailable", async () => { + const mod = await loadExtensionModules({ rankedCandidates: [1, 2, 3], withAction: false }); + await flush(); + expect(typeof mod.backgroundInternals.refreshToolbarBadge).toBe("function"); + expect(mod.setBadgeText).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/gittensory-miner-extension/test/helpers.ts b/apps/gittensory-miner-extension/test/helpers.ts new file mode 100644 index 0000000000..5681555bdc --- /dev/null +++ b/apps/gittensory-miner-extension/test/helpers.ts @@ -0,0 +1,195 @@ +import { vi } from "vitest"; + +export const flush = () => new Promise((resolve) => setTimeout(resolve, 0)); + +export type BackgroundInternals = { + PING_MESSAGE: string; + ISSUE_CONTEXT_MESSAGE: string; + SYNC_RANKED_CANDIDATES_MESSAGE: string; + DEFAULT_MINER_UI_URL: string; + loadIssueOpportunityContext: (message: { + owner: string; + repo: string; + issueNumber: number; + }) => Promise>; + loadMinerExtensionSettings: () => Promise<{ watchedRepos: string[] }>; + loadRankedCandidates: () => Promise<{ rankedCandidates: unknown[]; savedAt: number | null }>; + loadMinerUiUrl: () => Promise; + syncRankedCandidatesFromMinerUi: () => Promise>; + refreshToolbarBadge: () => Promise; +}; + +export type OpportunityBadgeExports = { + issueLookupKey: (repoFullName: unknown, issueNumber: unknown) => string | null; + lookupRankedOpportunity: ( + rankedIssues: unknown, + repoFullName: string, + issueNumber: number, + ) => Record | null; + scoreToTier: (rankScore: unknown) => string; + buildOpportunityWhy: (entry: Record) => string; + formatOpportunityBadge: (entry: Record) => { + tier: string; + score: string; + why: string; + rankScore: number | null; + }; + formatLastSyncedLabel: (savedAt: unknown, nowMs: number) => string | null; + escapeOpportunityHtml: (value: unknown) => string; + renderOpportunityBadgeMarkup: (badge: Record, lastSyncedLabel?: string | null) => string; +}; + +type ChromeMockOptions = { + watchedRepos?: string[]; + syncGetResult?: Record; + rankedCandidates?: unknown; + rankedCandidatesSavedAt?: number | null; + minerUiUrl?: string; + withAction?: boolean; + withAlarms?: boolean; + withLifecycle?: boolean; + failAction?: boolean; + syncGetThrows?: boolean; + syncGetRejectsWith?: unknown; + fetchImpl?: typeof fetch; +}; + +export function jsonFetch(status: number, payload: unknown): typeof fetch { + return (async () => + ({ + ok: status >= 200 && status < 300, + status, + json: async () => payload, + }) as unknown as Response) as typeof fetch; +} + +export function buildChromeMock(options: ChromeMockOptions = {}) { + const watchedRepos = options.watchedRepos ?? []; + const rankedCandidates = + "rankedCandidates" in options ? options.rankedCandidates : []; + const rankedCandidatesSavedAt = options.rankedCandidatesSavedAt ?? null; + const minerUiUrl = options.minerUiUrl ?? "http://localhost:5174"; + const withAction = options.withAction ?? true; + const withAlarms = options.withAlarms ?? false; + const withLifecycle = options.withLifecycle ?? false; + const failAction = options.failAction ?? false; + const syncGetThrows = options.syncGetThrows ?? false; + const syncGetRejectsWith = options.syncGetRejectsWith; + + const localSetCalls: Array> = []; + const syncSetCalls: Array> = []; + const syncRemoveCalls: string[] = []; + const alarmCreateCalls: Array<[string, unknown]> = []; + let changeListener: ((changes: unknown, areaName: string) => void) | null = null; + let alarmListener: ((alarm: { name: string }) => void) | undefined; + let startupListener: (() => void) | undefined; + let installedListener: (() => void) | undefined; + let messageListener: + | ((message: unknown, sender: unknown, sendResponse: (response: unknown) => void) => boolean | void) + | undefined; + + const setBadgeText = failAction + ? vi.fn(async () => { + throw new Error("chrome.action unavailable"); + }) + : vi.fn(async () => {}); + const setBadgeBackgroundColor = vi.fn(async () => {}); + + const chrome: Record = { + runtime: { + onMessage: { + addListener: (fn: typeof messageListener) => { + messageListener = fn; + }, + }, + ...(withLifecycle + ? { + onStartup: { addListener: (fn: typeof startupListener) => (startupListener = fn) }, + onInstalled: { addListener: (fn: typeof installedListener) => (installedListener = fn) }, + } + : {}), + }, + storage: { + sync: { + get: syncGetThrows + ? async () => { + throw syncGetRejectsWith ?? new Error("sync storage unavailable"); + } + : async () => ({ watchedRepos, minerUiUrl, ...(options.syncGetResult ?? {}) }), + set: async (value: Record) => { + syncSetCalls.push(value); + }, + remove: async (keys: string | string[]) => { + syncRemoveCalls.push(...(Array.isArray(keys) ? keys : [keys])); + }, + }, + local: { + get: async (arg: unknown) => + typeof arg === "string" + ? { rankedCandidates } + : { + rankedCandidates: Array.isArray(rankedCandidates) ? rankedCandidates : [], + rankedCandidatesSavedAt, + }, + set: async (value: Record) => { + localSetCalls.push(value); + }, + }, + onChanged: withAction + ? { addListener: (fn: typeof changeListener) => (changeListener = fn) } + : undefined, + }, + }; + + if (withAction) { + chrome.action = { setBadgeText, setBadgeBackgroundColor }; + } + if (withAlarms) { + chrome.alarms = { + create: (name: string, info: unknown) => alarmCreateCalls.push([name, info]), + onAlarm: { addListener: (fn: typeof alarmListener) => (alarmListener = fn) }, + }; + } + + return { + chrome, + localSetCalls, + syncSetCalls, + syncRemoveCalls, + alarmCreateCalls, + setBadgeText, + setBadgeBackgroundColor, + dispatchAlarm: (name: string) => alarmListener?.({ name }), + dispatchStartup: () => startupListener?.(), + dispatchInstalled: () => installedListener?.(), + dispatchMessage: (message: unknown) => + new Promise((resolve) => { + const keepChannelOpen = messageListener?.(message, {}, resolve); + if (!keepChannelOpen) resolve(undefined); + }), + fireChange: (changes: unknown, areaName: string) => changeListener?.(changes, areaName), + }; +} + +export async function loadExtensionModules(options: ChromeMockOptions = {}) { + vi.resetModules(); + vi.unstubAllGlobals(); + + const harness = buildChromeMock(options); + vi.stubGlobal("chrome", harness.chrome); + vi.stubGlobal("__GITTENSORY_MINER_EXTENSION_TEST__", true); + if (options.fetchImpl) vi.stubGlobal("fetch", options.fetchImpl); + + await import("../opportunity-badge.js"); + await import("../toolbar-badge.js"); + await import("../background.js"); + + return { + ...harness, + opportunityExports: globalThis.__gittensoryMinerOpportunityBadgeTestExports as OpportunityBadgeExports, + toolbarApi: globalThis.__gittensoryMinerToolbarBadge as { + computeToolbarBadge: (rankedCandidates: unknown) => { text: string; backgroundColor: string }; + }, + backgroundInternals: globalThis.__gittensoryMinerBackgroundInternals as BackgroundInternals, + }; +} diff --git a/apps/gittensory-miner-extension/test/opportunity-badge.test.ts b/apps/gittensory-miner-extension/test/opportunity-badge.test.ts new file mode 100644 index 0000000000..ee1d07a449 --- /dev/null +++ b/apps/gittensory-miner-extension/test/opportunity-badge.test.ts @@ -0,0 +1,85 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { loadExtensionModules } from "./helpers.js"; + +const NOW_MS = Date.parse("2026-07-10T12:00:00.000Z"); + +describe("opportunity-badge exports", () => { + afterEach(() => { + vi.unstubAllGlobals(); + vi.resetModules(); + vi.restoreAllMocks(); + }); + + it("builds stable repo#issue lookup keys and finds ranked entries", async () => { + const { opportunityExports: badge } = await loadExtensionModules(); + expect(badge.issueLookupKey("JSONbored/gittensory", 145)).toBe("jsonbored/gittensory#145"); + expect(badge.issueLookupKey("", 1)).toBeNull(); + expect(badge.issueLookupKey("a/b", 0)).toBeNull(); + + const ranked = [ + { repoFullName: "JSONbored/gittensory", issueNumber: 145, rankScore: 0.8 }, + { repoFullName: "owner/repo", issueNumber: 2, rankScore: 0.4 }, + ]; + expect(badge.lookupRankedOpportunity(ranked, "JSONbored/gittensory", 145)?.rankScore).toBe(0.8); + expect(badge.lookupRankedOpportunity(ranked, "JSONbored/gittensory", 404)).toBeNull(); + expect(badge.lookupRankedOpportunity(null, "a/b", 1)).toBeNull(); + }); + + it("formats tier, score, and why without duplicating ranking math", async () => { + const { opportunityExports: badge } = await loadExtensionModules(); + const entry = { + rankScore: 0.82, + laneFit: 0.9, + freshness: 0.8, + potential: 0.7, + feasibility: 0.75, + dupRisk: 0.1, + }; + const formatted = badge.formatOpportunityBadge(entry); + expect(formatted.tier).toBe("High"); + expect(formatted.score).toBe("0.82"); + expect(formatted.why.length).toBeGreaterThan(0); + expect(badge.scoreToTier(0.6)).toBe("Medium"); + expect(badge.scoreToTier(0.2)).toBe("Low"); + + const fallback = badge.formatOpportunityBadge({ rankScore: Number.NaN }); + expect(fallback.tier).toBe("Unknown"); + expect(fallback.score).toBe("—"); + expect(fallback.rankScore).toBeNull(); + expect(badge.buildOpportunityWhy({})).toBe("Balanced opportunity signals"); + expect(badge.buildOpportunityWhy({ laneFit: 0.8 })).toContain("lane fit"); + expect(badge.buildOpportunityWhy({ freshness: 0.8 })).toContain("Fresh issue"); + expect(badge.buildOpportunityWhy({ potential: 0.8 })).toContain("reward potential"); + expect(badge.buildOpportunityWhy({ feasibility: 0.8 })).toContain("Feasible scope"); + expect(badge.buildOpportunityWhy({ dupRisk: 0.1 })).toContain("duplicate risk"); + }); + + it("skips malformed ranked entries while scanning the cache", async () => { + const { opportunityExports: badge } = await loadExtensionModules(); + const ranked = [ + null, + { repoFullName: "a/b", issueNumber: "nope" }, + { repoFullName: "JSONbored/gittensory", issueNumber: 145, rankScore: 0.5 }, + ]; + expect(badge.lookupRankedOpportunity(ranked, "JSONbored/gittensory", 145)?.rankScore).toBe(0.5); + }); + + it("formats relative last-synced labels and escapes badge markup", async () => { + const { opportunityExports: badge } = await loadExtensionModules(); + expect(badge.formatLastSyncedLabel(NOW_MS, NOW_MS)).toBe("last synced just now"); + expect(badge.formatLastSyncedLabel(NOW_MS - 60_000, NOW_MS)).toBe("last synced 1m ago"); + expect(badge.formatLastSyncedLabel(NOW_MS - 60 * 60_000, NOW_MS)).toBe("last synced 1h ago"); + expect(badge.formatLastSyncedLabel(NOW_MS - 24 * 60 * 60_000, NOW_MS)).toBe("last synced 1d ago"); + expect(badge.formatLastSyncedLabel(null, NOW_MS)).toBeNull(); + + const formatted = badge.formatOpportunityBadge({ rankScore: 0.6 }); + const markup = badge.renderOpportunityBadgeMarkup(formatted, "last synced 3m ago"); + expect(markup).toContain("Read-only"); + expect(markup).toContain("last synced 3m ago"); + expect(markup).not.toContain("