| 1 | import { chromium, webkit } from "playwright"; |
| 2 | import { spawn, execFileSync } from "node:child_process"; |
| 3 | import { createServer } from "node:http"; |
| 4 | import { mkdirSync } from "node:fs"; |
| 5 | import { once } from "node:events"; |
| 6 | import assert from "node:assert/strict"; |
| 7 | |
| 8 | mkdirSync(".build", { recursive: true }); |
| 9 | execFileSync("go", ["build", "-o", "../../.build/example", "."], { cwd: "examples/basic", stdio: "inherit" }); |
| 10 | const app = spawn(".build/example", ["-addr", "127.0.0.1:0"], { stdio: ["ignore", "pipe", "inherit"] }); |
| 11 | let proxy; |
| 12 | let browser; |
| 13 | try { |
| 14 | const upstream = await new Promise((resolve, reject) => { |
| 15 | let output = ""; |
| 16 | app.stdout.on("data", chunk => { |
| 17 | output += chunk; |
| 18 | if (output.includes("\n")) resolve(output.trim()); |
| 19 | }); |
| 20 | app.on("error", reject); |
| 21 | app.on("exit", code => reject(new Error(`example exited: ${code}`))); |
| 22 | }); |
| 23 | let revision = 1; |
| 24 | let disconnected = false; |
| 25 | // Change only worker bytes between visits: this reproduces a deployment |
| 26 | // without adding test-only routes or switches to the example application. |
| 27 | proxy = createServer(async (req, res) => { |
| 28 | try { |
| 29 | if (disconnected) { req.socket.destroy(); return; } |
| 30 | if (req.url === "/server-error") { res.writeHead(500); res.end("Server error"); return; } |
| 31 | if (req.url === "/private") { res.writeHead(401); res.end("Sign in"); return; } |
| 32 | const response = await fetch(upstream + req.url); |
| 33 | const headers = Object.fromEntries(response.headers); |
| 34 | delete headers["content-length"]; |
| 35 | delete headers["transfer-encoding"]; |
| 36 | res.writeHead(response.status, headers); |
| 37 | const body = Buffer.from(await response.arrayBuffer()); |
| 38 | res.end(req.url === "/sw.js" ? `${body}\n// revision ${revision}\n` : body); |
| 39 | } catch (error) { |
| 40 | res.writeHead(502); res.end(String(error)); |
| 41 | } |
| 42 | }).listen(0, "127.0.0.1"); |
| 43 | await once(proxy, "listening"); |
| 44 | const origin = `http://127.0.0.1:${proxy.address().port}`; |
| 45 | |
| 46 | for (const [name, engine] of [["chromium", chromium], ["webkit", webkit]]) { |
| 47 | browser = await engine.launch(); |
| 48 | const context = await browser.newContext(); |
| 49 | const page = await context.newPage(); |
| 50 | const errors = []; |
| 51 | page.on("pageerror", error => errors.push(error.message)); |
| 52 | await page.goto(origin); |
| 53 | await page.evaluate(async () => { await navigator.serviceWorker.ready; }); |
| 54 | await page.reload(); |
| 55 | await page.waitForFunction(() => navigator.serviceWorker.controller); |
| 56 | const manifest = await page.evaluate(async () => (await fetch("/manifest.webmanifest")).json()); |
| 57 | assert.equal(manifest.id, "/"); |
| 58 | assert.deepEqual(manifest.icons.map(icon => icon.sizes), ["192x192", "512x512"]); |
| 59 | |
| 60 | const response = await page.goto(origin + "/private"); |
| 61 | assert.equal(response.status(), 401); |
| 62 | assert.equal(await page.locator("body").innerText(), "Sign in"); |
| 63 | assert.equal((await page.goto(origin + "/server-error")).status(), 500); |
| 64 | // WebKit's automation offline switch can abort navigation before invoking |
| 65 | // a controlling worker. Drop the connection at the server instead; the |
| 66 | // browser still has to handle a real failed network request in its worker. |
| 67 | disconnected = true; |
| 68 | if (name === "chromium") await context.setOffline(true); |
| 69 | const offline = await page.goto(origin + "/private"); |
| 70 | assert.equal(offline.status(), 503); |
| 71 | assert.equal(await page.locator("h1").innerText(), "You're offline"); |
| 72 | assert.equal(await page.evaluate(async () => (await caches.keys()).length), 0); |
| 73 | assert.equal(await page.evaluate(async () => { |
| 74 | try { await fetch("/api/data"); return "unexpected response"; } |
| 75 | catch { return "network failure"; } |
| 76 | }), "network failure", "API failures must not become HTML fallback responses"); |
| 77 | |
| 78 | disconnected = false; |
| 79 | if (name === "chromium") await context.setOffline(false); |
| 80 | await page.goto(origin); |
| 81 | await page.locator("#draft").fill("keep my draft"); |
| 82 | const other = await context.newPage(); |
| 83 | await other.goto(origin); |
| 84 | await other.locator("#draft").fill("another tab's draft"); |
| 85 | revision++; |
| 86 | await page.evaluate(async () => { await (await navigator.serviceWorker.getRegistration()).update(); }); |
| 87 | await page.waitForFunction(async () => !!(await navigator.serviceWorker.getRegistration()).waiting); |
| 88 | assert.equal(await page.locator("#draft").inputValue(), "keep my draft"); |
| 89 | assert.equal(await other.locator("#draft").inputValue(), "another tab's draft"); |
| 90 | await page.waitForFunction(() => !document.querySelector("#update").hidden); |
| 91 | await page.evaluate(async () => { |
| 92 | const { activateUpdate } = await import("/pwa/client.mjs"); |
| 93 | const changed = new Promise(resolve => navigator.serviceWorker.addEventListener("controllerchange", resolve, { once: true })); |
| 94 | if (!activateUpdate(await navigator.serviceWorker.getRegistration())) throw new Error("missing update"); |
| 95 | await changed; |
| 96 | }); |
| 97 | assert.equal(await page.locator("#draft").inputValue(), "keep my draft", "activation must not reload the page"); |
| 98 | assert.equal(await other.locator("#draft").inputValue(), "another tab's draft"); |
| 99 | if (name === "chromium") { |
| 100 | const workers = context.serviceWorkers(); |
| 101 | assert.ok(workers.length > 0); |
| 102 | assert.equal(await workers.at(-1).evaluate(() => typeof AvisoSW.handlePush), "function", "aviso shares the installed worker"); |
| 103 | } |
| 104 | assert.deepEqual(errors, []); |
| 105 | await browser.close(); |
| 106 | browser = null; |
| 107 | console.log(`${name}: offline navigation, network errors, no data cache, two-tab updates and worker composition passed`); |
| 108 | } |
| 109 | } finally { |
| 110 | await browser?.close(); |
| 111 | proxy?.close(); |
| 112 | proxy?.closeAllConnections(); |
| 113 | app.kill("SIGTERM"); |
| 114 | } |
| 115 | |