diff --git a/apps/gittensory-miner-extension/background.js b/apps/gittensory-miner-extension/background.js
index 98ea7b4444..7005403392 100644
--- a/apps/gittensory-miner-extension/background.js
+++ b/apps/gittensory-miner-extension/background.js
@@ -6,6 +6,7 @@ const toolbarBadgeApi = globalThis.__gittensoryMinerToolbarBadge;
const PING_MESSAGE = "gittensory-miner:ping";
const ISSUE_CONTEXT_MESSAGE = "gittensory-miner:issue-context";
+const SYNC_RANKED_CANDIDATES_MESSAGE = "gittensory-miner:sync-ranked-candidates";
chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
if (!message || typeof message.type !== "string") return false;
@@ -20,6 +21,10 @@ chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
);
return true;
}
+ if (message.type === SYNC_RANKED_CANDIDATES_MESSAGE) {
+ void syncRankedCandidatesFromMinerUi().then((result) => sendResponse({ ok: true, payload: result }));
+ return true;
+ }
return false;
});
@@ -79,6 +84,69 @@ async function loadRankedCandidates() {
};
}
+const DEFAULT_MINER_UI_URL = "http://localhost:5174";
+const SYNC_ALARM_NAME = "gittensory-miner:sync-ranked-candidates";
+const SYNC_ALARM_PERIOD_MINUTES = 10;
+
+async function loadMinerUiUrl() {
+ const stored = await chrome.storage.sync.get({ minerUiUrl: DEFAULT_MINER_UI_URL });
+ const url = typeof stored.minerUiUrl === "string" ? stored.minerUiUrl.trim() : "";
+ return url || DEFAULT_MINER_UI_URL;
+}
+
+/** Live-fetch replacement for the manual copy/paste workflow (#4859): pulls the miner's last discover run's
+ * ranked candidates from the local miner-ui's read-only /api/ranked-candidates endpoint (packages/gittensory-
+ * miner/lib/ranked-candidates.js via apps/gittensory-miner-ui/vite-ranked-candidates-api.ts) and writes them
+ * into the SAME chrome.storage.local keys the manual-paste flow (options.js) already writes
+ * (rankedCandidates/rankedCandidatesSavedAt) -- so content.js/opportunity-badge.js/toolbar-badge.js need zero
+ * changes; they already read from that one shared source regardless of which flow populated it.
+ *
+ * Never throws: any failure (miner-ui not running, network error, missing auth cookie because the dashboard
+ * was never opened in this browser, malformed response) resolves to a typed { ok: false } result and leaves
+ * whatever's already in storage untouched -- the existing manual-paste fallback (or a stale prior fetch) keeps
+ * working exactly as before, satisfying #4859's "keep paste as a fallback" requirement with no merge logic. */
+async function syncRankedCandidatesFromMinerUi() {
+ const minerUiUrl = await loadMinerUiUrl();
+ try {
+ const response = await fetch(`${minerUiUrl}/api/ranked-candidates`);
+ if (!response.ok) {
+ return { ok: false, error: `miner UI responded ${response.status}`, minerUiUrl };
+ }
+ const payload = await response.json();
+ const candidates = Array.isArray(payload?.candidates) ? payload.candidates : null;
+ if (!candidates) {
+ return { ok: false, error: "miner UI returned an unexpected payload shape", minerUiUrl };
+ }
+ const savedAt = Date.now();
+ await chrome.storage.local.set({ rankedCandidates: candidates, rankedCandidatesSavedAt: savedAt });
+ return { ok: true, count: candidates.length, savedAt, minerUiUrl };
+ } catch (error) {
+ return {
+ ok: false,
+ error: error instanceof Error ? error.message : String(error),
+ minerUiUrl,
+ };
+ }
+}
+
+// Ambient refresh so live data stays current without the user re-opening the options page: once on service-
+// worker startup/install, then every SYNC_ALARM_PERIOD_MINUTES via chrome.alarms (a service worker can be
+// killed and woken between calls, so a plain setInterval would not survive -- alarms are the MV3-correct
+// primitive for this). Guarded per-API so the unit-test harness (which provides none of these) is a clean
+// no-op, matching the toolbar-badge guard below.
+if (chrome.alarms) {
+ chrome.alarms.create(SYNC_ALARM_NAME, { periodInMinutes: SYNC_ALARM_PERIOD_MINUTES });
+ chrome.alarms.onAlarm.addListener((alarm) => {
+ if (alarm.name === SYNC_ALARM_NAME) void syncRankedCandidatesFromMinerUi();
+ });
+}
+if (chrome.runtime.onStartup) {
+ chrome.runtime.onStartup.addListener(() => void syncRankedCandidatesFromMinerUi());
+}
+if (chrome.runtime.onInstalled) {
+ chrome.runtime.onInstalled.addListener(() => void syncRankedCandidatesFromMinerUi());
+}
+
// 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() {
@@ -107,9 +175,13 @@ if (globalThis.__GITTENSORY_MINER_EXTENSION_TEST__) {
globalThis.__gittensoryMinerBackgroundInternals = {
PING_MESSAGE,
ISSUE_CONTEXT_MESSAGE,
+ SYNC_RANKED_CANDIDATES_MESSAGE,
+ DEFAULT_MINER_UI_URL,
loadIssueOpportunityContext,
loadMinerExtensionSettings,
loadRankedCandidates,
+ loadMinerUiUrl,
+ syncRankedCandidatesFromMinerUi,
refreshToolbarBadge,
};
}
diff --git a/apps/gittensory-miner-extension/manifest.json b/apps/gittensory-miner-extension/manifest.json
index a43eb2d15e..105a7cab4d 100644
--- a/apps/gittensory-miner-extension/manifest.json
+++ b/apps/gittensory-miner-extension/manifest.json
@@ -9,7 +9,7 @@
"48": "icons/icon-48.png",
"128": "icons/icon-128.png"
},
- "permissions": ["storage"],
+ "permissions": ["storage", "alarms"],
"host_permissions": ["https://github.com/*", "http://localhost/*", "http://127.0.0.1/*"],
"background": {
"service_worker": "background.js",
diff --git a/apps/gittensory-miner-extension/options.html b/apps/gittensory-miner-extension/options.html
index 9fafe55594..e5816f93b2 100644
--- a/apps/gittensory-miner-extension/options.html
+++ b/apps/gittensory-miner-extension/options.html
@@ -65,7 +65,16 @@
LoopOver miner extension
+
+ Ranked candidates sync automatically from the URL above (checked every 10 minutes, and once whenever
+ Chrome or this extension starts) — no action needed once it's running. Use "Sync now" for an
+ immediate pull, or paste JSON below by hand as a fallback when the miner UI isn't reachable.
+
+
+
diff --git a/apps/gittensory-miner-extension/options.js b/apps/gittensory-miner-extension/options.js
index 7480239264..a8efe40a2c 100644
--- a/apps/gittensory-miner-extension/options.js
+++ b/apps/gittensory-miner-extension/options.js
@@ -40,12 +40,26 @@ async function removeLegacyDiscoveryIndexUrl() {
await chrome.storage.sync.remove("discoveryIndexUrl");
}
+// Mirrors background.js's own literal (#4859) -- these classic (non-ESM-importing) extension scripts share a
+// message-type "protocol" via matching string literals, the same convention content.js already uses for
+// ISSUE_CONTEXT_MESSAGE, not a cross-file import.
+const SYNC_RANKED_CANDIDATES_MESSAGE = "gittensory-miner:sync-ranked-candidates";
+const DEFAULT_MINER_UI_URL = "http://localhost:5174";
+
+function normalizeMinerUiUrl(text) {
+ const trimmed = String(text ?? "").trim();
+ return trimmed || DEFAULT_MINER_UI_URL;
+}
+
if (globalThis.__GITTENSORY_MINER_EXTENSION_TEST__) {
globalThis.__gittensoryMinerOptionsInternals = {
parseWatchedRepos,
parseRankedCandidatesJson,
removeLegacyDiscoveryIndexUrl,
+ normalizeMinerUiUrl,
MAX_RANKED_CANDIDATES_JSON_BYTES,
+ SYNC_RANKED_CANDIDATES_MESSAGE,
+ DEFAULT_MINER_UI_URL,
};
}
@@ -53,8 +67,10 @@ const form = document.querySelector("#settings");
const status = document.querySelector("#status");
const watchedRepos = document.querySelector("#watchedRepos");
const rankedCandidatesJson = document.querySelector("#rankedCandidatesJson");
+const minerUiUrl = document.querySelector("#minerUiUrl");
+const syncNow = document.querySelector("#syncNow");
-if (!form || !status || !watchedRepos || !rankedCandidatesJson) {
+if (!form || !status || !watchedRepos || !rankedCandidatesJson || !minerUiUrl || !syncNow) {
// options.html is not mounted (unit-test harness or partial load).
} else {
void refreshSettings();
@@ -64,7 +80,7 @@ form.addEventListener("submit", async (event) => {
try {
const repos = parseWatchedRepos(watchedRepos.value);
const rankedCandidates = parseRankedCandidatesJson(rankedCandidatesJson.value);
- await chrome.storage.sync.set({ watchedRepos: repos });
+ await chrome.storage.sync.set({ watchedRepos: repos, minerUiUrl: normalizeMinerUiUrl(minerUiUrl.value) });
await chrome.storage.local.set({ rankedCandidates, rankedCandidatesSavedAt: Date.now() });
await refreshSettings();
showStatus(
@@ -76,14 +92,34 @@ form.addEventListener("submit", async (event) => {
showStatus(error instanceof Error ? error.message : String(error));
}
});
+
+// Live-fetch trigger (#4859): asks background.js's syncRankedCandidatesFromMinerUi to pull the miner-ui's
+// current ranked candidates immediately, without waiting for the ambient alarm. Saves the URL field first so
+// a URL the user just typed (but hasn't submitted the form for yet) is what gets used.
+syncNow.addEventListener("click", async () => {
+ try {
+ await chrome.storage.sync.set({ minerUiUrl: normalizeMinerUiUrl(minerUiUrl.value) });
+ const response = await chrome.runtime.sendMessage({ type: SYNC_RANKED_CANDIDATES_MESSAGE });
+ const result = response?.payload;
+ if (!result?.ok) {
+ showStatus(`Could not reach the miner UI at ${result?.minerUiUrl ?? minerUiUrl.value}: ${result?.error ?? "unknown error"}. Falling back to the pasted JSON below.`);
+ return;
+ }
+ await refreshSettings();
+ showStatus(`Synced ${result.count} ranked candidate(s) from ${result.minerUiUrl}.`);
+ } catch (error) {
+ showStatus(error instanceof Error ? error.message : String(error));
+ }
+});
}
async function refreshSettings() {
- const stored = await chrome.storage.sync.get({ watchedRepos: [] });
+ const stored = await chrome.storage.sync.get({ watchedRepos: [], minerUiUrl: DEFAULT_MINER_UI_URL });
await removeLegacyDiscoveryIndexUrl();
const local = await chrome.storage.local.get({ rankedCandidates: [] });
const repos = Array.isArray(stored.watchedRepos) ? stored.watchedRepos : [];
watchedRepos.value = repos.join("\n");
+ minerUiUrl.value = normalizeMinerUiUrl(stored.minerUiUrl);
const rankedCandidates = Array.isArray(local.rankedCandidates) ? local.rankedCandidates : [];
rankedCandidatesJson.value =
rankedCandidates.length > 0 ? JSON.stringify(rankedCandidates, null, 2) : "";
diff --git a/test/unit/miner-extension-content.test.ts b/test/unit/miner-extension-content.test.ts
index 1dd31a51b4..1b04365285 100644
--- a/test/unit/miner-extension-content.test.ts
+++ b/test/unit/miner-extension-content.test.ts
@@ -206,6 +206,8 @@ describe("miner extension opportunity badge", () => {
"#status": { textContent: "" },
"#watchedRepos": { value: "JSONbored/gittensory" },
"#rankedCandidatesJson": { value: "" },
+ "#minerUiUrl": { value: "" },
+ "#syncNow": { addEventListener: () => {} },
};
const context: Record = {
__GITTENSORY_MINER_EXTENSION_TEST__: true,
@@ -343,6 +345,8 @@ describe("miner extension opportunity badge", () => {
"#status": { textContent: "" },
"#watchedRepos": { value: "JSONbored/gittensory" },
"#rankedCandidatesJson": { value: "[]" },
+ "#minerUiUrl": { value: "" },
+ "#syncNow": { addEventListener: () => {} },
};
const context: Record = {
__GITTENSORY_MINER_EXTENSION_TEST__: true,
@@ -394,6 +398,8 @@ describe("miner extension opportunity badge", () => {
"#status": { textContent: "" },
"#watchedRepos": { value: "" },
"#rankedCandidatesJson": { value: "" },
+ "#minerUiUrl": { value: "" },
+ "#syncNow": { addEventListener: () => {} },
};
const context: Record = {
__GITTENSORY_MINER_EXTENSION_TEST__: true,
@@ -432,7 +438,10 @@ describe("miner extension opportunity badge", () => {
await elements["#settings"].dispatchSubmit();
expect(setCalls).toHaveLength(1);
- expect(setCalls[0]).toEqual({ watchedRepos: ["JSONbored/gittensory"] });
+ expect(setCalls[0]).toEqual({
+ watchedRepos: ["JSONbored/gittensory"],
+ minerUiUrl: "http://localhost:5174",
+ });
expect(removeCalls).toEqual(["discoveryIndexUrl", "discoveryIndexUrl"]);
expect("discoveryIndexUrl" in synced).toBe(false);
});
diff --git a/test/unit/miner-extension-live-fetch.test.ts b/test/unit/miner-extension-live-fetch.test.ts
new file mode 100644
index 0000000000..174f1913a6
--- /dev/null
+++ b/test/unit/miner-extension-live-fetch.test.ts
@@ -0,0 +1,339 @@
+import { readFileSync } from "node:fs";
+import { Script, createContext } from "node:vm";
+import { describe, expect, it } from "vitest";
+
+// Live-fetch replacement for the manual copy/paste workflow (#4859): background.js pulls ranked candidates
+// from the local miner-ui's /api/ranked-candidates (built by #5619) and writes them into the SAME
+// chrome.storage.local keys the manual-paste flow (options.js) already writes, so content.js/opportunity-
+// badge.js/toolbar-badge.js need zero changes. This file covers the NEW sync machinery specifically; the
+// pre-existing badge/paste/purge behavior stays covered by miner-extension-content.test.ts.
+
+const backgroundScript = readFileSync("apps/gittensory-miner-extension/background.js", "utf8");
+const optionsScript = readFileSync("apps/gittensory-miner-extension/options.js", "utf8");
+const manifest = JSON.parse(readFileSync("apps/gittensory-miner-extension/manifest.json", "utf8"));
+
+function flushPromises() {
+ return new Promise((resolve) => setTimeout(resolve, 0));
+}
+
+function backgroundScriptForVm() {
+ // Strip the top-of-file `import "./x.js"` lines, same as miner-extension-content.test.ts's harness: the
+ // node:vm sandbox has no module resolution, and this file only needs the raw statements that follow.
+ return backgroundScript.replace(/^import\s+["'][^"']+["'];\s*/gm, "");
+}
+
+type FakeChromeOptions = {
+ minerUiUrl?: string;
+ fetchImpl?: typeof fetch;
+ withAlarms?: boolean;
+ withLifecycle?: boolean;
+};
+
+function loadBackgroundWithFakeChrome({
+ minerUiUrl = "http://localhost:5174",
+ fetchImpl,
+ withAlarms = false,
+ withLifecycle = false,
+}: FakeChromeOptions = {}) {
+ const localSetCalls: Array> = [];
+ const alarmCreateCalls: Array<[string, unknown]> = [];
+ 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)
+ | undefined;
+
+ 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: async () => ({ minerUiUrl }) },
+ local: {
+ get: async () => ({ rankedCandidates: [] }),
+ set: async (value: Record) => {
+ localSetCalls.push(value);
+ },
+ },
+ },
+ };
+ if (withAlarms) {
+ chrome.alarms = {
+ create: (name: string, info: unknown) => alarmCreateCalls.push([name, info]),
+ onAlarm: { addListener: (fn: typeof alarmListener) => (alarmListener = fn) },
+ };
+ }
+
+ const context: Record = {
+ __GITTENSORY_MINER_EXTENSION_TEST__: true,
+ chrome,
+ fetch: fetchImpl,
+ };
+ context.globalThis = context;
+ const vmContext = createContext(context);
+ new Script(backgroundScriptForVm()).runInContext(vmContext);
+
+ const internals = vmContext.__gittensoryMinerBackgroundInternals as {
+ SYNC_RANKED_CANDIDATES_MESSAGE: string;
+ DEFAULT_MINER_UI_URL: string;
+ loadMinerUiUrl: () => Promise;
+ syncRankedCandidatesFromMinerUi: () => Promise>;
+ };
+
+ return {
+ internals,
+ localSetCalls,
+ alarmCreateCalls,
+ dispatchAlarm: (name: string) => alarmListener?.({ name }),
+ dispatchStartup: () => startupListener?.(),
+ dispatchInstalled: () => installedListener?.(),
+ dispatchMessage: (message: unknown) =>
+ new Promise((resolve) => {
+ const keepChannelOpen = messageListener?.(message, {}, resolve);
+ if (!keepChannelOpen) resolve(undefined);
+ }),
+ };
+}
+
+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;
+}
+
+describe("manifest.json grants the alarms permission for ambient sync (#4859)", () => {
+ it("includes alarms alongside the existing storage permission", () => {
+ expect(manifest.permissions).toContain("alarms");
+ expect(manifest.permissions).toContain("storage");
+ });
+});
+
+describe("syncRankedCandidatesFromMinerUi (#4859)", () => {
+ it("fetches from the configured miner UI URL and writes candidates + a savedAt timestamp into local storage", async () => {
+ const candidates = [{ repoFullName: "acme/widgets", issueNumber: 1, rankScore: 0.8 }];
+ const { internals, localSetCalls } = loadBackgroundWithFakeChrome({
+ minerUiUrl: "http://localhost:5174",
+ fetchImpl: jsonFetch(200, { candidates }),
+ });
+
+ const result = await internals.syncRankedCandidatesFromMinerUi();
+
+ expect(result.ok).toBe(true);
+ expect(result.count).toBe(1);
+ expect(result.minerUiUrl).toBe("http://localhost:5174");
+ expect(typeof result.savedAt).toBe("number");
+ expect(localSetCalls).toHaveLength(1);
+ expect(localSetCalls[0]).toEqual({ rankedCandidates: candidates, rankedCandidatesSavedAt: result.savedAt });
+ });
+
+ it("falls back to DEFAULT_MINER_UI_URL when no URL is stored", async () => {
+ const { internals } = loadBackgroundWithFakeChrome({
+ minerUiUrl: "",
+ fetchImpl: jsonFetch(200, { candidates: [] }),
+ });
+ expect(await internals.loadMinerUiUrl()).toBe(internals.DEFAULT_MINER_UI_URL);
+ });
+
+ it("surfaces a non-2xx response as a typed failure without touching storage", async () => {
+ const { internals, localSetCalls } = loadBackgroundWithFakeChrome({ fetchImpl: jsonFetch(401, {}) });
+ const result = await internals.syncRankedCandidatesFromMinerUi();
+ expect(result).toEqual({ ok: false, error: "miner UI responded 401", minerUiUrl: "http://localhost:5174" });
+ expect(localSetCalls).toHaveLength(0);
+ });
+
+ it("surfaces a malformed payload (candidates not an array) as a typed failure without touching storage", async () => {
+ const { internals, localSetCalls } = loadBackgroundWithFakeChrome({ fetchImpl: jsonFetch(200, { candidates: "nope" }) });
+ const result = await internals.syncRankedCandidatesFromMinerUi();
+ expect(result).toEqual({
+ ok: false,
+ error: "miner UI returned an unexpected payload shape",
+ minerUiUrl: "http://localhost:5174",
+ });
+ expect(localSetCalls).toHaveLength(0);
+ });
+
+ it("surfaces a thrown fetch (miner UI not running) as a typed failure without touching storage, never throwing", async () => {
+ // Throws a plain string, not `new Error(...)`: a node:vm sandbox has its own realm-local Error
+ // constructor, so an Error built in THIS (outer) realm would fail the production code's own
+ // `error instanceof Error` check once thrown inside the sandbox -- a test-harness artifact, not something
+ // that can happen in the real single-realm extension runtime. A string throw sidesteps the cross-realm
+ // instanceof gotcha while still exercising the "non-Error thrown value" fallback (`String(error)`).
+ const { internals, localSetCalls } = loadBackgroundWithFakeChrome({
+ fetchImpl: (async () => {
+ throw "connect ECONNREFUSED";
+ }) as unknown as typeof fetch,
+ });
+ const result = await internals.syncRankedCandidatesFromMinerUi();
+ expect(result).toEqual({ ok: false, error: "connect ECONNREFUSED", minerUiUrl: "http://localhost:5174" });
+ expect(localSetCalls).toHaveLength(0);
+ });
+
+ it("responds to the SYNC_RANKED_CANDIDATES_MESSAGE runtime message with the sync result", async () => {
+ const candidates = [{ repoFullName: "acme/widgets", issueNumber: 1, rankScore: 0.8 }];
+ const { internals, dispatchMessage } = loadBackgroundWithFakeChrome({ fetchImpl: jsonFetch(200, { candidates }) });
+
+ const response = (await dispatchMessage({ type: internals.SYNC_RANKED_CANDIDATES_MESSAGE })) as {
+ ok: boolean;
+ payload: { ok: boolean; count: number };
+ };
+ expect(response.ok).toBe(true);
+ expect(response.payload.ok).toBe(true);
+ expect(response.payload.count).toBe(1);
+ });
+
+ it("wires an alarms-based ambient refresh when chrome.alarms is present, and only syncs for its own alarm name", async () => {
+ const { alarmCreateCalls, dispatchAlarm, localSetCalls } = loadBackgroundWithFakeChrome({
+ withAlarms: true,
+ fetchImpl: jsonFetch(200, { candidates: [] }),
+ });
+ expect(alarmCreateCalls).toHaveLength(1);
+ const [name, info] = alarmCreateCalls[0]!;
+ expect(name).toBe("gittensory-miner:sync-ranked-candidates");
+ expect(info).toEqual({ periodInMinutes: 10 });
+
+ dispatchAlarm("some-other-extensions-alarm");
+ await flushPromises();
+ expect(localSetCalls).toHaveLength(0);
+
+ dispatchAlarm(name);
+ await flushPromises();
+ expect(localSetCalls).toHaveLength(1);
+ });
+
+ it("is a clean no-op to load (no throw) when chrome.alarms is absent, matching the toolbar-badge guard's discipline", () => {
+ expect(() => loadBackgroundWithFakeChrome({ withAlarms: false })).not.toThrow();
+ });
+
+ it("syncs once on startup and once on install when those lifecycle events are available", async () => {
+ const { dispatchStartup, dispatchInstalled, localSetCalls } = loadBackgroundWithFakeChrome({
+ withLifecycle: true,
+ fetchImpl: jsonFetch(200, { candidates: [] }),
+ });
+ dispatchStartup();
+ await flushPromises();
+ expect(localSetCalls).toHaveLength(1);
+
+ dispatchInstalled();
+ await flushPromises();
+ expect(localSetCalls).toHaveLength(2);
+ });
+});
+
+describe("options.js miner-UI URL field + Sync now button (#4859)", () => {
+ function loadOptionsWithFakeChrome({
+ minerUiUrl = "",
+ syncResponse = { ok: true, payload: { ok: true, count: 2, minerUiUrl: "http://localhost:5174" } } as unknown,
+ } = {}) {
+ const syncSetCalls: Array> = [];
+ const sentMessages: unknown[] = [];
+ const elements = {
+ "#settings": createFormMock(),
+ "#status": { textContent: "" },
+ "#watchedRepos": { value: "" },
+ "#rankedCandidatesJson": { value: "" },
+ "#minerUiUrl": { value: minerUiUrl },
+ "#syncNow": createClickMock(),
+ };
+ const context: Record = {
+ __GITTENSORY_MINER_EXTENSION_TEST__: true,
+ TextEncoder,
+ document: { querySelector: (selector: string) => elements[selector as keyof typeof elements] ?? null },
+ chrome: {
+ storage: {
+ sync: {
+ get: async () => ({ watchedRepos: [], minerUiUrl }),
+ set: async (value: Record) => {
+ syncSetCalls.push(value);
+ },
+ remove: async () => {},
+ },
+ local: { get: async () => ({ rankedCandidates: [] }), set: async () => {} },
+ },
+ runtime: {
+ sendMessage: async (message: unknown) => {
+ sentMessages.push(message);
+ return syncResponse;
+ },
+ },
+ },
+ window: { setTimeout: () => 0 },
+ };
+ context.globalThis = context;
+ const vmContext = createContext(context);
+ new Script(optionsScript).runInContext(vmContext);
+ return { elements, syncSetCalls, sentMessages, vmContext };
+ }
+
+ it("saves the URL alongside watchedRepos on form submit", async () => {
+ const { elements, syncSetCalls } = loadOptionsWithFakeChrome();
+ (elements["#minerUiUrl"] as { value: string }).value = "http://localhost:9999";
+ await elements["#settings"].dispatchSubmit();
+ expect(syncSetCalls).toHaveLength(1);
+ expect(syncSetCalls[0]).toMatchObject({ minerUiUrl: "http://localhost:9999" });
+ });
+
+ it("normalizes an empty/whitespace URL to the default on save", async () => {
+ const { elements, syncSetCalls } = loadOptionsWithFakeChrome();
+ (elements["#minerUiUrl"] as { value: string }).value = " ";
+ await elements["#settings"].dispatchSubmit();
+ expect(syncSetCalls[0]).toMatchObject({ minerUiUrl: "http://localhost:5174" });
+ });
+
+ it("populates the URL field from storage on load, falling back to the default when unset", async () => {
+ const { vmContext } = loadOptionsWithFakeChrome({ minerUiUrl: "http://localhost:7777" });
+ await flushPromises();
+ expect((vmContext.document as { querySelector: (s: string) => { value: string } }).querySelector("#minerUiUrl").value).toBe(
+ "http://localhost:7777",
+ );
+ });
+
+ it("Sync now sends the sync message and shows a success status with the candidate count", async () => {
+ const { elements, sentMessages } = loadOptionsWithFakeChrome({
+ syncResponse: { ok: true, payload: { ok: true, count: 3, minerUiUrl: "http://localhost:5174" } },
+ });
+ await (elements["#syncNow"] as ReturnType).dispatchClick();
+ expect(sentMessages).toEqual([{ type: "gittensory-miner:sync-ranked-candidates" }]);
+ expect((elements["#status"] as { textContent: string }).textContent).toMatch(/Synced 3 ranked candidate/);
+ });
+
+ it("Sync now shows a fallback-to-paste message when the miner UI can't be reached", async () => {
+ const { elements } = loadOptionsWithFakeChrome({
+ syncResponse: {
+ ok: true,
+ payload: { ok: false, error: "failed to reach the local miner UI", minerUiUrl: "http://localhost:5174" },
+ },
+ });
+ await (elements["#syncNow"] as ReturnType).dispatchClick();
+ expect((elements["#status"] as { textContent: string }).textContent).toMatch(/Falling back to the pasted JSON/);
+ });
+});
+
+function createFormMock() {
+ let submitHandler: ((event: { preventDefault: () => void }) => unknown) | null = null;
+ return {
+ addEventListener: (type: string, handler: typeof submitHandler) => {
+ if (type === "submit") submitHandler = handler;
+ },
+ dispatchSubmit: async () => {
+ await submitHandler?.({ preventDefault: () => {} });
+ },
+ };
+}
+
+function createClickMock() {
+ let clickHandler: (() => unknown) | null = null;
+ return {
+ addEventListener: (type: string, handler: typeof clickHandler) => {
+ if (type === "click") clickHandler = handler;
+ },
+ dispatchClick: async () => {
+ await clickHandler?.();
+ },
+ };
+}