diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 81653e456d..23df55c9f1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -389,6 +389,10 @@ jobs: - name: Extension typecheck if: ${{ github.event_name == 'push' || needs.changes.outputs.ui == 'true' }} run: npm run extension:typecheck && npm run miner-extension:typecheck + # gittensory-extension has no test suite of its own yet; only the miner extension is covered (#4865). + - name: Extension tests + if: ${{ github.event_name == 'push' || needs.changes.outputs.ui == 'true' }} + run: npm run miner-extension:test # `npm run ui:build` also regenerates apps/gittensory-ui/public/openapi.json (needed for a # standalone build), but this step's trigger condition is a strict subset of "OpenAPI drift # check" above (push || ui==true, vs. push || ui==true || uiContract==true), so whenever this diff --git a/apps/gittensory-miner-extension/README.md b/apps/gittensory-miner-extension/README.md index 02506e3968..35c3ace113 100644 --- a/apps/gittensory-miner-extension/README.md +++ b/apps/gittensory-miner-extension/README.md @@ -28,6 +28,14 @@ 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. +## Testing (#4865) + +`npm run test` runs the extension's vitest suite (one `*.test.js` file per source file) under a real coverage +gate — each source file exposes its testable internals on `globalThis` behind a `__GITTENSORY_MINER_EXTENSION_TEST__` +guard (e.g. `background.js` → `globalThis.__gittensoryMinerBackgroundInternals`), so tests import the file directly +(a dynamic `import()`, after setting that flag and any needed `chrome.*`/`fetch` mocks) rather than needing a +separate browser-extension test harness. `vitest.config.ts` documents the measured coverage baseline. + ## 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/background.test.js b/apps/gittensory-miner-extension/background.test.js new file mode 100644 index 0000000000..f9c9bdfd95 --- /dev/null +++ b/apps/gittensory-miner-extension/background.test.js @@ -0,0 +1,341 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +/** Builds a fresh chrome mock. Every optional surface (alarms, onStartup, onInstalled, action + + * storage.onChanged) is individually toggleable so both sides of background.js's `if (chrome.X)` + * guards get exercised. */ +function buildChromeMock({ withAlarms = true, withOnStartup = true, withOnInstalled = true, withActionAndOnChanged = true } = {}) { + const messageListeners = []; + const alarmListeners = []; + const startupListeners = []; + const installedListeners = []; + const storageChangedListeners = []; + + const chrome = { + runtime: { + onMessage: { addListener: (fn) => messageListeners.push(fn) }, + }, + storage: { + sync: { get: vi.fn(async (defaults) => ({ ...defaults })) }, + local: { + get: vi.fn(async (defaults) => ({ ...defaults })), + set: vi.fn(async () => {}), + }, + }, + action: withActionAndOnChanged + ? { setBadgeText: vi.fn(async () => {}), setBadgeBackgroundColor: vi.fn(async () => {}) } + : undefined, + }; + if (withAlarms) { + chrome.alarms = { + create: vi.fn(), + onAlarm: { addListener: (fn) => alarmListeners.push(fn) }, + }; + } + if (withOnStartup) chrome.runtime.onStartup = { addListener: (fn) => startupListeners.push(fn) }; + if (withOnInstalled) chrome.runtime.onInstalled = { addListener: (fn) => installedListeners.push(fn) }; + if (withActionAndOnChanged) { + chrome.storage.onChanged = { addListener: (fn) => storageChangedListeners.push(fn) }; + } + + return { chrome, messageListeners, alarmListeners, startupListeners, installedListeners, storageChangedListeners }; +} + +/** Imports a fresh copy of background.js (and its two auto-imported siblings) against the given + * chrome mock. Must use a dynamic import -- a static one would be hoisted above the globalThis + * assignments below and run before __GITTENSORY_MINER_EXTENSION_TEST__ / chrome are set. */ +async function loadBackground(chromeMock) { + vi.resetModules(); + globalThis.__GITTENSORY_MINER_EXTENSION_TEST__ = true; + globalThis.chrome = chromeMock.chrome; + await import("./background.js"); + return globalThis.__gittensoryMinerBackgroundInternals; +} + +describe("background.js", () => { + afterEach(() => { + delete globalThis.chrome; + vi.unstubAllGlobals(); + }); + + describe("message routing", () => { + it("responds synchronously to a ping message", async () => { + const mock = buildChromeMock(); + const internals = await loadBackground(mock); + const sendResponse = vi.fn(); + const keepChannelOpen = mock.messageListeners[0]({ type: internals.PING_MESSAGE }, {}, sendResponse); + expect(sendResponse).toHaveBeenCalledWith({ ok: true, payload: { ready: true } }); + expect(keepChannelOpen).toBe(false); + }); + + it("ignores a message with no type", async () => { + const mock = buildChromeMock(); + await loadBackground(mock); + const sendResponse = vi.fn(); + expect(mock.messageListeners[0](null, {}, sendResponse)).toBe(false); + expect(mock.messageListeners[0]({}, {}, sendResponse)).toBe(false); + expect(sendResponse).not.toHaveBeenCalled(); + }); + + it("returns true (async channel) and resolves an issue-context message", async () => { + const mock = buildChromeMock(); + mock.chrome.storage.sync.get.mockResolvedValue({ watchedRepos: ["owner/repo"] }); + mock.chrome.storage.local.get.mockResolvedValue({ rankedCandidates: [], rankedCandidatesSavedAt: null }); + const internals = await loadBackground(mock); + const sendResponse = vi.fn(); + const keepChannelOpen = mock.messageListeners[0]( + { type: internals.ISSUE_CONTEXT_MESSAGE, owner: "owner", repo: "repo", issueNumber: 1 }, + {}, + sendResponse, + ); + expect(keepChannelOpen).toBe(true); + await vi.waitFor(() => expect(sendResponse).toHaveBeenCalled()); + expect(sendResponse).toHaveBeenCalledWith(expect.objectContaining({ ok: true })); + }); + + it("responds with ok:false when the issue-context handler throws", async () => { + const mock = buildChromeMock(); + mock.chrome.storage.sync.get.mockRejectedValue(new Error("boom")); + const internals = await loadBackground(mock); + const sendResponse = vi.fn(); + mock.messageListeners[0]( + { type: internals.ISSUE_CONTEXT_MESSAGE, owner: "o", repo: "r", issueNumber: 1 }, + {}, + sendResponse, + ); + await vi.waitFor(() => expect(sendResponse).toHaveBeenCalled()); + expect(sendResponse).toHaveBeenCalledWith({ ok: false, error: "boom" }); + }); + + it("returns true (async channel) and resolves a sync-ranked-candidates message", async () => { + const mock = buildChromeMock(); + globalThis.fetch = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ candidates: [] }) }); + const internals = await loadBackground(mock); + const sendResponse = vi.fn(); + const keepChannelOpen = mock.messageListeners[0]({ type: internals.SYNC_RANKED_CANDIDATES_MESSAGE }, {}, sendResponse); + expect(keepChannelOpen).toBe(true); + await vi.waitFor(() => expect(sendResponse).toHaveBeenCalled()); + expect(sendResponse).toHaveBeenCalledWith(expect.objectContaining({ ok: true })); + delete globalThis.fetch; + }); + + it("ignores an unrecognized message type", async () => { + const mock = buildChromeMock(); + await loadBackground(mock); + const sendResponse = vi.fn(); + expect(mock.messageListeners[0]({ type: "some-other-message" }, {}, sendResponse)).toBe(false); + expect(sendResponse).not.toHaveBeenCalled(); + }); + }); + + describe("loadIssueOpportunityContext", () => { + it("reports repo-not-watched when the repo isn't in watchedRepos", async () => { + const mock = buildChromeMock(); + mock.chrome.storage.sync.get.mockResolvedValue({ watchedRepos: ["other/repo"] }); + const internals = await loadBackground(mock); + const result = await internals.loadIssueOpportunityContext({ owner: "owner", repo: "repo", issueNumber: 1 }); + expect(result).toMatchObject({ watched: false, status: "repo-not-watched", badge: null }); + }); + + it("reports no-signal when watched but no ranked entry exists", async () => { + const mock = buildChromeMock(); + mock.chrome.storage.sync.get.mockResolvedValue({ watchedRepos: ["OWNER/REPO"] }); + mock.chrome.storage.local.get.mockResolvedValue({ rankedCandidates: [], rankedCandidatesSavedAt: null }); + const internals = await loadBackground(mock); + const result = await internals.loadIssueOpportunityContext({ owner: "owner", repo: "repo", issueNumber: 1 }); + expect(result).toMatchObject({ watched: true, status: "no-signal", badge: null }); + }); + + it("reports ready with a formatted badge when a ranked entry matches", async () => { + const mock = buildChromeMock(); + mock.chrome.storage.sync.get.mockResolvedValue({ watchedRepos: ["owner/repo"] }); + mock.chrome.storage.local.get.mockResolvedValue({ + rankedCandidates: [{ repoFullName: "owner/repo", issueNumber: 1, rankScore: 0.9 }], + rankedCandidatesSavedAt: 123, + }); + const internals = await loadBackground(mock); + const result = await internals.loadIssueOpportunityContext({ owner: "owner", repo: "repo", issueNumber: 1 }); + expect(result).toMatchObject({ watched: true, status: "ready", savedAt: 123 }); + expect(result.badge).toMatchObject({ tier: "High" }); + }); + }); + + describe("loadMinerExtensionSettings", () => { + it("trims and filters blank watched repos", async () => { + const mock = buildChromeMock(); + mock.chrome.storage.sync.get.mockResolvedValue({ watchedRepos: [" owner/repo ", "", " "] }); + const internals = await loadBackground(mock); + expect(await internals.loadMinerExtensionSettings()).toEqual({ watchedRepos: ["owner/repo"] }); + }); + + it("degrades a malformed (non-array) stored value to an empty list", async () => { + const mock = buildChromeMock(); + mock.chrome.storage.sync.get.mockResolvedValue({ watchedRepos: "not-an-array" }); + const internals = await loadBackground(mock); + expect(await internals.loadMinerExtensionSettings()).toEqual({ watchedRepos: [] }); + }); + }); + + describe("loadRankedCandidates", () => { + it("degrades a malformed rankedCandidates value to an empty array with a null savedAt", async () => { + const mock = buildChromeMock(); + mock.chrome.storage.local.get.mockResolvedValue({ rankedCandidates: "nope", rankedCandidatesSavedAt: "nope" }); + const internals = await loadBackground(mock); + expect(await internals.loadRankedCandidates()).toEqual({ rankedCandidates: [], savedAt: null }); + }); + + it("passes through a well-formed value", async () => { + const mock = buildChromeMock(); + mock.chrome.storage.local.get.mockResolvedValue({ rankedCandidates: [{ a: 1 }], rankedCandidatesSavedAt: 999 }); + const internals = await loadBackground(mock); + expect(await internals.loadRankedCandidates()).toEqual({ rankedCandidates: [{ a: 1 }], savedAt: 999 }); + }); + }); + + describe("loadMinerUiUrl", () => { + it("falls back to the default when the stored URL is blank", async () => { + const mock = buildChromeMock(); + mock.chrome.storage.sync.get.mockResolvedValue({ minerUiUrl: " " }); + const internals = await loadBackground(mock); + expect(await internals.loadMinerUiUrl()).toBe(internals.DEFAULT_MINER_UI_URL); + }); + + it("trims and returns a stored URL", async () => { + const mock = buildChromeMock(); + mock.chrome.storage.sync.get.mockResolvedValue({ minerUiUrl: " http://example.test " }); + const internals = await loadBackground(mock); + expect(await internals.loadMinerUiUrl()).toBe("http://example.test"); + }); + }); + + describe("syncRankedCandidatesFromMinerUi", () => { + it("stores candidates and returns ok:true on a well-formed response", async () => { + const mock = buildChromeMock(); + globalThis.fetch = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ candidates: [{ a: 1 }, { b: 2 }] }) }); + const internals = await loadBackground(mock); + const result = await internals.syncRankedCandidatesFromMinerUi(); + expect(result).toMatchObject({ ok: true, count: 2 }); + expect(mock.chrome.storage.local.set).toHaveBeenCalledWith( + expect.objectContaining({ rankedCandidates: [{ a: 1 }, { b: 2 }] }), + ); + delete globalThis.fetch; + }); + + it("returns ok:false without writing storage on a non-2xx response", async () => { + const mock = buildChromeMock(); + globalThis.fetch = vi.fn().mockResolvedValue({ ok: false, status: 500 }); + const internals = await loadBackground(mock); + const result = await internals.syncRankedCandidatesFromMinerUi(); + expect(result).toMatchObject({ ok: false, error: "miner UI responded 500" }); + expect(mock.chrome.storage.local.set).not.toHaveBeenCalled(); + delete globalThis.fetch; + }); + + it("returns ok:false for a malformed payload shape", async () => { + const mock = buildChromeMock(); + globalThis.fetch = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ nope: true }) }); + const internals = await loadBackground(mock); + const result = await internals.syncRankedCandidatesFromMinerUi(); + expect(result).toMatchObject({ ok: false, error: "miner UI returned an unexpected payload shape" }); + delete globalThis.fetch; + }); + + it("returns ok:false with the error message when fetch itself throws", async () => { + const mock = buildChromeMock(); + globalThis.fetch = vi.fn().mockRejectedValue(new Error("network down")); + const internals = await loadBackground(mock); + const result = await internals.syncRankedCandidatesFromMinerUi(); + expect(result).toMatchObject({ ok: false, error: "network down" }); + delete globalThis.fetch; + }); + }); + + describe("refreshToolbarBadge", () => { + it("computes and applies the badge from stored rankedCandidates", async () => { + const mock = buildChromeMock(); + mock.chrome.storage.local.get.mockResolvedValue({ rankedCandidates: [{}, {}] }); + const internals = await loadBackground(mock); + await internals.refreshToolbarBadge(); + expect(mock.chrome.action.setBadgeText).toHaveBeenCalledWith({ text: "2" }); + expect(mock.chrome.action.setBadgeBackgroundColor).toHaveBeenCalledWith(expect.objectContaining({ color: expect.any(String) })); + }); + + it("swallows a chrome.storage failure instead of throwing", async () => { + const mock = buildChromeMock(); + mock.chrome.storage.local.get.mockRejectedValue(new Error("storage error")); + const internals = await loadBackground(mock); + await expect(internals.refreshToolbarBadge()).resolves.toBeUndefined(); + }); + }); + + describe("optional-API guards (alarms / onStartup / onInstalled / action+onChanged)", () => { + it("wires the alarm + onAlarm listener when chrome.alarms is present, and filters by alarm name", async () => { + const mock = buildChromeMock({ withAlarms: true }); + globalThis.fetch = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ candidates: [] }) }); + await loadBackground(mock); + expect(mock.chrome.alarms.create).toHaveBeenCalledWith( + "gittensory-miner:sync-ranked-candidates", + expect.objectContaining({ periodInMinutes: expect.any(Number) }), + ); + expect(mock.alarmListeners).toHaveLength(1); + // A differently-named alarm must be ignored (no throw, no extra fetch assertions needed -- just exercises the branch). + mock.alarmListeners[0]({ name: "some-other-alarm" }); + mock.alarmListeners[0]({ name: "gittensory-miner:sync-ranked-candidates" }); + delete globalThis.fetch; + }); + + it("skips alarm wiring entirely when chrome.alarms is absent", async () => { + const mock = buildChromeMock({ withAlarms: false }); + await loadBackground(mock); + expect(mock.alarmListeners).toHaveLength(0); + }); + + it("wires onStartup when present and skips it when absent", async () => { + const withStartup = buildChromeMock({ withOnStartup: true }); + globalThis.fetch = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ candidates: [] }) }); + await loadBackground(withStartup); + expect(withStartup.startupListeners).toHaveLength(1); + + const withoutStartup = buildChromeMock({ withOnStartup: false }); + await loadBackground(withoutStartup); + expect(withoutStartup.startupListeners).toHaveLength(0); + delete globalThis.fetch; + }); + + it("wires onInstalled when present and skips it when absent", async () => { + const withInstalled = buildChromeMock({ withOnInstalled: true }); + globalThis.fetch = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ candidates: [] }) }); + await loadBackground(withInstalled); + expect(withInstalled.installedListeners).toHaveLength(1); + + const withoutInstalled = buildChromeMock({ withOnInstalled: false }); + await loadBackground(withoutInstalled); + expect(withoutInstalled.installedListeners).toHaveLength(0); + delete globalThis.fetch; + }); + + it("paints the toolbar badge and wires storage.onChanged when action+onChanged are present", async () => { + const mock = buildChromeMock({ withActionAndOnChanged: true }); + mock.chrome.storage.local.get.mockResolvedValue({ rankedCandidates: [{}] }); + await loadBackground(mock); + await vi.waitFor(() => expect(mock.chrome.action.setBadgeText).toHaveBeenCalled()); + expect(mock.storageChangedListeners).toHaveLength(1); + + mock.chrome.action.setBadgeText.mockClear(); + // areaName !== "local" must be ignored. + mock.storageChangedListeners[0]({ rankedCandidates: {} }, "sync"); + expect(mock.chrome.action.setBadgeText).not.toHaveBeenCalled(); + // a "local" change with no rankedCandidates key must also be ignored. + mock.storageChangedListeners[0]({ someOtherKey: {} }, "local"); + expect(mock.chrome.action.setBadgeText).not.toHaveBeenCalled(); + // a genuine local rankedCandidates change triggers a repaint. + mock.storageChangedListeners[0]({ rankedCandidates: {} }, "local"); + await vi.waitFor(() => expect(mock.chrome.action.setBadgeText).toHaveBeenCalled()); + }); + + it("skips the toolbar-badge paint and onChanged wiring when action or onChanged is absent", async () => { + const mock = buildChromeMock({ withActionAndOnChanged: false }); + await loadBackground(mock); + expect(mock.storageChangedListeners).toHaveLength(0); + }); + }); +}); diff --git a/apps/gittensory-miner-extension/content.test.js b/apps/gittensory-miner-extension/content.test.js new file mode 100644 index 0000000000..c971058df1 --- /dev/null +++ b/apps/gittensory-miner-extension/content.test.js @@ -0,0 +1,164 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const BADGE_SELECTOR = "[data-gittensory-miner-opportunity-badge]"; + +function stubBadgeApi() { + globalThis.__gittensoryMinerOpportunityBadge = { + formatLastSyncedLabel: (savedAt) => (savedAt ? `last synced ${savedAt}` : null), + renderOpportunityBadgeMarkup: (badge, label) => + badge ? `${badge.tier}${label ? `${label}` : ""}` : "", + }; +} + +/** Fresh import of content.js at a given pathname, with chrome.runtime.sendMessage mocked to + * resolve with `sendMessageResult`. Must be a dynamic import so the pathname/mock setup below + * runs before content.js's top-level auto-mount logic reads them. */ +async function loadContentAt(pathname, sendMessageResult) { + vi.resetModules(); + window.history.pushState({}, "", pathname); + globalThis.__GITTENSORY_MINER_EXTENSION_TEST__ = true; + globalThis.chrome = { runtime: { sendMessage: vi.fn().mockResolvedValue(sendMessageResult) } }; + stubBadgeApi(); + await import("./content.js"); + return globalThis.__gittensoryMinerContentInternals; +} + +describe("content.js", () => { + beforeEach(() => { + document.body.innerHTML = ""; + }); + + afterEach(() => { + delete globalThis.chrome; + delete globalThis.__gittensoryMinerOpportunityBadge; + }); + + describe("matchGitHubIssueTarget", () => { + it("matches a GitHub issue URL", async () => { + const internals = await loadContentAt("/", { ok: false }); + expect(internals.matchGitHubIssueTarget("/octocat/hello-world/issues/42")).toEqual({ + kind: "issue", + owner: "octocat", + repo: "hello-world", + issueNumber: 42, + }); + }); + + it("matches with a trailing slash or trailing segment", async () => { + const internals = await loadContentAt("/", { ok: false }); + expect(internals.matchGitHubIssueTarget("/octocat/hello-world/issues/42/")).toMatchObject({ issueNumber: 42 }); + }); + + it("returns null for a non-issue path", async () => { + const internals = await loadContentAt("/", { ok: false }); + expect(internals.matchGitHubIssueTarget("/octocat/hello-world/pulls/42")).toBeNull(); + expect(internals.matchGitHubIssueTarget("/octocat/hello-world")).toBeNull(); + expect(internals.matchGitHubIssueTarget(null)).toBeNull(); + }); + }); + + describe("findIssueSidebar", () => { + it("prefers #partial-discussion-sidebar over the other fallbacks", async () => { + const internals = await loadContentAt("/", { ok: false }); + document.body.innerHTML = ` +
+ + `; + expect(internals.findIssueSidebar()?.id).toBe("partial-discussion-sidebar"); + }); + + it("falls back through the remaining selectors in order", async () => { + const internals = await loadContentAt("/", { ok: false }); + document.body.innerHTML = ``; + expect(internals.findIssueSidebar()?.className).toBe("Layout-sidebar"); + }); + + it("returns null when nothing matches", async () => { + const internals = await loadContentAt("/", { ok: false }); + document.body.innerHTML = ``; + expect(internals.findIssueSidebar()).toBeNull(); + }); + }); + + describe("renderOpportunityBadge", () => { + it("removes the container when the payload isn't watched", async () => { + const internals = await loadContentAt("/", { ok: false }); + const container = document.createElement("aside"); + document.body.appendChild(container); + internals.renderOpportunityBadge(container, { watched: false }); + expect(document.body.contains(container)).toBe(false); + }); + + it("removes the container when watched but there's no badge", async () => { + const internals = await loadContentAt("/", { ok: false }); + const container = document.createElement("aside"); + document.body.appendChild(container); + internals.renderOpportunityBadge(container, { watched: true, badge: null }); + expect(document.body.contains(container)).toBe(false); + }); + + it("removes the container when the badge API produces no markup", async () => { + const internals = await loadContentAt("/", { ok: false }); + globalThis.__gittensoryMinerOpportunityBadge.renderOpportunityBadgeMarkup = () => ""; + const container = document.createElement("aside"); + document.body.appendChild(container); + internals.renderOpportunityBadge(container, { watched: true, badge: { tier: "High" } }); + expect(document.body.contains(container)).toBe(false); + }); + + it("un-hides the container and fills it with markup when ready, including the last-synced label", async () => { + const internals = await loadContentAt("/", { ok: false }); + const container = document.createElement("aside"); + container.hidden = true; + document.body.appendChild(container); + internals.renderOpportunityBadge(container, { watched: true, badge: { tier: "High" }, savedAt: 555 }, 1000); + expect(container.hidden).toBe(false); + expect(container.innerHTML).toContain("High"); + expect(container.innerHTML).toContain("last synced 555"); + }); + }); + + describe("auto-mount on import", () => { + it("mounts and populates the badge on a watched, ready issue page", async () => { + await loadContentAt("/octocat/hello-world/issues/42", { + ok: true, + payload: { watched: true, badge: { tier: "High" }, savedAt: 42 }, + }); + await vi.waitFor(() => { + const el = document.querySelector(BADGE_SELECTOR); + expect(el).not.toBeNull(); + expect(el.hidden).toBe(false); + }); + }); + + it("mounts into the issue sidebar host when present, otherwise floats in the body", async () => { + document.body.innerHTML = ``; + await loadContentAt("/octocat/hello-world/issues/42", { + ok: true, + payload: { watched: true, badge: { tier: "High" } }, + }); + await vi.waitFor(() => { + const el = document.querySelector(BADGE_SELECTOR); + expect(el).not.toBeNull(); + expect(document.getElementById("partial-discussion-sidebar").contains(el)).toBe(true); + expect(el.className).not.toContain("--floating"); + }); + }); + + it("removes the mounted container when the background responds ok:false", async () => { + await loadContentAt("/octocat/hello-world/issues/42", { ok: false }); + await vi.waitFor(() => expect(document.querySelector(BADGE_SELECTOR)).toBeNull()); + }); + + it("does not mount anything on a non-issue page", async () => { + await loadContentAt("/octocat/hello-world/pulls/1", { ok: false }); + expect(document.querySelector(BADGE_SELECTOR)).toBeNull(); + }); + + it("does not mount a second badge if one is already present", async () => { + document.body.innerHTML = ``; + await loadContentAt("/octocat/hello-world/issues/42", { ok: false }); + expect(document.querySelectorAll(BADGE_SELECTOR)).toHaveLength(1); + }); + }); +}); diff --git a/apps/gittensory-miner-extension/opportunity-badge.test.js b/apps/gittensory-miner-extension/opportunity-badge.test.js new file mode 100644 index 0000000000..803a1065e9 --- /dev/null +++ b/apps/gittensory-miner-extension/opportunity-badge.test.js @@ -0,0 +1,212 @@ +import { beforeAll, describe, expect, it } from "vitest"; + +let api; + +beforeAll(async () => { + globalThis.__GITTENSORY_MINER_EXTENSION_TEST__ = true; + await import("./opportunity-badge.js"); + api = globalThis.__gittensoryMinerOpportunityBadgeTestExports; +}); + +describe("issueLookupKey", () => { + it("builds a lowercase repo#number key", () => { + expect(api.issueLookupKey("Owner/Repo", 42)).toBe("owner/repo#42"); + }); + + it("returns null for a blank repo", () => { + expect(api.issueLookupKey(" ", 42)).toBeNull(); + }); + + it("returns null for a non-integer issue number", () => { + expect(api.issueLookupKey("owner/repo", 1.5)).toBeNull(); + }); + + it("returns null for a zero or negative issue number", () => { + expect(api.issueLookupKey("owner/repo", 0)).toBeNull(); + expect(api.issueLookupKey("owner/repo", -3)).toBeNull(); + }); +}); + +describe("lookupRankedOpportunity", () => { + const entries = [ + { repoFullName: "owner/repo", issueNumber: 42, rankScore: 0.9 }, + { repoFullName: "owner/other", issueNumber: 7, rankScore: 0.2 }, + ]; + + it("finds the matching entry case-insensitively", () => { + expect(api.lookupRankedOpportunity(entries, "Owner/Repo", 42)).toBe(entries[0]); + }); + + it("returns null when no entry matches", () => { + expect(api.lookupRankedOpportunity(entries, "owner/repo", 999)).toBeNull(); + }); + + it("returns null when rankedIssues is not an array", () => { + expect(api.lookupRankedOpportunity(null, "owner/repo", 42)).toBeNull(); + }); + + it("returns null when the lookup key itself is invalid", () => { + expect(api.lookupRankedOpportunity(entries, "", 42)).toBeNull(); + }); + + it("skips non-object entries without throwing", () => { + expect(api.lookupRankedOpportunity([null, 5, "x", entries[0]], "owner/repo", 42)).toBe(entries[0]); + }); +}); + +describe("scoreToTier", () => { + it("labels a high score", () => { + expect(api.scoreToTier(0.75)).toBe("High"); + expect(api.scoreToTier(0.9)).toBe("High"); + }); + + it("labels a medium score", () => { + expect(api.scoreToTier(0.5)).toBe("Medium"); + expect(api.scoreToTier(0.74)).toBe("Medium"); + }); + + it("labels a low score", () => { + expect(api.scoreToTier(0.49)).toBe("Low"); + expect(api.scoreToTier(0)).toBe("Low"); + }); + + it("labels a non-finite score as Unknown", () => { + expect(api.scoreToTier(Number.NaN)).toBe("Unknown"); + expect(api.scoreToTier(undefined)).toBe("Unknown"); + }); +}); + +describe("buildOpportunityWhy", () => { + it("lists every reason that clears its threshold, capped at two", () => { + const why = api.buildOpportunityWhy({ + laneFit: 0.8, + freshness: 0.8, + potential: 0.8, + feasibility: 0.8, + dupRisk: 0.1, + }); + expect(why).toBe("Strong lane fit; Fresh issue"); + }); + + it("falls back to a balanced-signals message when nothing clears its threshold", () => { + const why = api.buildOpportunityWhy({ + laneFit: 0.1, + freshness: 0.1, + potential: 0.1, + feasibility: 0.1, + dupRisk: 0.9, + }); + expect(why).toBe("Balanced opportunity signals"); + }); + + it("includes the low-duplicate-risk reason when dupRisk clears its own (inverted) threshold", () => { + const why = api.buildOpportunityWhy({ + laneFit: 0, + freshness: 0, + potential: 0, + feasibility: 0, + dupRisk: 0.2, + }); + expect(why).toBe("Low duplicate risk"); + }); +}); + +describe("formatOpportunityBadge", () => { + it("formats a finite rank score to two decimal places", () => { + const badge = api.formatOpportunityBadge({ + rankScore: 0.856, + laneFit: 0.8, + freshness: 0, + potential: 0, + feasibility: 0, + dupRisk: 0.9, + }); + expect(badge).toMatchObject({ tier: "High", score: "0.86", rankScore: 0.856 }); + }); + + it("degrades score to an em dash and rankScore to null when non-finite", () => { + const badge = api.formatOpportunityBadge({ + rankScore: Number.NaN, + laneFit: 0, + freshness: 0, + potential: 0, + feasibility: 0, + dupRisk: 0.9, + }); + expect(badge).toMatchObject({ tier: "Unknown", score: "—", rankScore: null }); + }); +}); + +describe("formatLastSyncedLabel", () => { + it("returns null for a non-numeric savedAt", () => { + expect(api.formatLastSyncedLabel(undefined, Date.now())).toBeNull(); + expect(api.formatLastSyncedLabel(Number.NaN, Date.now())).toBeNull(); + }); + + it("labels just now for sub-minute deltas", () => { + expect(api.formatLastSyncedLabel(1000, 30_000)).toBe("last synced just now"); + }); + + it("labels minutes for sub-hour deltas", () => { + expect(api.formatLastSyncedLabel(0, 5 * 60_000)).toBe("last synced 5m ago"); + }); + + it("labels hours for sub-day deltas", () => { + expect(api.formatLastSyncedLabel(0, 3 * 60 * 60_000)).toBe("last synced 3h ago"); + }); + + it("labels days beyond 24h", () => { + expect(api.formatLastSyncedLabel(0, 2 * 24 * 60 * 60_000)).toBe("last synced 2d ago"); + }); + + it("clamps a marginally-future savedAt to zero delta instead of a negative age", () => { + expect(api.formatLastSyncedLabel(10_000, 9_000)).toBe("last synced just now"); + }); +}); + +describe("escapeOpportunityHtml", () => { + it("escapes every HTML-sensitive character", () => { + expect(api.escapeOpportunityHtml(`"a" & 'b'`)).toBe( + "<b>"a" & 'b'</b>", + ); + }); + + it("stringifies a non-string value first", () => { + expect(api.escapeOpportunityHtml(42)).toBe("42"); + }); +}); + +describe("renderOpportunityBadgeMarkup", () => { + it("returns an empty string for a missing/non-object badge", () => { + expect(api.renderOpportunityBadgeMarkup(null, null)).toBe(""); + expect(api.renderOpportunityBadgeMarkup("x", null)).toBe(""); + }); + + it("renders the badge markup with the last-synced line when a label is given", () => { + const markup = api.renderOpportunityBadgeMarkup( + { tier: "High", score: "0.90", why: "Strong lane fit" }, + "last synced 2m ago", + ); + expect(markup).toContain("High"); + expect(markup).toContain("0.90"); + expect(markup).toContain("Strong lane fit"); + expect(markup).toContain("last synced 2m ago"); + }); + + it("omits the last-synced line entirely when no label is given", () => { + const markup = api.renderOpportunityBadgeMarkup( + { tier: "Low", score: "0.10", why: "Balanced opportunity signals" }, + null, + ); + expect(markup).not.toContain("gittensory-miner-opportunity-badge__synced"); + }); + + it("escapes badge field content so untrusted text cannot inject markup", () => { + const markup = api.renderOpportunityBadgeMarkup( + { tier: "