diff --git a/apps/gittensory-miner-extension/background.js b/apps/gittensory-miner-extension/background.js index d8c5b332a0..bb86400b93 100644 --- a/apps/gittensory-miner-extension/background.js +++ b/apps/gittensory-miner-extension/background.js @@ -1,6 +1,8 @@ import "./opportunity-badge.js"; +import "./toolbar-badge.js"; const badgeApi = globalThis.__gittensoryMinerOpportunityBadge; +const toolbarBadgeApi = globalThis.__gittensoryMinerToolbarBadge; const PING_MESSAGE = "gittensory-miner:ping"; const ISSUE_CONTEXT_MESSAGE = "gittensory-miner:issue-context"; @@ -71,6 +73,30 @@ async function loadRankedCandidates() { return Array.isArray(stored.rankedCandidates) ? stored.rankedCandidates : []; } +// Toolbar-icon badge (#5193). Reads `rankedCandidates` WITHOUT a default so `undefined` still means +// "cache never populated" (a dash), distinct from a populated-but-empty `[]` (cleared text). Read-only. +async function refreshToolbarBadge() { + // Swallow transient chrome.storage/chrome.action failures: this runs void-called on startup and from the + // onChanged listener, so an unhandled rejection would surface uncaught in the service-worker context. + try { + const { rankedCandidates } = await chrome.storage.local.get("rankedCandidates"); + const badge = toolbarBadgeApi.computeToolbarBadge(rankedCandidates); + await chrome.action.setBadgeText({ text: badge.text }); + await chrome.action.setBadgeBackgroundColor({ color: badge.backgroundColor }); + } catch (error) { + console.warn("gittensory-miner: failed to refresh toolbar badge", error); + } +} + +// Paint on service-worker startup, then keep it live as the miner rewrites the cache. Guarded so environments +// without the action API surface (e.g. the unit-test harness) are a clean no-op. +if (chrome.action && chrome.storage.onChanged) { + void refreshToolbarBadge(); + chrome.storage.onChanged.addListener((changes, areaName) => { + if (areaName === "local" && changes && changes.rankedCandidates) void refreshToolbarBadge(); + }); +} + if (globalThis.__GITTENSORY_MINER_EXTENSION_TEST__) { globalThis.__gittensoryMinerBackgroundInternals = { PING_MESSAGE, @@ -78,5 +104,6 @@ if (globalThis.__GITTENSORY_MINER_EXTENSION_TEST__) { loadIssueOpportunityContext, loadMinerExtensionSettings, loadRankedCandidates, + refreshToolbarBadge, }; } diff --git a/apps/gittensory-miner-extension/toolbar-badge.d.ts b/apps/gittensory-miner-extension/toolbar-badge.d.ts new file mode 100644 index 0000000000..11f06c0d54 --- /dev/null +++ b/apps/gittensory-miner-extension/toolbar-badge.d.ts @@ -0,0 +1,9 @@ +export declare const TOOLBAR_BADGE_HAS_DATA_COLOR: string; +export declare const TOOLBAR_BADGE_EMPTY_COLOR: string; +export declare const TOOLBAR_BADGE_NO_DATA_TEXT: string; + +/** Map the raw `chrome.storage.local` `rankedCandidates` value to the toolbar badge's text + background color. */ +export declare function computeToolbarBadge(rankedCandidates: unknown): { + text: string; + backgroundColor: string; +}; diff --git a/apps/gittensory-miner-extension/toolbar-badge.js b/apps/gittensory-miner-extension/toolbar-badge.js new file mode 100644 index 0000000000..557eda81d1 --- /dev/null +++ b/apps/gittensory-miner-extension/toolbar-badge.js @@ -0,0 +1,42 @@ +// Toolbar-icon badge state (#5193). A pure map from the raw `rankedCandidates` value in chrome.storage.local to +// the toolbar badge's { text, backgroundColor }. It distinguishes THREE states so "no data yet" is never +// confused with "zero opportunities": +// • never populated (no key ever written ⇒ value is `undefined`, or any malformed non-array) → a dash, NEVER a count +// • populated but empty (`[]`) → cleared text +// • populated (`[…]`) → the count +// Read-only: it computes values only; the background service worker applies them via chrome.action. +export const TOOLBAR_BADGE_HAS_DATA_COLOR = "#16a34a"; +export const TOOLBAR_BADGE_EMPTY_COLOR = "#6b7280"; +// The no-data indicator (a dash, never a numeric count). A named constant so source and tests can never drift. +export const TOOLBAR_BADGE_NO_DATA_TEXT = "–"; + +/** + * @param {unknown} rankedCandidates the raw `chrome.storage.local` value (read WITHOUT a default, so `undefined` + * genuinely means the key has never been written). + * @returns {{ text: string, backgroundColor: string }} + */ +export function computeToolbarBadge(rankedCandidates) { + if (Array.isArray(rankedCandidates)) { + return rankedCandidates.length > 0 + ? { + text: String(rankedCandidates.length), + backgroundColor: TOOLBAR_BADGE_HAS_DATA_COLOR, + } + : { text: "", backgroundColor: TOOLBAR_BADGE_EMPTY_COLOR }; + } + // undefined (never populated) or a malformed non-array value → show a dash, never a numeric count. + return { + text: TOOLBAR_BADGE_NO_DATA_TEXT, + backgroundColor: TOOLBAR_BADGE_EMPTY_COLOR, + }; +} + +// Expose on a global too — the background service worker reads this the same way it reads +// `__gittensoryMinerOpportunityBadge`, so the extension's VM-based test harness (which cannot evaluate ESM +// `import` bindings) can drive it without a module loader. +globalThis.__gittensoryMinerToolbarBadge = { + computeToolbarBadge, + TOOLBAR_BADGE_HAS_DATA_COLOR, + TOOLBAR_BADGE_EMPTY_COLOR, + TOOLBAR_BADGE_NO_DATA_TEXT, +}; diff --git a/test/unit/miner-extension-content.test.ts b/test/unit/miner-extension-content.test.ts index 376ba101d0..a192aada23 100644 --- a/test/unit/miner-extension-content.test.ts +++ b/test/unit/miner-extension-content.test.ts @@ -276,7 +276,7 @@ function loadBackgroundInternals({ context.globalThis = context; const vmContext = createContext(context); new Script(badgeScript).runInContext(vmContext); - const backgroundForTest = backgroundScript.replace(/^import\s+["'][^"']+["'];\s*/m, ""); + const backgroundForTest = backgroundScript.replace(/^import\s+["'][^"']+["'];\s*/gm, ""); new Script(backgroundForTest).runInContext(vmContext); return vmContext.__gittensoryMinerBackgroundInternals as { loadIssueOpportunityContext: (message: { diff --git a/test/unit/miner-toolbar-badge.test.ts b/test/unit/miner-toolbar-badge.test.ts new file mode 100644 index 0000000000..3340bf4e94 --- /dev/null +++ b/test/unit/miner-toolbar-badge.test.ts @@ -0,0 +1,205 @@ +import { readFileSync } from "node:fs"; +import { Script, createContext } from "node:vm"; + +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { + computeToolbarBadge, + TOOLBAR_BADGE_EMPTY_COLOR, + TOOLBAR_BADGE_HAS_DATA_COLOR, + TOOLBAR_BADGE_NO_DATA_TEXT, +} from "../../apps/gittensory-miner-extension/toolbar-badge.js"; + +// ─── The pure state map (the substance of #5193) ────────────────────────────────────────────────────────── + +describe("computeToolbarBadge (miner extension toolbar badge, #5193)", () => { + it("shows the count with the has-data color when candidates are populated", () => { + expect(computeToolbarBadge([{}, {}, {}])).toEqual({ + text: "3", + backgroundColor: TOOLBAR_BADGE_HAS_DATA_COLOR, + }); + // a single opportunity still renders as a count, not a dash + expect(computeToolbarBadge([{}])).toEqual({ + text: "1", + backgroundColor: TOOLBAR_BADGE_HAS_DATA_COLOR, + }); + }); + + it("clears the text (populated-but-empty state) for an empty array", () => { + expect(computeToolbarBadge([])).toEqual({ + text: "", + backgroundColor: TOOLBAR_BADGE_EMPTY_COLOR, + }); + }); + + it("shows a dash for the never-populated cache (key never written ⇒ undefined)", () => { + expect(computeToolbarBadge(undefined)).toEqual({ + text: TOOLBAR_BADGE_NO_DATA_TEXT, + backgroundColor: TOOLBAR_BADGE_EMPTY_COLOR, + }); + }); + + it("treats any malformed non-array value as no-data (dash), never a numeric count", () => { + for (const malformed of [null, "12", 7, { length: 5 }, true]) { + expect(computeToolbarBadge(malformed)).toEqual({ + text: TOOLBAR_BADGE_NO_DATA_TEXT, + backgroundColor: TOOLBAR_BADGE_EMPTY_COLOR, + }); + } + }); + + it("INVARIANT: no-data (never-written or malformed) never renders a numeric count — never shown as zero", () => { + for (const noData of [undefined, null, 0, "", { foo: "bar" }]) { + const text = computeToolbarBadge(noData).text; + expect(text).not.toMatch(/[0-9]/); + expect(text).toBe(TOOLBAR_BADGE_NO_DATA_TEXT); + } + }); +}); + +// ─── The background service-worker wiring (startup paint + live onChanged repaint) ──────────────────────── +// Loaded exactly the way the extension's own VM harness loads it (readFileSync + node:vm), so no module loader +// and no engine import is needed — this keeps the wiring test runnable without native/optional deps. + +const EXT_DIR = "apps/gittensory-miner-extension"; +const opportunityBadgeScript = readFileSync( + `${EXT_DIR}/opportunity-badge.js`, + "utf8", +); +const toolbarBadgeScript = readFileSync( + `${EXT_DIR}/toolbar-badge.js`, + "utf8", +).replace(/^export\s+/gm, ""); +const backgroundScript = readFileSync( + `${EXT_DIR}/background.js`, + "utf8", +).replace(/^import\s+["'][^"']+["'];\s*/gm, ""); + +const flush = () => new Promise((resolve) => setTimeout(resolve, 0)); + +function loadBackground( + rawRankedCandidates: unknown, + { withAction = true, failAction = false } = {}, +) { + const setBadgeText = failAction + ? vi.fn(async () => { + throw new Error("chrome.action unavailable"); + }) + : vi.fn(async () => {}); + const setBadgeBackgroundColor = vi.fn(async () => {}); + let changeListener: ((changes: unknown, areaName: string) => void) | null = + null; + const chrome: Record = { + storage: { + sync: { get: async () => ({ watchedRepos: [] }) }, + local: { + // the toolbar badge reads WITHOUT a default (string arg) → `undefined` survives as never-populated; + // the per-page path reads WITH a default object → always an array. + get: async (arg: unknown) => + typeof arg === "string" + ? { rankedCandidates: rawRankedCandidates } + : { + rankedCandidates: Array.isArray(rawRankedCandidates) + ? rawRankedCandidates + : [], + }, + }, + onChanged: withAction + ? { addListener: (fn: typeof changeListener) => (changeListener = fn) } + : undefined, + }, + runtime: { onMessage: { addListener: () => {} } }, + }; + if (withAction) chrome.action = { setBadgeText, setBadgeBackgroundColor }; + const warn = vi.fn(); + const context: Record = { + __GITTENSORY_MINER_EXTENSION_TEST__: true, + chrome, + console: { warn }, + }; + context.globalThis = context; + const vmContext = createContext(context); + new Script(opportunityBadgeScript).runInContext(vmContext); + new Script(toolbarBadgeScript).runInContext(vmContext); + new Script(backgroundScript).runInContext(vmContext); + const internals = vmContext.__gittensoryMinerBackgroundInternals as { + refreshToolbarBadge: () => Promise; + }; + return { + setBadgeText, + setBadgeBackgroundColor, + warn, + internals, + fireChange: (changes: unknown, areaName: string) => + changeListener?.(changes, areaName), + }; +} + +describe("background toolbar-badge wiring (#5193)", () => { + afterEach(() => vi.restoreAllMocks()); + + it("paints the badge on service-worker startup from the current cache", async () => { + const bg = loadBackground([1, 2]); + await flush(); + expect(bg.setBadgeText).toHaveBeenCalledWith({ text: "2" }); + expect(bg.setBadgeBackgroundColor).toHaveBeenCalledWith({ + color: TOOLBAR_BADGE_HAS_DATA_COLOR, + }); + }); + + it("refreshToolbarBadge applies the never-populated / empty / populated states via chrome.action", async () => { + const never = loadBackground(undefined); + await flush(); + never.setBadgeText.mockClear(); + never.setBadgeBackgroundColor.mockClear(); + await never.internals.refreshToolbarBadge(); + expect(never.setBadgeText).toHaveBeenLastCalledWith({ text: "–" }); + expect(never.setBadgeBackgroundColor).toHaveBeenLastCalledWith({ + color: TOOLBAR_BADGE_EMPTY_COLOR, + }); + + const empty = loadBackground([]); + await flush(); + empty.setBadgeText.mockClear(); + await empty.internals.refreshToolbarBadge(); + expect(empty.setBadgeText).toHaveBeenLastCalledWith({ text: "" }); + + const populated = loadBackground([{}, {}, {}, {}]); + await flush(); + populated.setBadgeText.mockClear(); + await populated.internals.refreshToolbarBadge(); + expect(populated.setBadgeText).toHaveBeenLastCalledWith({ text: "4" }); + }); + + it("repaints on a local rankedCandidates change; ignores other keys and other storage areas", async () => { + const bg = loadBackground([9]); + await flush(); + bg.setBadgeText.mockClear(); + + bg.fireChange({ rankedCandidates: { newValue: [9] } }, "local"); + await flush(); + expect(bg.setBadgeText).toHaveBeenCalledTimes(1); + + bg.setBadgeText.mockClear(); + bg.fireChange({ rankedCandidates: { newValue: [9] } }, "sync"); // right key, wrong area + bg.fireChange({ watchedRepos: { newValue: [] } }, "local"); // right area, wrong key + await flush(); + expect(bg.setBadgeText).not.toHaveBeenCalled(); + }); + + it("swallows a rejected chrome.action call so the void-called refresh never leaks an unhandled rejection", async () => { + const bg = loadBackground([1, 2], { failAction: true }); + await flush(); + // refreshToolbarBadge must resolve (not reject) even though setBadgeText throws + await expect(bg.internals.refreshToolbarBadge()).resolves.toBeUndefined(); + expect(bg.warn).toHaveBeenCalled(); + }); + + it("no-ops cleanly (no throw, no listener) when the chrome.action surface is unavailable", async () => { + const bg = loadBackground([1, 2, 3], { withAction: false }); + await flush(); + // module still loads and exports internals; the guarded startup/onChanged wiring simply never ran + expect(typeof bg.internals.refreshToolbarBadge).toBe("function"); + expect(bg.setBadgeText).not.toHaveBeenCalled(); + }); +});