const assert = require("node:assert/strict"); const fs = require("node:fs"); const path = require("node:path"); const test = require("node:test"); const vm = require("node:vm"); const { webcrypto } = require("node:crypto"); const BACKGROUND_PATH = path.join(__dirname, "background.js"); function createHarness(options = {}) { let storageChangedListener; let alarmCreateCount = 0; let clock = options.now ?? Date.now(); let fetchHandler = options.fetch; let cookieToken = ""; const localData = options.localData || {}; const fetchUrls = []; const settings = { apiBaseUrl: "http://localhost:8001", webBaseUrl: "http://localhost:5174", autoDiscoverServiceUrl: false, accessToken: "", tokenSyncEnabled: false, taskPollingEnabled: true, workerId: "test-worker", taskPollIntervalSeconds: 5, ...options.settings, }; const chrome = { alarms: { clear: async () => {}, create: async () => { alarmCreateCount += 1; }, onAlarm: { addListener: () => {} }, }, cookies: { get: async () => cookieToken ? { value: cookieToken } : null }, runtime: { onInstalled: { addListener: () => {} }, onMessage: { addListener: () => {} }, onStartup: { addListener: () => {} }, }, storage: { local: { get: async (defaults) => ({ ...defaults, ...localData }), set: async (updates) => Object.assign(localData, updates), }, session: { get: async () => ({ actSessions: {} }), set: async () => {} }, sync: { get: async () => ({ ...settings }), set: async (updates) => Object.assign(settings, updates), }, onChanged: { addListener: (listener) => { storageChangedListener = listener; }, }, }, tabs: { query: async () => [] }, }; const context = { chrome, console, fetch: async (url, init) => { fetchUrls.push(String(url)); if (fetchHandler) return fetchHandler(url, init); return { ok: true, status: 200, json: async () => ({ enabled: true }), }; }, importScripts: () => {}, setTimeout: () => 1, clearTimeout: () => {}, URL, AbortController, TextEncoder, crypto: webcrypto, Date: class extends Date { constructor(...args) { super(...(args.length ? args : [clock])); } static now() { return clock; } }, DEER_CLIPPER_DEFAULTS: settings, deerNormalizeBaseUrl: (value) => String(value || "").replace(/\/+$/, ""), deerMakeWorkerId: () => "test-worker", deerAuthHeaders: () => ({}), deerExtractAuthTokenFromPage: () => ({ token: "" }), }; context.globalThis = context; vm.runInNewContext(fs.readFileSync(BACKGROUND_PATH, "utf8"), context, { filename: BACKGROUND_PATH, }); return { emitStorageChange(changes) { storageChangedListener(changes, "sync"); }, getAlarmCreateCount: () => alarmCreateCount, getFetchUrls: () => [...fetchUrls], getSettings: () => context.getSettings(), setSettings: (updates) => Object.assign(settings, updates), getLocalData: () => ({ ...localData }), advanceTime: (ms) => { clock += ms; }, getTime: () => clock, setFetch: (handler) => { fetchHandler = handler; }, setCookie: (token) => { cookieToken = token; }, status: (url, settings = {}, force = false) => context.browserContextStatus(url, settings, force), discoverServiceBases: (settings, force = false) => context.discoverServiceBases(settings, force), resetAlarmCreateCount: () => { alarmCreateCount = 0; }, getApiCandidates: (webBase, currentApiBase) => vm.runInContext(`apiCandidatesForWebBase(${JSON.stringify(webBase)}, ${JSON.stringify(currentApiBase)})`, context), }; } const settle = () => new Promise((resolve) => setImmediate(resolve)); test("self-written sync metadata does not restart browser task polling", async () => { const harness = createHarness(); await settle(); harness.resetAlarmCreateCount(); harness.emitStorageChange({ lastTokenSyncAt: { oldValue: "before", newValue: "after" }, }); await settle(); assert.equal(harness.getAlarmCreateCount(), 0); }); test("polling configuration changes restart browser task polling", async () => { const harness = createHarness(); await settle(); harness.resetAlarmCreateCount(); harness.emitStorageChange({ taskPollingEnabled: { oldValue: false, newValue: true }, }); await settle(); assert.equal(harness.getAlarmCreateCount(), 1); }); test("service discovery prefers the API paired with the open zncm tab", async () => { const harness = createHarness(); assert.deepEqual( Array.from(harness.getApiCandidates("http://localhost:5174", "http://47.94.209.59:2026")), [ "http://localhost:5174", "http://localhost:8001", "http://47.94.209.59:2026", "http://localhost:8001", ].filter((value, index, values) => values.indexOf(value) === index), ); }); test("automatic service discovery checks status once per ten minutes, not on every task poll", async () => { const harness = createHarness(); const settings = await harness.getSettings(); settings.autoDiscoverServiceUrl = true; await harness.discoverServiceBases(settings); assert.equal(harness.getFetchUrls().length, 1); await harness.discoverServiceBases(await harness.getSettings()); assert.equal(harness.getFetchUrls().length, 1); await harness.discoverServiceBases(await harness.getSettings(), true); assert.equal(harness.getFetchUrls().length, 1); harness.advanceTime(30_000); await harness.discoverServiceBases(await harness.getSettings(), true); assert.equal(harness.getFetchUrls().length, 2); }); test("unchanged service discovery refreshes its timestamp after TTL expiry", async () => { const harness = createHarness(); await settle(); harness.setSettings({ autoDiscoverServiceUrl: true }); await harness.discoverServiceBases(await harness.getSettings()); const firstSync = harness.getLocalData().lastServiceSyncAt; harness.advanceTime(10 * 60_000 + 1); await harness.discoverServiceBases(await harness.getSettings()); assert.notEqual(harness.getLocalData().lastServiceSyncAt, firstSync); assert.equal(harness.getFetchUrls().length, 2); for (let i = 0; i < 20; i++) { harness.advanceTime(3_000); await harness.discoverServiceBases(await harness.getSettings()); } assert.equal(harness.getFetchUrls().length, 2); }); test("concurrent status callers share a request and successful results expire", async () => { const harness = createHarness(); const url = "http://example.test"; const results = await Promise.all(Array.from({ length: 30 }, () => harness.status(url))); assert.ok(results.every((result) => result.enabled)); assert.equal(harness.getFetchUrls().length, 1); harness.advanceTime(59_000); await harness.status(`${url}/`); assert.equal(harness.getFetchUrls().length, 1); harness.advanceTime(1_001); await harness.status(url); assert.equal(harness.getFetchUrls().length, 2); }); test("disabled status respects Retry-After even for manual calls and later recovers", async () => { const harness = createHarness({ fetch: async () => ({ ok: false, status: 403, headers: { get: () => "300" }, json: async () => ({ detail: { code: "browser_status_disabled" } }), }), }); const url = "http://example.test"; const status = await harness.status(url); assert.equal(status.enabled, false); assert.equal(status.statusAccessDisabled, true); assert.equal(status.authenticationRequired, undefined); harness.advanceTime(299_999); await harness.status(url, {}, true); assert.equal(harness.getFetchUrls().length, 1); harness.setFetch(async () => ({ ok: true, status: 200, json: async () => ({ enabled: true }) })); harness.advanceTime(1); assert.equal((await harness.status(url)).enabled, true); assert.equal(harness.getFetchUrls().length, 2); }); test("network and server failures back off for five minutes", async () => { for (const fetch of [ async () => { throw new Error("offline"); }, async () => ({ ok: false, status: 500, json: async () => ({ detail: "unavailable" }) }), ]) { const harness = createHarness({ fetch }); for (let i = 0; i < 10; i++) assert.equal(await harness.status("http://example.test", {}, true), null); assert.equal(harness.getFetchUrls().length, 1); harness.advanceTime(300_000); assert.equal(await harness.status("http://example.test"), null); assert.equal(harness.getFetchUrls().length, 2); } }); test("status cache separates service addresses, login tokens and cookies", async () => { const harness = createHarness(); await harness.status("http://one.test", { accessToken: "account-a" }); await harness.status("http://one.test", { accessToken: "account-b" }); await harness.status("http://two.test", { accessToken: "account-b" }); harness.setCookie("new-login"); await harness.status("http://two.test", { accessToken: "account-b" }); assert.equal(harness.getFetchUrls().length, 4); await harness.status("http://two.test", { accessToken: "account-b" }); assert.equal(harness.getFetchUrls().length, 4); }); test("discovery survives worker restarts but refreshes after account changes", async () => { const harness = createHarness(); await settle(); harness.setSettings({ autoDiscoverServiceUrl: true, accessToken: "account-a" }); await harness.discoverServiceBases(await harness.getSettings()); const localData = harness.getLocalData(); assert.ok(!JSON.stringify(localData).includes("account-a")); const restarted = createHarness({ localData, now: harness.getTime(), settings: { autoDiscoverServiceUrl: true, accessToken: "account-a" }, }); await restarted.discoverServiceBases(await restarted.getSettings()); assert.equal(restarted.getFetchUrls().length, 0); restarted.setSettings({ accessToken: "account-b" }); await restarted.discoverServiceBases(await restarted.getSettings()); assert.equal(restarted.getFetchUrls().length, 1); });