279 lines
9.9 KiB
JavaScript
279 lines
9.9 KiB
JavaScript
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);
|
|
});
|