Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions apps/gittensory-miner-extension/background.js
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -71,12 +73,37 @@ 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,
ISSUE_CONTEXT_MESSAGE,
loadIssueOpportunityContext,
loadMinerExtensionSettings,
loadRankedCandidates,
refreshToolbarBadge,
};
}
9 changes: 9 additions & 0 deletions apps/gittensory-miner-extension/toolbar-badge.d.ts
Original file line number Diff line number Diff line change
@@ -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;
};
42 changes: 42 additions & 0 deletions apps/gittensory-miner-extension/toolbar-badge.js
Original file line number Diff line number Diff line change
@@ -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,
};
2 changes: 1 addition & 1 deletion test/unit/miner-extension-content.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
205 changes: 205 additions & 0 deletions test/unit/miner-toolbar-badge.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> = {
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<string, unknown> = {
__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<void>;
};
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();
});
});