From 4f37a8f06b36c3a3f36c05a1677695d18aebffb7 Mon Sep 17 00:00:00 2001 From: carlh171112 Date: Mon, 13 Jul 2026 11:20:30 -0700 Subject: [PATCH] feat(miner-extension): enhance testing capabilities and documentation - Updated the `ui:test` script in `package.json` to include tests from the `@jsonbored/gittensory-miner-extension` workspace, ensuring comprehensive coverage. - Added a new `test` script in the `apps/gittensory-miner-extension/package.json` to run tests using Vitest, improving the testing framework. - Expanded the README.md to document the testing process and coverage details for the miner extension, providing clarity for developers. - Introduced Vitest configuration in `vitest.config.ts` to streamline test execution and coverage reporting. - Created multiple test files for various components, including `background.test.js`, `content.test.js`, and `opportunity-badge.test.js`, to ensure robust testing of the miner extension's functionality. --- apps/gittensory-miner-extension/README.md | 10 + apps/gittensory-miner-extension/package.json | 3 +- .../test/background.test.js | 382 ++++++++++++++++++ .../test/content.test.js | 204 ++++++++++ .../test/opportunity-badge.test.js | 167 ++++++++ .../test/options.test.js | 252 ++++++++++++ apps/gittensory-miner-extension/test/setup.js | 5 + .../test/toolbar-badge.test.js | 66 +++ .../vitest.config.ts | 29 ++ package.json | 2 +- 10 files changed, 1118 insertions(+), 2 deletions(-) create mode 100644 apps/gittensory-miner-extension/test/background.test.js create mode 100644 apps/gittensory-miner-extension/test/content.test.js create mode 100644 apps/gittensory-miner-extension/test/opportunity-badge.test.js create mode 100644 apps/gittensory-miner-extension/test/options.test.js create mode 100644 apps/gittensory-miner-extension/test/setup.js create mode 100644 apps/gittensory-miner-extension/test/toolbar-badge.test.js 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..87f175ab54 100644 --- a/apps/gittensory-miner-extension/README.md +++ b/apps/gittensory-miner-extension/README.md @@ -35,3 +35,13 @@ quota, instead of silently failing to save or leaving storage partially written. (#4860). Chrome match patterns cannot pin a port, so `http://localhost/*` is the narrowest grant the platform allows; `https` is intentionally omitted because the local miner-ui dev server is plain HTTP. This is the enabling permission for live-fetching ranked candidates from the local miner-ui instead of pasting them. + +## Tests + +`npm run test` (Vitest + v8 coverage) covers every shipped script — `background.js`, `content.js`, +`opportunity-badge.js`, `options.js`, and `toolbar-badge.js` (#4865). The two DOM-page scripts (`content.js`, +`options.js`) run under jsdom via a per-file `// @vitest-environment jsdom` docblock; the rest run in Node. Each +script exposes its otherwise-unexported internals on `globalThis` only when +`globalThis.__GITTENSORY_MINER_EXTENSION_TEST__` is set (done in `test/setup.js`), so the suite imports the real +source files directly and v8 attributes true coverage. The suite is wired into the repo's `npm run ui:test`, so it +runs in CI alongside the other UI workspaces. diff --git a/apps/gittensory-miner-extension/package.json b/apps/gittensory-miner-extension/package.json index cb6e9488fc..02bb597fb0 100644 --- a/apps/gittensory-miner-extension/package.json +++ b/apps/gittensory-miner-extension/package.json @@ -7,6 +7,7 @@ "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" } } diff --git a/apps/gittensory-miner-extension/test/background.test.js b/apps/gittensory-miner-extension/test/background.test.js new file mode 100644 index 0000000000..0adde10321 --- /dev/null +++ b/apps/gittensory-miner-extension/test/background.test.js @@ -0,0 +1,382 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const flush = () => new Promise((resolve) => setTimeout(resolve, 0)); + +function jsonResponse(body, { ok = true, status = 200 } = {}) { + return { ok, status, json: async () => body }; +} + +// A configurable chrome mock modelled on the shapes background.js's own guards expect. `minimal` omits every +// optional surface (alarms/action/onChanged/onStartup/onInstalled) so the "clean no-op in a bare environment" +// guard branches are exercised too. +function createChrome({ + syncStore = {}, + localStore = {}, + minimal = false, + syncGetThrows = false, + syncGetThrowsNonError = false, + actionThrows = false, +} = {}) { + const calls = { + badgeText: [], + badgeColor: [], + localSet: [], + warn: [], + alarmCreate: null, + fetched: [], + }; + const listeners = {}; + + const chrome = { + runtime: { + onMessage: { addListener: (fn) => (listeners.message = fn) }, + }, + storage: { + sync: { + get: async (defaults) => { + if (syncGetThrows) throw new Error("sync get failed"); + if (syncGetThrowsNonError) throw "sync get failed (string)"; // a raw string, not an Error + return { ...(defaults ?? {}), ...syncStore }; + }, + set: async () => {}, + remove: async () => {}, + }, + local: { + get: async (arg) => { + if (typeof arg === "string") { + return { [arg]: arg in localStore ? localStore[arg] : undefined }; + } + return { ...(arg ?? {}), ...localStore }; + }, + set: async (value) => { + calls.localSet.push(value); + Object.assign(localStore, value); + }, + }, + }, + }; + + if (!minimal) { + chrome.action = { + setBadgeText: async (value) => { + calls.badgeText.push(value); + if (actionThrows) throw new Error("chrome.action unavailable"); + }, + setBadgeBackgroundColor: async (value) => { + calls.badgeColor.push(value); + }, + }; + chrome.storage.onChanged = { addListener: (fn) => (listeners.changed = fn) }; + chrome.alarms = { + create: (name, options) => (calls.alarmCreate = { name, options }), + onAlarm: { addListener: (fn) => (listeners.alarm = fn) }, + }; + chrome.runtime.onStartup = { addListener: (fn) => (listeners.startup = fn) }; + chrome.runtime.onInstalled = { addListener: (fn) => (listeners.installed = fn) }; + } + + return { chrome, calls, listeners }; +} + +async function loadBackground(options = {}) { + const built = createChrome(options); + const fetchImpl = vi.fn(async (url) => { + built.calls.fetched.push(url); + return options.fetchResponse ?? jsonResponse({ candidates: [{ repoFullName: "a/b", issueNumber: 1 }] }); + }); + if (options.fetchThrows) fetchImpl.mockImplementation(async () => { + throw new Error("network down"); + }); + if (options.fetchThrowsNonError) fetchImpl.mockImplementation(async () => { + throw "network down (string)"; // a raw string, not an Error + }); + + vi.resetModules(); + vi.stubGlobal("chrome", built.chrome); + vi.stubGlobal("fetch", fetchImpl); + + await import("../background.js"); + await flush(); + + return { + ...built, + fetchImpl, + internals: globalThis.__gittensoryMinerBackgroundInternals, + }; +} + +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +describe("background.js message router (#4859)", () => { + let sendResponse; + beforeEach(() => { + sendResponse = vi.fn(); + }); + + it("ignores messages with no/invalid type and returns false (no async response)", async () => { + const bg = await loadBackground(); + expect(bg.listeners.message(null, {}, sendResponse)).toBe(false); + expect(bg.listeners.message({ type: 42 }, {}, sendResponse)).toBe(false); + expect(sendResponse).not.toHaveBeenCalled(); + }); + + it("answers a ping synchronously", async () => { + const bg = await loadBackground(); + const ret = bg.listeners.message({ type: "gittensory-miner:ping" }, {}, sendResponse); + expect(ret).toBe(false); + expect(sendResponse).toHaveBeenCalledWith({ ok: true, payload: { ready: true } }); + }); + + it("resolves issue-context asynchronously and keeps the channel open", async () => { + const bg = await loadBackground({ + syncStore: { watchedRepos: ["JSONbored/gittensory"] }, + localStore: { + rankedCandidates: [{ repoFullName: "JSONbored/gittensory", issueNumber: 145, rankScore: 0.8, laneFit: 0.8 }], + rankedCandidatesSavedAt: 111, + }, + }); + const ret = bg.listeners.message( + { type: "gittensory-miner:issue-context", owner: "JSONbored", repo: "gittensory", issueNumber: 145 }, + {}, + sendResponse, + ); + expect(ret).toBe(true); + await flush(); + expect(sendResponse).toHaveBeenCalledWith({ + ok: true, + payload: expect.objectContaining({ status: "ready", savedAt: 111 }), + }); + }); + + it("reports an error payload when issue-context resolution throws", async () => { + const bg = await loadBackground({ syncGetThrows: true }); + bg.listeners.message( + { type: "gittensory-miner:issue-context", owner: "o", repo: "r", issueNumber: 1 }, + {}, + sendResponse, + ); + await flush(); + expect(sendResponse).toHaveBeenCalledWith({ ok: false, error: "sync get failed" }); + }); + + it("stringifies a non-Error thrown during issue-context resolution (String(error) branch)", async () => { + const bg = await loadBackground({ syncGetThrowsNonError: true }); + bg.listeners.message( + { type: "gittensory-miner:issue-context", owner: "o", repo: "r", issueNumber: 1 }, + {}, + sendResponse, + ); + await flush(); + expect(sendResponse).toHaveBeenCalledWith({ ok: false, error: "sync get failed (string)" }); + }); + + it("triggers a live sync and returns its result", async () => { + const bg = await loadBackground(); + const ret = bg.listeners.message({ type: "gittensory-miner:sync-ranked-candidates" }, {}, sendResponse); + expect(ret).toBe(true); + await flush(); + expect(sendResponse).toHaveBeenCalledWith({ ok: true, payload: expect.objectContaining({ ok: true, count: 1 }) }); + }); + + it("returns false for an unknown message type", async () => { + const bg = await loadBackground(); + expect(bg.listeners.message({ type: "gittensory-miner:unknown" }, {}, sendResponse)).toBe(false); + }); +}); + +describe("background.js loadIssueOpportunityContext", () => { + it("returns repo-not-watched when the repo is not in the watch list", async () => { + const bg = await loadBackground({ syncStore: { watchedRepos: [" ", "owner/other"] } }); + const payload = await bg.internals.loadIssueOpportunityContext({ owner: "o", repo: "r", issueNumber: 1 }); + expect(payload).toEqual({ + watched: false, + issueNumber: 1, + repoFullName: "o/r", + badge: null, + status: "repo-not-watched", + }); + }); + + it("returns no-signal when watched but no ranked candidate matches", async () => { + const bg = await loadBackground({ + syncStore: { watchedRepos: ["JSONbored/gittensory"] }, + localStore: { rankedCandidates: [] }, + }); + const payload = await bg.internals.loadIssueOpportunityContext({ + owner: "JSONbored", + repo: "gittensory", + issueNumber: 145, + }); + expect(payload.status).toBe("no-signal"); + expect(payload.badge).toBeNull(); + }); + + it("returns a ready badge when a watched repo has a cached ranked candidate", async () => { + const bg = await loadBackground({ + syncStore: { watchedRepos: ["JSONbored/gittensory"] }, + localStore: { + rankedCandidates: [{ repoFullName: "JSONbored/gittensory", issueNumber: 145, rankScore: 0.9, potential: 0.8 }], + rankedCandidatesSavedAt: 999, + }, + }); + const payload = await bg.internals.loadIssueOpportunityContext({ + owner: "JSONbored", + repo: "gittensory", + issueNumber: 145, + }); + expect(payload.status).toBe("ready"); + expect(payload.badge.tier).toBe("High"); + expect(payload.savedAt).toBe(999); + }); +}); + +describe("background.js storage readers", () => { + it("loadMinerExtensionSettings trims/filters a watch list and defaults a non-array to empty", async () => { + const withList = await loadBackground({ syncStore: { watchedRepos: [" JSONbored/gittensory ", ""] } }); + expect((await withList.internals.loadMinerExtensionSettings()).watchedRepos).toEqual(["JSONbored/gittensory"]); + + const nonArray = await loadBackground({ syncStore: { watchedRepos: "oops" } }); + expect((await nonArray.internals.loadMinerExtensionSettings()).watchedRepos).toEqual([]); + }); + + it("loadRankedCandidates degrades a non-array cache and a non-numeric savedAt safely", async () => { + const good = await loadBackground({ localStore: { rankedCandidates: [1, 2], rankedCandidatesSavedAt: 7 } }); + expect(await good.internals.loadRankedCandidates()).toEqual({ rankedCandidates: [1, 2], savedAt: 7 }); + + const bad = await loadBackground({ localStore: { rankedCandidates: "nope", rankedCandidatesSavedAt: "nope" } }); + expect(await bad.internals.loadRankedCandidates()).toEqual({ rankedCandidates: [], savedAt: null }); + }); + + it("loadMinerUiUrl returns the stored URL, or the default for empty/whitespace/non-string values", async () => { + const custom = await loadBackground({ syncStore: { minerUiUrl: "http://localhost:9999" } }); + expect(await custom.internals.loadMinerUiUrl()).toBe("http://localhost:9999"); + + const blank = await loadBackground({ syncStore: { minerUiUrl: " " } }); + expect(await blank.internals.loadMinerUiUrl()).toBe("http://localhost:5174"); + + const nonString = await loadBackground({ syncStore: { minerUiUrl: 42 } }); + expect(await nonString.internals.loadMinerUiUrl()).toBe("http://localhost:5174"); + }); +}); + +describe("background.js syncRankedCandidatesFromMinerUi (#4859)", () => { + it("writes fetched candidates to local storage and reports the count", async () => { + const bg = await loadBackground({ + fetchResponse: jsonResponse({ candidates: [{ issueNumber: 1 }, { issueNumber: 2 }] }), + }); + const result = await bg.internals.syncRankedCandidatesFromMinerUi(); + expect(result).toMatchObject({ ok: true, count: 2, minerUiUrl: "http://localhost:5174" }); + expect(bg.calls.localSet.at(-1)).toMatchObject({ rankedCandidates: [{ issueNumber: 1 }, { issueNumber: 2 }] }); + }); + + it("returns a typed failure (never throws) on a non-OK response", async () => { + const bg = await loadBackground({ fetchResponse: jsonResponse({}, { ok: false, status: 503 }) }); + const result = await bg.internals.syncRankedCandidatesFromMinerUi(); + expect(result).toEqual({ ok: false, error: "miner UI responded 503", minerUiUrl: "http://localhost:5174" }); + }); + + it("rejects an unexpected payload shape", async () => { + const bg = await loadBackground({ fetchResponse: jsonResponse({ candidates: "not-an-array" }) }); + const result = await bg.internals.syncRankedCandidatesFromMinerUi(); + expect(result.ok).toBe(false); + expect(result.error).toMatch(/unexpected payload shape/); + }); + + it("swallows a thrown fetch into a typed failure result", async () => { + const bg = await loadBackground({ fetchThrows: true }); + const result = await bg.internals.syncRankedCandidatesFromMinerUi(); + expect(result).toEqual({ ok: false, error: "network down", minerUiUrl: "http://localhost:5174" }); + }); + + it("stringifies a non-Error thrown by fetch into a typed failure (String(error) branch)", async () => { + const bg = await loadBackground({ fetchThrowsNonError: true }); + const result = await bg.internals.syncRankedCandidatesFromMinerUi(); + expect(result).toEqual({ + ok: false, + error: "network down (string)", + minerUiUrl: "http://localhost:5174", + }); + }); +}); + +describe("background.js toolbar-badge wiring (#5193)", () => { + it("paints the badge on startup from the current cache", async () => { + const bg = await loadBackground({ localStore: { rankedCandidates: [1, 2] } }); + expect(bg.calls.badgeText).toContainEqual({ text: "2" }); + }); + + it("refreshToolbarBadge maps never-populated / empty / populated caches through chrome.action", async () => { + const never = await loadBackground({}); + never.calls.badgeText.length = 0; + await never.internals.refreshToolbarBadge(); + expect(never.calls.badgeText.at(-1)).toEqual({ text: "–" }); + + const populated = await loadBackground({ localStore: { rankedCandidates: [{}, {}, {}, {}] } }); + populated.calls.badgeText.length = 0; + await populated.internals.refreshToolbarBadge(); + expect(populated.calls.badgeText.at(-1)).toEqual({ text: "4" }); + }); + + it("repaints on a local rankedCandidates change and ignores other keys/areas", async () => { + const bg = await loadBackground({ localStore: { rankedCandidates: [9] } }); + bg.calls.badgeText.length = 0; + + bg.listeners.changed({ rankedCandidates: { newValue: [9] } }, "local"); + await flush(); + expect(bg.calls.badgeText).toHaveLength(1); + + bg.calls.badgeText.length = 0; + bg.listeners.changed({ rankedCandidates: { newValue: [9] } }, "sync"); + bg.listeners.changed({ watchedRepos: { newValue: [] } }, "local"); + await flush(); + expect(bg.calls.badgeText).toHaveLength(0); + }); + + it("swallows a rejected chrome.action call so the void-called refresh never leaks a rejection", async () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const bg = await loadBackground({ localStore: { rankedCandidates: [1] }, actionThrows: true }); + await expect(bg.internals.refreshToolbarBadge()).resolves.toBeUndefined(); + expect(warn).toHaveBeenCalled(); + }); +}); + +describe("background.js ambient refresh wiring", () => { + it("registers a periodic alarm and syncs when it fires under the right name", async () => { + const bg = await loadBackground(); + expect(bg.calls.alarmCreate?.options).toEqual({ periodInMinutes: 10 }); + + bg.fetchImpl.mockClear(); + bg.listeners.alarm({ name: "some-other-alarm" }); + await flush(); + expect(bg.fetchImpl).not.toHaveBeenCalled(); + + bg.listeners.alarm({ name: bg.calls.alarmCreate.name }); + await flush(); + expect(bg.fetchImpl).toHaveBeenCalledTimes(1); + }); + + it("syncs on service-worker startup and on install", async () => { + const bg = await loadBackground(); + + bg.fetchImpl.mockClear(); + bg.listeners.startup(); + await flush(); + expect(bg.fetchImpl).toHaveBeenCalledTimes(1); + + bg.fetchImpl.mockClear(); + bg.listeners.installed(); + await flush(); + expect(bg.fetchImpl).toHaveBeenCalledTimes(1); + }); + + it("is a clean no-op (no paint, no throw) when the optional chrome surfaces are absent", async () => { + const bg = await loadBackground({ minimal: true, localStore: { rankedCandidates: [1, 2] } }); + expect(typeof bg.internals.refreshToolbarBadge).toBe("function"); + expect(bg.calls.badgeText).toHaveLength(0); + expect(bg.listeners.changed).toBeUndefined(); + expect(bg.listeners.alarm).toBeUndefined(); + }); +}); diff --git a/apps/gittensory-miner-extension/test/content.test.js b/apps/gittensory-miner-extension/test/content.test.js new file mode 100644 index 0000000000..d948a486c5 --- /dev/null +++ b/apps/gittensory-miner-extension/test/content.test.js @@ -0,0 +1,204 @@ +// @vitest-environment jsdom +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +// content.js reads `globalThis.__gittensoryMinerOpportunityBadge` (published by opportunity-badge.js, which the +// browser loads first) at import time, and its exposed internals reuse it — so import it for its side effect here. +import "../opportunity-badge.js"; + +const flush = async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + await new Promise((resolve) => setTimeout(resolve, 0)); +}; + +const BADGE_SELECTOR = "[data-gittensory-miner-opportunity-badge]"; + +function setPath(pathname) { + window.history.pushState({}, "", pathname); +} + +// content.js only touches chrome.runtime.sendMessage (from loadOpportunityBadge). Stub it per scenario. +function stubChrome(sendMessage) { + vi.stubGlobal("chrome", { runtime: { sendMessage } }); +} + +// Re-run content.js's top level under the current URL + chrome stub, then drain the void-called async mount. +async function importContent() { + vi.resetModules(); + await import("../content.js"); + await flush(); + return globalThis.__gittensoryMinerContentInternals; +} + +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + document.body.innerHTML = ""; + setPath("/"); +}); + +describe("content.js matchGitHubIssueTarget", () => { + let internals; + beforeEach(async () => { + setPath("/"); + stubChrome(vi.fn().mockResolvedValue({ ok: false })); + internals = await importContent(); + }); + + it("parses an owner/repo/issue path into a typed issue target", () => { + expect(internals.matchGitHubIssueTarget("/JSONbored/gittensory/issues/145")).toEqual({ + kind: "issue", + owner: "JSONbored", + repo: "gittensory", + issueNumber: 145, + }); + }); + + it("returns null for non-issue paths, an empty path, and a nullish path", () => { + expect(internals.matchGitHubIssueTarget("/JSONbored/gittensory/pull/145")).toBeNull(); + expect(internals.matchGitHubIssueTarget("/JSONbored/gittensory/issues/")).toBeNull(); + expect(internals.matchGitHubIssueTarget("")).toBeNull(); + expect(internals.matchGitHubIssueTarget(null)).toBeNull(); + }); +}); + +describe("content.js findIssueSidebar", () => { + let internals; + beforeEach(async () => { + setPath("/"); + stubChrome(vi.fn().mockResolvedValue({ ok: false })); + internals = await importContent(); + }); + + it("returns null when no known sidebar container is present", () => { + expect(internals.findIssueSidebar()).toBeNull(); + }); + + it("resolves each known sidebar selector, in priority order", () => { + for (const setup of [ + { id: "partial-discussion-sidebar" }, + { attr: ["data-testid", "issue-sidebar"] }, + { cls: "Layout-sidebar" }, + { cls: "discussion-sidebar" }, + ]) { + document.body.innerHTML = ""; + const el = document.createElement("div"); + if (setup.id) el.id = setup.id; + if (setup.attr) el.setAttribute(setup.attr[0], setup.attr[1]); + if (setup.cls) el.className = setup.cls; + document.body.appendChild(el); + expect(internals.findIssueSidebar()).toBe(el); + } + }); +}); + +describe("content.js renderOpportunityBadge", () => { + let internals; + beforeEach(async () => { + setPath("/"); + stubChrome(vi.fn().mockResolvedValue({ ok: false })); + internals = await importContent(); + }); + + function freshContainer() { + const container = document.createElement("aside"); + container.dataset.gittensoryMinerOpportunityBadge = "true"; + container.hidden = true; + document.body.appendChild(container); + return container; + } + + it("removes the container when the payload is not watched", () => { + const container = freshContainer(); + internals.renderOpportunityBadge(container, { watched: false }); + expect(container.isConnected).toBe(false); + }); + + it("removes the container when there is no badge", () => { + const container = freshContainer(); + internals.renderOpportunityBadge(container, { watched: true, badge: null }); + expect(container.isConnected).toBe(false); + }); + + it("removes the container when the badge produces no markup", () => { + const container = freshContainer(); + // A truthy-but-non-object badge passes the guard yet yields an empty markup string. + internals.renderOpportunityBadge(container, { watched: true, badge: "not-an-object" }); + expect(container.isConnected).toBe(false); + }); + + it("renders escaped markup and reveals the container for a real badge (with a synced label)", () => { + const container = freshContainer(); + const nowMs = Date.parse("2026-07-10T12:00:00.000Z"); + internals.renderOpportunityBadge( + container, + { watched: true, badge: { tier: "High", score: "0.81", why: "Strong lane fit" }, savedAt: nowMs - 3 * 60_000 }, + nowMs, + ); + expect(container.isConnected).toBe(true); + expect(container.hidden).toBe(false); + expect(container.innerHTML).toContain("LoopOver opportunity"); + expect(container.innerHTML).toContain("last synced 3m ago"); + }); +}); + +describe("content.js mount (top-level side effect)", () => { + it("does nothing on a non-issue page (no badge, no message sent)", async () => { + setPath("/JSONbored/gittensory/pulls"); + const sendMessage = vi.fn().mockResolvedValue({ ok: true }); + stubChrome(sendMessage); + await importContent(); + expect(document.querySelector(BADGE_SELECTOR)).toBeNull(); + expect(sendMessage).not.toHaveBeenCalled(); + }); + + it("mounts a badge into the issue sidebar and renders the resolved payload", async () => { + setPath("/JSONbored/gittensory/issues/145"); + const sidebar = document.createElement("div"); + sidebar.id = "partial-discussion-sidebar"; + document.body.appendChild(sidebar); + stubChrome( + vi.fn().mockResolvedValue({ + ok: true, + payload: { watched: true, badge: { tier: "High", score: "0.9", why: "Strong lane fit" }, savedAt: null }, + }), + ); + await importContent(); + const badge = sidebar.querySelector(BADGE_SELECTOR); + expect(badge).not.toBeNull(); + expect(badge.hidden).toBe(false); + expect(badge.innerHTML).toContain("LoopOver opportunity"); + }); + + it("falls back to a floating badge when no sidebar exists", async () => { + setPath("/JSONbored/gittensory/issues/1"); + stubChrome( + vi.fn().mockResolvedValue({ + ok: true, + payload: { watched: true, badge: { tier: "Low", score: "0.20", why: "Balanced opportunity signals" } }, + }), + ); + await importContent(); + const badge = document.body.querySelector(BADGE_SELECTOR); + expect(badge).not.toBeNull(); + expect(badge.className).toContain("gittensory-miner-opportunity-badge--floating"); + }); + + it("removes the container when the background responds not-ok", async () => { + setPath("/JSONbored/gittensory/issues/7"); + stubChrome(vi.fn().mockResolvedValue({ ok: false })); + await importContent(); + expect(document.querySelector(BADGE_SELECTOR)).toBeNull(); + }); + + it("does not mount a second badge when one already exists on the page", async () => { + setPath("/JSONbored/gittensory/issues/9"); + const existing = document.createElement("aside"); + existing.dataset.gittensoryMinerOpportunityBadge = "true"; + document.body.appendChild(existing); + const sendMessage = vi.fn().mockResolvedValue({ ok: true, payload: { watched: true, badge: {} } }); + stubChrome(sendMessage); + await importContent(); + expect(document.querySelectorAll(BADGE_SELECTOR)).toHaveLength(1); + expect(sendMessage).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/gittensory-miner-extension/test/opportunity-badge.test.js b/apps/gittensory-miner-extension/test/opportunity-badge.test.js new file mode 100644 index 0000000000..433f3819d7 --- /dev/null +++ b/apps/gittensory-miner-extension/test/opportunity-badge.test.js @@ -0,0 +1,167 @@ +import { describe, expect, it } from "vitest"; + +// opportunity-badge.js ships as a classic (non-ESM-exporting) content script: importing it for its side effect +// publishes the helper API on globalThis, exactly as the browser loads it ahead of content.js. +import "../opportunity-badge.js"; + +const api = globalThis.__gittensoryMinerOpportunityBadge; + +describe("opportunity-badge.js API surface", () => { + it("publishes the same object on both the runtime global and the test-exports hook", () => { + expect(api).toBeTruthy(); + expect(globalThis.__gittensoryMinerOpportunityBadgeTestExports).toBe(api); + }); +}); + +describe("issueLookupKey", () => { + it("builds a normalized repo#issue key, lower-cased and trimmed", () => { + expect(api.issueLookupKey(" JSONbored/Gittensory ", "145")).toBe("jsonbored/gittensory#145"); + expect(api.issueLookupKey("owner/repo", 7)).toBe("owner/repo#7"); + }); + + it("returns null for missing repo, non-integer, or non-positive issue numbers", () => { + expect(api.issueLookupKey("", 1)).toBeNull(); + expect(api.issueLookupKey(null, 1)).toBeNull(); + expect(api.issueLookupKey("owner/repo", "not-a-number")).toBeNull(); + expect(api.issueLookupKey("owner/repo", 1.5)).toBeNull(); + expect(api.issueLookupKey("owner/repo", 0)).toBeNull(); + expect(api.issueLookupKey("owner/repo", -3)).toBeNull(); + }); +}); + +describe("lookupRankedOpportunity", () => { + const ranked = [ + null, + "not-an-object", + { repoFullName: "other/repo", issueNumber: 1 }, + { repoFullName: "JSONbored/gittensory", issueNumber: 145, rankScore: 0.8 }, + ]; + + it("finds the matching entry by repo#issue key, skipping malformed entries", () => { + const match = api.lookupRankedOpportunity(ranked, "jsonbored/gittensory", 145); + expect(match?.rankScore).toBe(0.8); + }); + + it("returns null when the target key is unresolvable", () => { + expect(api.lookupRankedOpportunity(ranked, "", 145)).toBeNull(); + }); + + it("returns null when the ranked list is not an array", () => { + expect(api.lookupRankedOpportunity(undefined, "owner/repo", 1)).toBeNull(); + }); + + it("returns null when no entry matches", () => { + expect(api.lookupRankedOpportunity(ranked, "owner/repo", 999)).toBeNull(); + }); +}); + +describe("scoreToTier", () => { + it("maps finite scores into High/Medium/Low bands", () => { + expect(api.scoreToTier(0.9)).toBe("High"); + expect(api.scoreToTier(0.75)).toBe("High"); + expect(api.scoreToTier(0.6)).toBe("Medium"); + expect(api.scoreToTier(0.5)).toBe("Medium"); + expect(api.scoreToTier(0.2)).toBe("Low"); + }); + + it("returns Unknown for a non-finite score", () => { + expect(api.scoreToTier("nope")).toBe("Unknown"); + expect(api.scoreToTier(Number.NaN)).toBe("Unknown"); + }); +}); + +describe("buildOpportunityWhy", () => { + it("surfaces the strongest signals, capped at two, joined with a semicolon", () => { + const why = api.buildOpportunityWhy({ laneFit: 0.9, freshness: 0.9, potential: 0.9 }); + expect(why).toBe("Strong lane fit; Fresh issue"); + }); + + it("recognizes each individual signal threshold", () => { + expect(api.buildOpportunityWhy({ potential: 0.7 })).toBe("High reward potential"); + expect(api.buildOpportunityWhy({ feasibility: 0.7 })).toBe("Feasible scope"); + expect(api.buildOpportunityWhy({ dupRisk: 0.3 })).toBe("Low duplicate risk"); + }); + + it("falls back to a balanced-signals message when nothing crosses a threshold", () => { + expect(api.buildOpportunityWhy({ laneFit: 0.1, dupRisk: 0.9 })).toBe("Balanced opportunity signals"); + }); +}); + +describe("formatOpportunityBadge", () => { + it("formats tier, a two-decimal score, and passes through the numeric rankScore", () => { + expect(api.formatOpportunityBadge({ rankScore: 0.812, laneFit: 0.8 })).toEqual({ + tier: "High", + score: "0.81", + why: "Strong lane fit", + rankScore: 0.812, + }); + }); + + it("degrades a non-finite rankScore to an em-dash score and a null rankScore", () => { + const badge = api.formatOpportunityBadge({ rankScore: "n/a" }); + expect(badge.tier).toBe("Unknown"); + expect(badge.score).toBe("—"); + expect(badge.rankScore).toBeNull(); + }); +}); + +describe("formatLastSyncedLabel (#5192)", () => { + const NOW = Date.parse("2026-07-10T12:00:00.000Z"); + + it("buckets the delta the same way ORB's RefreshMeta does", () => { + expect(api.formatLastSyncedLabel(NOW, NOW)).toBe("last synced just now"); + expect(api.formatLastSyncedLabel(NOW - 59_000, NOW)).toBe("last synced just now"); + expect(api.formatLastSyncedLabel(NOW - 60_000, NOW)).toBe("last synced 1m ago"); + expect(api.formatLastSyncedLabel(NOW - 59 * 60_000, NOW)).toBe("last synced 59m ago"); + expect(api.formatLastSyncedLabel(NOW - 60 * 60_000, NOW)).toBe("last synced 1h ago"); + expect(api.formatLastSyncedLabel(NOW - 23 * 60 * 60_000, NOW)).toBe("last synced 23h ago"); + expect(api.formatLastSyncedLabel(NOW - 24 * 60 * 60_000, NOW)).toBe("last synced 1d ago"); + }); + + it("clamps a future timestamp to just now rather than a negative delta", () => { + expect(api.formatLastSyncedLabel(NOW + 5_000, NOW)).toBe("last synced just now"); + }); + + it("returns null for a missing or invalid timestamp (never the epoch)", () => { + expect(api.formatLastSyncedLabel(null, NOW)).toBeNull(); + expect(api.formatLastSyncedLabel(undefined, NOW)).toBeNull(); + expect(api.formatLastSyncedLabel(Number.NaN, NOW)).toBeNull(); + expect(api.formatLastSyncedLabel("", NOW)).toBeNull(); + expect(api.formatLastSyncedLabel("not-a-timestamp", NOW)).toBeNull(); + }); +}); + +describe("escapeOpportunityHtml", () => { + it("escapes every HTML-significant character", () => { + expect(api.escapeOpportunityHtml(`&<>"'`)).toBe("&<>"'"); + }); + + it("coerces non-strings and leaves safe text untouched", () => { + expect(api.escapeOpportunityHtml(42)).toBe("42"); + expect(api.escapeOpportunityHtml("plain text")).toBe("plain text"); + }); +}); + +describe("renderOpportunityBadgeMarkup", () => { + const badge = { tier: "High", score: "0.81", why: "Strong lane fit" }; + + it("renders the read-only badge markup with escaped, script-free content", () => { + const markup = api.renderOpportunityBadgeMarkup(badge); + expect(markup).toContain("LoopOver opportunity"); + expect(markup).toContain("Read-only"); + expect(markup).toContain("High"); + expect(markup).not.toContain("