import { chromium, webkit } from "playwright"; import { spawn, execFileSync } from "node:child_process"; import { createServer } from "node:http"; import { mkdirSync } from "node:fs"; import { once } from "node:events"; import assert from "node:assert/strict"; mkdirSync(".build", { recursive: true }); execFileSync("go", ["build", "-o", "../../.build/example", "."], { cwd: "examples/basic", stdio: "inherit" }); const app = spawn(".build/example", ["-addr", "127.0.0.1:0"], { stdio: ["ignore", "pipe", "inherit"] }); let proxy; let browser; try { const upstream = await new Promise((resolve, reject) => { let output = ""; app.stdout.on("data", chunk => { output += chunk; if (output.includes("\n")) resolve(output.trim()); }); app.on("error", reject); app.on("exit", code => reject(new Error(`example exited: ${code}`))); }); let revision = 1; let disconnected = false; // Change only worker bytes between visits: this reproduces a deployment // without adding test-only routes or switches to the example application. proxy = createServer(async (req, res) => { try { if (disconnected) { req.socket.destroy(); return; } if (req.url === "/server-error") { res.writeHead(500); res.end("Server error"); return; } if (req.url === "/private") { res.writeHead(401); res.end("Sign in"); return; } const response = await fetch(upstream + req.url); const headers = Object.fromEntries(response.headers); delete headers["content-length"]; delete headers["transfer-encoding"]; res.writeHead(response.status, headers); const body = Buffer.from(await response.arrayBuffer()); res.end(req.url === "/sw.js" ? `${body}\n// revision ${revision}\n` : body); } catch (error) { res.writeHead(502); res.end(String(error)); } }).listen(0, "127.0.0.1"); await once(proxy, "listening"); const origin = `http://127.0.0.1:${proxy.address().port}`; for (const [name, engine] of [["chromium", chromium], ["webkit", webkit]]) { browser = await engine.launch(); const context = await browser.newContext(); const page = await context.newPage(); const errors = []; page.on("pageerror", error => errors.push(error.message)); await page.goto(origin); await page.evaluate(async () => { await navigator.serviceWorker.ready; }); await page.reload(); await page.waitForFunction(() => navigator.serviceWorker.controller); const manifest = await page.evaluate(async () => (await fetch("/manifest.webmanifest")).json()); assert.equal(manifest.id, "/"); assert.deepEqual(manifest.icons.map(icon => icon.sizes), ["192x192", "512x512"]); const response = await page.goto(origin + "/private"); assert.equal(response.status(), 401); assert.equal(await page.locator("body").innerText(), "Sign in"); assert.equal((await page.goto(origin + "/server-error")).status(), 500); // WebKit's automation offline switch can abort navigation before invoking // a controlling worker. Drop the connection at the server instead; the // browser still has to handle a real failed network request in its worker. disconnected = true; if (name === "chromium") await context.setOffline(true); const offline = await page.goto(origin + "/private"); assert.equal(offline.status(), 503); assert.equal(await page.locator("h1").innerText(), "You're offline"); assert.equal(await page.evaluate(async () => (await caches.keys()).length), 0); assert.equal(await page.evaluate(async () => { try { await fetch("/api/data"); return "unexpected response"; } catch { return "network failure"; } }), "network failure", "API failures must not become HTML fallback responses"); disconnected = false; if (name === "chromium") await context.setOffline(false); await page.goto(origin); await page.locator("#draft").fill("keep my draft"); const other = await context.newPage(); await other.goto(origin); await other.locator("#draft").fill("another tab's draft"); revision++; await page.evaluate(async () => { await (await navigator.serviceWorker.getRegistration()).update(); }); await page.waitForFunction(async () => !!(await navigator.serviceWorker.getRegistration()).waiting); assert.equal(await page.locator("#draft").inputValue(), "keep my draft"); assert.equal(await other.locator("#draft").inputValue(), "another tab's draft"); await page.waitForFunction(() => !document.querySelector("#update").hidden); await page.evaluate(async () => { const { activateUpdate } = await import("/pwa/client.mjs"); const changed = new Promise(resolve => navigator.serviceWorker.addEventListener("controllerchange", resolve, { once: true })); if (!activateUpdate(await navigator.serviceWorker.getRegistration())) throw new Error("missing update"); await changed; }); assert.equal(await page.locator("#draft").inputValue(), "keep my draft", "activation must not reload the page"); assert.equal(await other.locator("#draft").inputValue(), "another tab's draft"); if (name === "chromium") { const workers = context.serviceWorkers(); assert.ok(workers.length > 0); assert.equal(await workers.at(-1).evaluate(() => typeof AvisoSW.handlePush), "function", "aviso shares the installed worker"); } assert.deepEqual(errors, []); await browser.close(); browser = null; console.log(`${name}: offline navigation, network errors, no data cache, two-tab updates and worker composition passed`); } } finally { await browser?.close(); proxy?.close(); proxy?.closeAllConnections(); app.kill("SIGTERM"); }