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?.(); + }, + }; +}