swarm-ui: test the service worker's install and navigation logic
review-classifier asked for a test of sw.js's new behaviour. sw.test.mjs loads the worker in a sandbox with a fake cache and network: the install fetches past the HTTP cache and keeps every asset, a redirected or refused asset fails it with a logged reason, a navigation that reaches the network refreshes the cached entry document, a redirected or failed one does not, and offline a navigation is answered from the cache.
This commit is contained in:
parent
24f6690fe5
commit
067a260b85
1 changed files with 148 additions and 0 deletions
148
frontend/packages/swarm-ui/test/sw.test.mjs
Normal file
148
frontend/packages/swarm-ui/test/sw.test.mjs
Normal file
|
|
@ -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("/"));
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue