diff --git a/frontend/packages/swarm-ui/test/sw.test.mjs b/frontend/packages/swarm-ui/test/sw.test.mjs new file mode 100644 index 00000000..6f31f40e --- /dev/null +++ b/frontend/packages/swarm-ui/test/sw.test.mjs @@ -0,0 +1,148 @@ +// The service worker's own logic, run in a sandbox with a fake cache and a fake +// fetch: what the install keeps, when it fails, and when a navigation refreshes +// the cached entry document. Run by `npm test`. +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { test } from "node:test"; +import { runInNewContext } from "node:vm"; + +const SOURCE = readFileSync(new URL("../src/sw.js", import.meta.url), "utf8"); +const ORIGIN = "https://swarm.example"; + +function res({ ok = true, redirected = false, status = 200 } = {}) { + const r = { ok, redirected, status, body: `body-${Math.random()}` }; + r.clone = () => ({ ...r }); + return r; +} + +// A worker loaded against a fake network. `network(url, init)` returns a +// response or throws; `stores` is every cache the worker opened. +function load(network) { + const handlers = {}; + const stores = new Map(); + const calls = []; + const errors = []; + const open = async (name) => { + if (!stores.has(name)) stores.set(name, new Map()); + const store = stores.get(name); + return { + put: async (key, value) => { + store.set(typeof key === "string" ? key : key.url, value); + }, + keys: async () => [...store.keys()], + }; + }; + const sandbox = { + self: { + location: { origin: ORIGIN }, + addEventListener: (type, fn) => (handlers[type] = fn), + skipWaiting: () => {}, + clients: { claim: async () => {} }, + }, + caches: { + open, + keys: async () => [...stores.keys()], + delete: async (name) => stores.delete(name), + match: async (url) => { + for (const store of stores.values()) { + if (store.has(url)) return store.get(url); + } + return undefined; + }, + }, + fetch: async (input, init) => { + const url = typeof input === "string" ? input : input.url; + calls.push({ url, init }); + return network(url, init); + }, + URL, + console: { error: (...args) => errors.push(args.join(" ")) }, + }; + runInNewContext(SOURCE, sandbox); + return { handlers, stores, calls, errors }; +} + +function install(worker) { + const waits = []; + worker.handlers.install({ waitUntil: (p) => waits.push(p) }); + return Promise.all(waits); +} + +function navigate(worker, url) { + const waits = []; + let answer; + worker.handlers.fetch({ + request: { url: ORIGIN + url, mode: "navigate" }, + respondWith: (p) => (answer = p), + waitUntil: (p) => waits.push(p), + }); + return { answer, settled: () => Promise.all(waits) }; +} + +test("the install fetches every shell asset past the HTTP cache and keeps it", async () => { + const worker = load(() => res()); + await install(worker); + assert.ok(worker.calls.length >= 7); + assert.ok(worker.calls.every((c) => c.init?.cache === "reload")); + const [store] = [...worker.stores.values()]; + assert.ok(store.has("/")); + assert.ok(store.has("/static/colors.css")); +}); + +test("a redirected asset fails the install and says why", async () => { + const worker = load((url) => + url === "/static/colors.css" ? res({ redirected: true }) : res(), + ); + await assert.rejects(install(worker), /colors\.css.*redirect/); + assert.equal(worker.errors.length, 1); +}); + +test("an asset the server refuses fails the install", async () => { + const worker = load((url) => + url === "/manifest.webmanifest" ? res({ ok: false, status: 404 }) : res(), + ); + await assert.rejects(install(worker), /manifest\.webmanifest.*404/); +}); + +test("a navigation that reaches the network refreshes the cached entry document", async () => { + const fresh = res(); + const net = load(() => fresh); + await install(net); + const nav = navigate(net, "/agents/iris/terminal"); + const answered = await nav.answer; + await nav.settled(); + assert.equal(answered, fresh); + const [store] = [...net.stores.values()]; + assert.equal(store.get("/").body, fresh.body); +}); + +test("a redirected or failed navigation does not replace the cached entry document", async () => { + let next = res(); + const worker = load((url) => (url === "/" ? res() : next)); + await install(worker); + const [store] = [...worker.stores.values()]; + const before = store.get("/"); + for (const bad of [ + res({ redirected: true }), + res({ ok: false, status: 502 }), + ]) { + next = bad; + const nav = navigate(worker, "/agents"); + await nav.answer; + await nav.settled(); + assert.equal(store.get("/"), before); + } +}); + +test("offline, a navigation is answered from the cached entry document", async () => { + let online = true; + const worker = load((url) => { + if (online || url === "/") return res(); + throw new TypeError("offline"); + }); + await install(worker); + online = false; + const [store] = [...worker.stores.values()]; + const nav = navigate(worker, "/jobs"); + assert.equal(await nav.answer, store.get("/")); +});