// A site worker that composes the runtime's and holds new versions in `waiting` // (self.nibServiceWorker = { skipWaiting: false, claim: false }) keeps its update flow: // the runtime neither activates a new version nor claims pages on its own, keeps working // from the old version while the new one waits, and recovers its bridges once the site // activates the new version. import { test, expect, type Page } from "@playwright/test"; test.afterEach(async ({ page }) => { await page.evaluate(async () => { for (const reg of await navigator.serviceWorker.getRegistrations()) await reg.unregister(); }).catch(() => {}); }); interface Nib { boot: () => Promise; runtime: Runtime; base: string; } interface Runtime { writeTree(tree: Record): void; exec(entry: string): { stdout: ReadableStream; exited: Promise }; } /** What the page sees: who controls it, what the registration holds, and the runtime's * answers — guest http from the server started at boot, and a fresh ESM process. */ function probe(page: Page, label: string) { return page.evaluate(async (label) => { const { runtime, base } = (window as unknown as { nib: Nib }).nib; const reg = await navigator.serviceWorker.getRegistration("/"); const v = (w: ServiceWorker | null | undefined) => w ? new URL(w.scriptURL).searchParams.get("v") : null; const esm = runtime.exec("main.mjs"); const out = (await new Response(esm.stdout).text()).trim(); return { controller: v(navigator.serviceWorker.controller), active: v(reg?.active), waiting: v(reg?.waiting), servedBy: await (await fetch("/tests/fixtures/compose-update/version")).text(), http: await (await fetch(base + label)).text(), esm: `${out} (${await esm.exited})`, }; }, label); } test("a site worker that holds updates in waiting keeps the runtime working across its update", async ({ page }) => { await page.goto("/tests/fixtures/compose-update/"); const result = page.locator("#result"); await expect(result).not.toHaveText("pending", { timeout: 20_000 }); const text = await result.textContent() ?? ""; expect(text, "boot() threw instead of reporting an outcome").toMatch(/^\{/); // claim: false left the first load uncontrolled; boot()'s own claim request still took it. expect(JSON.parse(text)).toMatchObject({ uncontrolled: true }); expect(await probe(page, "v1")).toEqual({ controller: "1", active: "1", waiting: null, servedBy: "1", http: "http ok /v1", esm: "esm ok (0)", }); // A new deploy: the browser installs v2 and, with skipWaiting: false, leaves it waiting. const installed = await page.evaluate(async () => { const reg = await navigator.serviceWorker.register("/tests/fixtures/compose-update/sw.js?v=2", { scope: "/" }); const worker = reg.installing ?? reg.waiting; if (worker && worker.state !== "installed") { await new Promise((resolve, reject) => { const timer = setTimeout(() => reject(new Error(`v2 stuck in ${worker.state}`)), 10_000); worker.addEventListener("statechange", () => { if (worker.state === "installing") return; clearTimeout(timer); resolve(); }); }); } return worker?.state; }); expect(installed).toBe("installed"); expect(await probe(page, "waiting")).toEqual({ controller: "1", active: "1", waiting: "2", servedBy: "1", http: "http ok /waiting", esm: "esm ok (0)", }); // boot() while v2 waits: it recognizes the waiting composed worker by its ping and // bridges to the active one. const second = await page.evaluate(async () => { const runtime = await (window as unknown as { nib: Nib }).nib.boot(); runtime.writeTree({ "/app/second.mjs": 'import { x } from "./x.mjs";\nconsole.log(x);\n', "/app/x.mjs": 'export const x = "second ok";\n' }); const p = runtime.exec("second.mjs"); return (await new Response(p.stdout).text()).trim(); }); expect(second).toBe("second ok"); // The site's page tells v2 to take over. The page (and its runtime's workers) move to // v2, which has no bridges yet; its activate-time rebridge asks this page for them. const switched = await page.evaluate(async () => { const reg = await navigator.serviceWorker.getRegistration("/"); return await new Promise((resolve) => { const timer = setTimeout(() => resolve(false), 5_000); navigator.serviceWorker.addEventListener("controllerchange", () => { clearTimeout(timer); resolve(true); }, { once: true }); reg!.waiting!.postMessage({ type: "SKIP_WAITING" }); }); }); expect(switched, "controllerchange after SKIP_WAITING").toBe(true); const started = Date.now(); expect(await probe(page, "after")).toEqual({ controller: "2", active: "2", waiting: null, servedBy: "2", http: "http ok /after", esm: "esm ok (0)", }); // Recovered by the activate-time rebridge, not by a fetch waiting out the 3 s limit. expect(Date.now() - started).toBeLessThan(2_500); });