rastrillo / pwa Public

Clone
git clone https://amadan.net/rastrillo/pwa

Plain git — no account needed to clone.

Download

Download this file

1import { chromium, webkit } from "playwright";
2import { spawn, execFileSync } from "node:child_process";
3import { createServer } from "node:http";
4import { mkdirSync } from "node:fs";
5import { once } from "node:events";
6import assert from "node:assert/strict";
7
8mkdirSync(".build", { recursive: true });
9execFileSync("go", ["build", "-o", "../../.build/example", "."], { cwd: "examples/basic", stdio: "inherit" });
10const app = spawn(".build/example", ["-addr", "127.0.0.1:0"], { stdio: ["ignore", "pipe", "inherit"] });
11let proxy;
12let browser;
13try {
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