import { timedTest } from "./boot.js";
import assert from "node:assert/strict";
import { readFileSync, readdirSync } from "node:fs";
import vm from "node:vm";
const swTest = timedTest(2000);
// The shell's deadline is 3s, so the test that waits it out needs longer.
const stalledTest = timedTest(8000);
const ORIGIN = "https://badges.simplex.chat";
const WORKER_SOURCE = readFileSync(new URL("../../public/sw.js", import.meta.url), "utf8");
const SHELL_HTML = readFileSync(new URL("../../public/index.html", import.meta.url), "utf8");
// Anubis serves its challenge as HTML at the page's own path.
const ANUBIS_CHALLENGE = `
Making sure you are not a bot
`;
interface FakeResponse {
url: string;
ok: boolean;
status: number;
body: string;
bodyUsed: boolean;
clone(): FakeResponse;
text(): Promise;
}
// A single-use body, since text() disturbs it and Cache.put rejects a disturbed body, as a browser does.
function response(url: string, status: number, body: string): FakeResponse {
let used = false;
return {
url, status, body,
ok: status >= 200 && status < 300,
get bodyUsed() { return used; },
clone() {
if (used) throw new TypeError("Response body is already used");
return response(url, status, body);
},
async text() {
if (used) throw new TypeError("Response body is already used");
used = true;
return body;
},
};
}
function keyOf(key: unknown): string {
const raw = typeof key === "string" ? key : String((key as { url: string }).url);
return new URL(raw, `${ORIGIN}/`).href;
}
function methodOf(key: unknown): string {
return typeof key === "string" ? "GET" : String((key as { method?: string }).method ?? "GET");
}
class FakeCache {
readonly entries = new Map();
readonly matched: string[] = [];
readonly written: string[] = [];
constructor(private readonly net: FakeNet) {}
async match(key: unknown): Promise {
this.matched.push(keyOf(key));
// The Cache API matches GET only, whatever is stored under the URL.
if (methodOf(key) !== "GET") return undefined;
return this.entries.get(keyOf(key));
}
async put(key: unknown, res: FakeResponse): Promise {
if (res.bodyUsed) throw new TypeError("Failed to execute 'put' on 'Cache': Response body is already used");
this.written.push(keyOf(key));
this.entries.set(keyOf(key), res);
}
// The whole set is added or none of it is.
async addAll(keys: readonly string[]): Promise {
const got = await Promise.all(keys.map(async (k) => [k, await this.net.fetch(k)] as const));
const bad = got.find(([, res]) => !res.ok);
if (bad) throw new Error(`addAll: ${bad[0]} answered ${bad[1].status}`);
for (const [k, res] of got) await this.put(k, res);
}
async keys(): Promise { return [...this.entries.keys()]; }
}
class FakeCacheStorage {
readonly stores = new Map();
readonly opened: string[] = [];
readonly deleted: string[] = [];
constructor(private readonly net: FakeNet) {}
async open(name: string): Promise {
this.opened.push(name);
const existing = this.stores.get(name);
if (existing !== undefined) return existing;
const made = new FakeCache(this.net);
this.stores.set(name, made);
return made;
}
async keys(): Promise { return [...this.stores.keys()]; }
async delete(name: string): Promise {
this.deleted.push(name);
return this.stores.delete(name);
}
}
class FakeNet {
readonly requests: Array<{ url: string; init?: Record }> = [];
readonly routes = new Map();
offline = false;
readonly stalled = new Set();
serve(path: string, status: number, body: string): void {
this.routes.set(path, { status, body });
}
stall(path: string): void {
this.stalled.add(path);
}
readonly fetch = async (input: unknown, init?: Record): Promise => {
const url = keyOf(input);
this.requests.push(init ? { url, init } : { url });
if (this.offline) throw new TypeError("Failed to fetch");
const path = url.slice(ORIGIN.length);
if (this.stalled.has(path)) return new Promise(() => { /* never answers */ });
const hit = this.routes.get(path);
return hit === undefined
? response(url, 404, "not found")
: response(url, hit.status, hit.body);
};
get paths(): string[] { return this.requests.map((r) => r.url.slice(ORIGIN.length)); }
}
interface WorkerExports {
BUILD: string;
CACHE: string;
ASSETS: string;
SHELL: string;
ENTRY: string;
PRECACHE: string[];
strategyFor(url: URL): string;
}
interface Rig {
sw: WorkerExports;
caches: FakeCacheStorage;
net: FakeNet;
calls: { skipWaiting: number; claim: number };
install(): Promise;
activate(): Promise;
request(url: string, method?: string): Promise;
}
interface Served {
responded: boolean;
body?: string;
status?: number;
}
// Loads sw.js into a fresh context so one test's top-level state cannot leak into another's.
interface TimerSpy {
set: (id: number) => void;
clear: (id: number) => void;
}
function rig(options: { shell?: string; shellStatus?: number; missing?: readonly string[]; timers?: TimerSpy } = {}): Rig {
const net = new FakeNet();
const caches = new FakeCacheStorage(net);
const calls = { skipWaiting: 0, claim: 0 };
const listeners = new Map void>>();
const self = {
location: new URL(`${ORIGIN}/`),
addEventListener(type: string, fn: (event: unknown) => void) {
const list = listeners.get(type) ?? [];
list.push(fn);
listeners.set(type, list);
},
skipWaiting: () => { calls.skipWaiting++; },
clients: { claim: async () => { calls.claim++; } },
} as Record;
const spy = options.timers;
const watchedSetTimeout = (fn: () => void, ms: number): NodeJS.Timeout => {
const handle = setTimeout(fn, ms);
spy?.set(Number(handle));
return handle;
};
const watchedClearTimeout = (handle: NodeJS.Timeout): void => {
spy?.clear(Number(handle));
clearTimeout(handle);
};
const sandbox: Record = {
self, caches, fetch: net.fetch, URL, console,
setTimeout: watchedSetTimeout, clearTimeout: watchedClearTimeout,
};
sandbox.globalThis = sandbox;
vm.createContext(sandbox);
vm.runInContext(WORKER_SOURCE, sandbox, { filename: "public/sw.js" });
const sw = self.sw as WorkerExports;
net.serve("/", options.shellStatus ?? 200, options.shell ?? SHELL_HTML);
for (const url of sw.PRECACHE) {
if (options.missing?.includes(url) === true) continue;
net.serve(url, 200, `/* ${url} */`);
}
async function fire(type: string, event: Record): Promise {
const waited: Array> = [];
const withWait = { ...event, waitUntil: (p: Promise) => { waited.push(p); } };
for (const fn of listeners.get(type) ?? []) fn(withWait);
await Promise.all(waited);
}
return {
sw, caches, net, calls,
install: () => fire("install", {}),
activate: () => fire("activate", {}),
request: async (url, method = "GET") => {
let answer: Promise | undefined;
const request = { url: keyOf(url), method };
for (const fn of listeners.get("fetch") ?? []) {
fn({ request, respondWith: (p: Promise) => { answer = p; } });
}
if (answer === undefined) return { responded: false };
const res = await answer;
return { responded: true, body: res.body, status: res.status };
},
};
}
swTest("sw: the shell and the worker name the same build", () => {
const r = rig();
assert.match(r.sw.BUILD, /^[0-9a-f]{16}$/, "the build hash is content-derived by build.js");
assert.ok(SHELL_HTML.includes(`src="${r.sw.ENTRY}"`),
"index.html must load THIS build's entry module, or a cache-first shell would import another build's");
assert.ok(SHELL_HTML.includes(`${r.sw.ASSETS}styles.css`));
const hashes = new Set([...SHELL_HTML.matchAll(/\/assets\/([0-9a-f]{16})\//g)].map((m) => m[1]));
assert.deepEqual([...hashes], [r.sw.BUILD], "the shell names exactly one build, and it is this one");
});
swTest("sw: the precache is explicit URLs, every compiled module, all under one hash", () => {
const r = rig();
const modules = readdirSync(new URL("../../src", import.meta.url))
.filter((f) => f.endsWith(".ts"))
.map((f) => `${r.sw.ASSETS}${f.replace(/\.ts$/, ".js")}`);
const images = readdirSync(new URL("../../public/img", import.meta.url))
.filter((f) => f.endsWith(".png") || f.endsWith(".svg")).map((f) => `${r.sw.ASSETS}${f}`);
const fonts = readdirSync(new URL("../../public/fonts", import.meta.url))
.filter((f) => f.endsWith(".woff2")).map((f) => `${r.sw.ASSETS}${f}`);
assert.ok(images.length > 0, "the hero has to be somewhere for the worker to precache");
const expected = [`${r.sw.ASSETS}styles.css`, `${r.sw.ASSETS}init.js`, ...modules, ...images, ...fonts].sort();
assert.deepEqual([...r.sw.PRECACHE].sort(), expected,
"a module missing here is a page that half-works offline; one under another hash is a skew");
assert.ok(!r.sw.PRECACHE.includes("/"),
"`/` is fetched and checked at install, never added blind with the rest");
});
swTest("sw: /api/* is network-only, on every form the two endpoints take", () => {
const { strategyFor } = rig().sw;
for (const path of [
"/api", "/api/invoice", "/api/invoice/inv_9f3a",
"/api/invoice/inv_9f3a?wait=open", "/api/invoice/inv_9f3a?wait=expired", "/api/anything/else",
]) {
assert.equal(strategyFor(new URL(path, ORIGIN)), "network-only",
`${path} must never be cached: a stale paid shows a code for an order that later failed`);
}
});
swTest("sw: / is the shell whatever query or hash it carries", () => {
const { strategyFor } = rig().sw;
for (const path of ["/", "/index.html", "/?order=inv_9f3a", "/#/codes", "/?order=inv_9f3a#/x"]) {
assert.equal(strategyFor(new URL(path, ORIGIN)), "shell", path);
}
});
swTest("sw: only THIS build's assets are cache-first", () => {
const r = rig();
assert.equal(r.sw.strategyFor(new URL(`${r.sw.ASSETS}main.js`, ORIGIN)), "asset");
assert.equal(r.sw.strategyFor(new URL(`${r.sw.ASSETS}styles.css`, ORIGIN)), "asset");
assert.equal(r.sw.strategyFor(new URL("/assets/0000000000000000/main.js", ORIGIN)), "bypass",
"another build's URL is not ours to answer: its cache is gone");
});
swTest("sw: js.stripe.com is never this worker's business", () => {
const { strategyFor } = rig().sw;
for (const url of [
"https://js.stripe.com/v3/", "https://js.stripe.com/basil/stripe.js",
"https://api.stripe.com/v1/payment_intents", "https://hooks.stripe.com/3d_secure",
]) {
assert.equal(strategyFor(new URL(url)), "bypass", `${url} — Stripe forbids caching or self-hosting it`);
}
});
swTest("sw: anything else same-origin is left to the browser", () => {
const { strategyFor } = rig().sw;
for (const path of ["/sw.js", "/favicon.ico", "/webhooks/stripe", "/styles.css", "/src/main.js"]) {
assert.equal(strategyFor(new URL(path, ORIGIN)), "bypass", path);
}
});
swTest("sw: install precaches the shell and this build's assets, and nothing else", async () => {
const r = rig();
await r.install();
const cache = r.caches.stores.get(r.sw.CACHE)!;
assert.deepEqual([...(await cache.keys())].sort(),
[`${ORIGIN}/`, ...r.sw.PRECACHE.map((u) => `${ORIGIN}${u}`)].sort());
assert.deepEqual([...r.caches.stores.keys()], [r.sw.CACHE], "one cache, named for the build");
const shellRequest = r.net.requests.find((q) => q.url === `${ORIGIN}/`)!;
assert.equal(shellRequest.init?.cache, "reload",
"the shell is re-read rather than taken from the HTTP cache it may already be stale in");
});
swTest("sw: install refuses a document that is not this build's shell", async () => {
const r = rig({ shell: ANUBIS_CHALLENGE });
await assert.rejects(r.install(), /not this build's shell/);
assert.equal(r.caches.stores.size, 0, "nothing survives: a challenge page enshrined as the shell is permanent");
assert.ok(r.caches.deleted.includes(r.sw.CACHE));
});
swTest("sw: and a challenge page reaches no cache even for a moment", async () => {
const r = rig({ shell: ANUBIS_CHALLENGE });
await r.install().catch(() => { /* asserted above */ });
for (const store of r.caches.stores.values()) {
for (const res of store.entries.values()) {
assert.ok(!res.body.includes("anubis_challenge"), "the challenge is in a cache");
}
}
});
swTest("sw: install fails, and leaves nothing, when the shell does not load", async () => {
for (const status of [403, 503]) {
const r = rig({ shellStatus: status, shell: "" });
await assert.rejects(r.install(), new RegExp(`answered ${status}`));
assert.equal(r.caches.stores.size, 0);
}
});
swTest("sw: a precache that cannot be completed leaves no half-installed cache", async () => {
const r = rig({ missing: [`${rig().sw.ASSETS}flow.js`] });
await assert.rejects(r.install(), /flow\.js answered 404/);
assert.equal(r.caches.stores.size, 0,
"a half-filled cache would serve a shell whose modules 404 for as long as the build lived");
});
swTest("sw: the shell is cloned before it is read, or no install could ever store it", async () => {
const r = rig();
await r.install();
const cache = r.caches.stores.get(r.sw.CACHE)!;
const stored = (await cache.match("/"))!;
assert.equal(stored.body, SHELL_HTML);
assert.equal(stored.bodyUsed, false,
"what was put in the cache is the undisturbed response: reading it to check the marker must use a clone");
});
swTest("sw: a failed RE-INSTALL leaves the shipped build's cache untouched", async () => {
const r = rig();
await r.install();
const before = [...(await r.caches.stores.get(r.sw.CACHE)!.keys())].sort();
// A background update check that meets the challenge, a 5xx, or nothing.
r.net.serve("/", 200, ANUBIS_CHALLENGE);
await assert.rejects(r.install(), /not this build's shell/);
const cache = r.caches.stores.get(r.sw.CACHE);
assert.ok(cache !== undefined,
"deleting this would take offline support from the build that is working");
assert.deepEqual([...(await cache.keys())].sort(), before, "and every entry is still in it");
assert.ok(!r.caches.deleted.includes(r.sw.CACHE));
});
swTest("sw: a re-install with no network at all leaves it alone too", async () => {
const r = rig();
await r.install();
r.net.offline = true;
await assert.rejects(r.install(), TypeError);
// Read back through the storage, not through a cache object captured before:
// deleting a cache the worker holds a handle to is exactly the bug.
const cache = r.caches.stores.get(r.sw.CACHE);
assert.ok(cache !== undefined, "the buyer on a train keeps their page");
assert.equal((await cache.match("/"))?.body, SHELL_HTML);
});
swTest("sw: a FIRST install that fails still leaves nothing behind", async () => {
const r = rig({ shell: ANUBIS_CHALLENGE });
await assert.rejects(r.install(), /not this build's shell/);
assert.equal(r.caches.stores.size, 0, "there was no build here to protect");
});
swTest("sw: install skipWaits, so a redeploy does not wait for every tab to close", async () => {
const r = rig();
await r.install();
assert.equal(r.calls.skipWaiting, 1,
"without this the new worker sits in `waiting` and the old build is served indefinitely");
});
swTest("sw: an install that fails does not skipWaiting over a half-filled cache", async () => {
const r = rig({ shellStatus: 500 });
await assert.rejects(() => r.install());
assert.equal(r.calls.skipWaiting, 0,
"taking over with no cache would serve a shell whose modules are not there");
});
swTest("sw: activation deletes every cache whose hash is not this build", async () => {
const r = rig();
await r.caches.open("sb-0000000000000000");
await r.caches.open("sb-1111111111111111");
await r.install();
await r.activate();
assert.deepEqual([...r.caches.stores.keys()], [r.sw.CACHE], "the old builds are gone");
assert.deepEqual(r.caches.deleted.sort(), ["sb-0000000000000000", "sb-1111111111111111"]);
});
swTest("sw: activation keeps this build's cache, which is what offline is made of", async () => {
const r = rig();
await r.install();
await r.activate();
const cache = r.caches.stores.get(r.sw.CACHE)!;
assert.ok((await cache.keys()).length > 0);
assert.ok(!r.caches.deleted.includes(r.sw.CACHE));
});
swTest("sw: activation does not claim the open pages either", async () => {
const r = rig();
await r.install();
await r.activate();
assert.equal(r.calls.claim, 0, "a page keeps the build it loaded with until it is loaded again");
});
swTest("sw: an API request is not answered here, and never touches the Cache API", async () => {
const r = rig();
await r.install();
const cache = r.caches.stores.get(r.sw.CACHE)!;
const openedBefore = r.caches.opened.length;
cache.matched.length = 0;
cache.written.length = 0;
for (const [url, method] of [
[`${ORIGIN}/api/invoice`, "POST"],
[`${ORIGIN}/api/invoice/inv_9f3a`, "GET"],
[`${ORIGIN}/api/invoice/inv_9f3a?wait=open`, "GET"],
] as const) {
const served = await r.request(url, method);
assert.equal(served.responded, false, `${method} ${url} must go to the network as if no worker existed`);
}
assert.deepEqual(cache.matched, [], "not even a read: a cached paid would show a code for an order that failed");
assert.deepEqual(cache.written, [], "and nothing about money is ever put into the Cache API");
assert.equal(r.caches.opened.length, openedBefore, "no cache is so much as opened for /api/*");
});
swTest("sw: a page load is answered from the cache when the network is gone", async () => {
const r = rig();
await r.install();
r.net.offline = true;
for (const url of ["/", "/?order=inv_9f3a", "/?order=inv_9f3a#/x", "/index.html"]) {
const served = await r.request(`${ORIGIN}${url}`);
assert.equal(served.responded, true, url);
assert.equal(served.body, SHELL_HTML, `${url} — ?order= is a page, not a key`);
}
});
swTest("sw: a redeployed shell reaches the page on a reload, with the old worker still active", async () => {
const r = rig();
await r.install();
const REDEPLOYED = SHELL_HTML.replace("main.js", "main.js?v=next");
r.net.serve("/", 200, REDEPLOYED);
for (const url of ["/", "/?order=inv_9f3a", "/index.html"]) {
const served = await r.request(`${ORIGIN}${url}`);
assert.equal(served.responded, true, url);
assert.equal(served.body, REDEPLOYED, `${url} kept serving the cached shell after a redeploy`);
}
const cache = r.caches.stores.get(r.sw.CACHE)!;
assert.deepEqual(cache.written.filter((w) => w === "/"), []);
});
swTest("sw: the shell's deadline is cleared on every path, so no request leaves a timer armed", async () => {
const armed: number[] = [];
const r = rig({ timers: { set: (id) => armed.push(id), clear: (id) => { armed.splice(armed.indexOf(id), 1); } } });
await r.install();
r.net.offline = true;
await r.request(`${ORIGIN}/`);
assert.deepEqual(armed, [], "the offline path left a timer running");
r.net.offline = false;
await r.request(`${ORIGIN}/`);
assert.deepEqual(armed, [], "and so did the answered path");
});
stalledTest("sw: a network that accepts and never answers falls back rather than hanging the page", async () => {
// fetch does not reject on a stalled connection and navigator.onLine still says online, so only a deadline serves the page.
const r = rig();
await r.install();
r.net.stall("/");
const started = Date.now();
const served = await r.request(`${ORIGIN}/`);
assert.equal(served.responded, true);
assert.equal(served.body, SHELL_HTML, "the precached shell is what a stalled network is for");
assert.ok(Date.now() - started < 10_000, "and it must not wait out the browser's own timeout");
});
swTest("sw: a shell the listener cannot serve falls back to the cache, not to an error", async () => {
const r = rig();
await r.install();
r.net.serve("/", 503, "the listener is restarting");
const served = await r.request(`${ORIGIN}/`);
assert.equal(served.responded, true);
assert.equal(served.body, SHELL_HTML, "a 5xx must not take the page away from a buyer");
});
swTest("sw: the shell and every module are served with the network gone", async () => {
const r = rig();
await r.install();
r.net.offline = true;
const before = r.net.requests.length;
for (const url of r.sw.PRECACHE) {
const served = await r.request(`${ORIGIN}${url}`);
assert.equal(served.responded, true, url);
assert.equal(served.body, `/* ${url} */`, url);
}
assert.equal(r.net.requests.length, before, "an asset is cache-first: offline is the same path, not a special case");
});
swTest("sw: an asset this build never precached falls through to the network, uncached", async () => {
const r = rig();
await r.install();
const cache = r.caches.stores.get(r.sw.CACHE)!;
cache.written.length = 0;
r.net.serve(`${r.sw.ASSETS}later.js`, 200, "/* a module added after this worker shipped */");
const served = await r.request(`${ORIGIN}${r.sw.ASSETS}later.js`);
assert.equal(served.responded, true);
assert.equal(served.body, "/* a module added after this worker shipped */");
assert.deepEqual(cache.written, [], "this worker caches one build at install and writes nothing at request time");
});
swTest("sw: nothing whatsoever is written to the Cache API after install", async () => {
const r = rig();
await r.install();
const cache = r.caches.stores.get(r.sw.CACHE)!;
cache.written.length = 0;
await r.request(`${ORIGIN}/`);
await r.request(`${ORIGIN}/?order=inv_9f3a`);
await r.request(`${ORIGIN}${r.sw.ASSETS}main.js`);
await r.request(`${ORIGIN}/api/invoice/inv_9f3a`);
await r.request(`${ORIGIN}/api/invoice`, "POST");
await r.request("https://js.stripe.com/v3/");
await r.request(`${ORIGIN}/favicon.ico`);
assert.deepEqual(cache.written, []);
});
swTest("sw: a request that is not a GET is never answered from a cache", async () => {
const r = rig();
await r.install();
const cache = r.caches.stores.get(r.sw.CACHE)!;
cache.written.length = 0;
for (const url of ["/", `${r.sw.ASSETS}main.js`]) {
for (const method of ["POST", "HEAD"]) {
const served = await r.request(`${ORIGIN}${url}`, method);
assert.equal(served.responded, false, `${method} ${url} must reach the service`);
}
}
assert.deepEqual(cache.written, [], "and none of them wrote anything either");
});
swTest("sw: Stripe.js is fetched by the page, never by this worker", async () => {
const r = rig();
await r.install();
const before = r.net.requests.length;
const served = await r.request("https://js.stripe.com/v3/");
assert.equal(served.responded, false, "another origin is answered as if no worker existed");
assert.equal(r.net.requests.length, before, "and the worker does not fetch it itself");
});