|
| 1 | +// Live bug 6 (Oct 11): returning visitors kept the old site and app after a deploy. Two real |
| 2 | +// builds, N and N+1, served from one origin: load N until the service worker controls the page, |
| 3 | +// switch the server to N+1, reload once, and check N+1's HTML and JS are what runs. |
| 4 | +// Usage: node apps/web/test/pwa/e2e/sw-update.ts Writes docs/pwa/evidence/e2e-sw-update.txt. |
| 5 | +import { execFileSync } from "node:child_process"; |
| 6 | +import fs from "node:fs"; |
| 7 | +import http from "node:http"; |
| 8 | +import os from "node:os"; |
| 9 | +import path from "node:path"; |
| 10 | +import { fileURLToPath } from "node:url"; |
| 11 | +import { chromium } from "playwright-core"; |
| 12 | + |
| 13 | +const HERE = path.dirname(fileURLToPath(import.meta.url)); |
| 14 | +const ROOT = path.resolve(HERE, "../../../../.."); |
| 15 | +const WEB = path.join(ROOT, "apps/web"); |
| 16 | +const OUT = path.join(ROOT, "docs/pwa/evidence/e2e-sw-update.txt"); |
| 17 | +const PORT = Number(process.env.PWA_E2E_PORT ?? 8874); |
| 18 | +const ORIGIN = `http://127.0.0.1:${PORT}`; |
| 19 | +const lines: string[] = []; |
| 20 | +let failed = 0; |
| 21 | +const log = (s: string) => { |
| 22 | + lines.push(s); |
| 23 | + process.stdout.write(`${s}\n`); |
| 24 | +}; |
| 25 | +const check = (name: string, ok: boolean, detail = "") => { |
| 26 | + if (!ok) failed++; |
| 27 | + log(`${ok ? "PASS" : "FAIL"} ${name}${detail ? `: ${detail}` : ""}`); |
| 28 | +}; |
| 29 | + |
| 30 | +// Two builds that differ only in their build id (the SvelteKit version, so new chunk hashes too). |
| 31 | +const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "flare-swup-")); |
| 32 | +const builds: Record<string, string> = {}; |
| 33 | +for (const id of ["buildN000000", "buildN100000"]) { |
| 34 | + execFileSync("npx", ["vite", "build", "--logLevel", "error"], { |
| 35 | + cwd: WEB, |
| 36 | + env: { ...process.env, FLARE_BUILD: id }, |
| 37 | + stdio: "inherit", |
| 38 | + }); |
| 39 | + const dir = path.join(tmp, id); |
| 40 | + fs.cpSync(path.join(WEB, "build"), dir, { recursive: true }); |
| 41 | + builds[id] = dir; |
| 42 | +} |
| 43 | +let current = builds.buildN000000 as string; |
| 44 | +const TYPES: Record<string, string> = { |
| 45 | + ".html": "text/html; charset=utf-8", |
| 46 | + ".js": "text/javascript", |
| 47 | + ".css": "text/css", |
| 48 | + ".json": "application/json", |
| 49 | + ".svg": "image/svg+xml", |
| 50 | + ".png": "image/png", |
| 51 | + ".webmanifest": "application/manifest+json", |
| 52 | +}; |
| 53 | +const missing: string[] = []; |
| 54 | +const server = http.createServer((req, res) => { |
| 55 | + const url = new URL(req.url ?? "/", ORIGIN); |
| 56 | + if (url.pathname.startsWith("/api/")) { |
| 57 | + res.writeHead(200, { "content-type": "application/json" }); |
| 58 | + res.end(url.pathname.endsWith("/health") ? '{"ok":true,"version":"x","hub":"down","db":"ok"}' : "{}"); |
| 59 | + return; |
| 60 | + } |
| 61 | + let file = path.join(current, decodeURIComponent(url.pathname)); |
| 62 | + if (fs.existsSync(file) && fs.statSync(file).isDirectory()) file = path.join(file, "index.html"); |
| 63 | + if (!fs.existsSync(file)) { |
| 64 | + if (url.pathname.startsWith("/_app/")) missing.push(url.pathname); |
| 65 | + file = path.join(current, "200.html"); |
| 66 | + } |
| 67 | + const immutable = url.pathname.startsWith("/_app/immutable/"); |
| 68 | + res.writeHead(fs.existsSync(file) ? 200 : 404, { |
| 69 | + "content-type": TYPES[path.extname(file)] ?? "application/octet-stream", |
| 70 | + "cache-control": immutable ? "public, max-age=31536000, immutable" : "no-cache", |
| 71 | + }); |
| 72 | + res.end(fs.existsSync(file) ? fs.readFileSync(file) : ""); |
| 73 | +}); |
| 74 | +await new Promise<void>((r) => server.listen(PORT, "127.0.0.1", r)); |
| 75 | + |
| 76 | +const browser = await chromium.launch({ channel: "chrome", headless: true }); |
| 77 | +const page = await (await browser.newContext()).newPage(); |
| 78 | +const errors: string[] = []; |
| 79 | +page.on("pageerror", (e) => errors.push(e.message)); |
| 80 | +const build = async () => (await page.locator(".site .build").textContent())?.trim() ?? ""; |
| 81 | +const swVersion = () => |
| 82 | + page.evaluate( |
| 83 | + () => |
| 84 | + new Promise<string>((resolve) => { |
| 85 | + const c = navigator.serviceWorker.controller; |
| 86 | + if (!c) return resolve(""); |
| 87 | + navigator.serviceWorker.addEventListener("message", (e) => resolve(String(e.data?.version ?? "")), { |
| 88 | + once: true, |
| 89 | + }); |
| 90 | + c.postMessage({ type: "version" }); |
| 91 | + setTimeout(() => resolve("timeout"), 3000); |
| 92 | + }), |
| 93 | + ); |
| 94 | +try { |
| 95 | + await page.goto(`${ORIGIN}/`); |
| 96 | + await page.evaluate(() => navigator.serviceWorker.ready); |
| 97 | + await page.reload(); |
| 98 | + await page.waitForFunction(() => !!navigator.serviceWorker.controller); |
| 99 | + check("build N is controlled by its service worker", (await swVersion()) === "buildN000000"); |
| 100 | + check("build N footer", (await build()) === "Build buildN000000", await build()); |
| 101 | + |
| 102 | + current = builds.buildN100000 as string; // the deploy |
| 103 | + await page.reload(); |
| 104 | + check("one reload after the deploy shows N+1's HTML", (await build()) === "Build buildN100000", await build()); |
| 105 | + const scripts = await page.evaluate(() => |
| 106 | + performance |
| 107 | + .getEntriesByType("resource") |
| 108 | + .map((e) => new URL(e.name).pathname) |
| 109 | + .filter((p) => p.startsWith("/_app/immutable/") && p.endsWith(".js")), |
| 110 | + ); |
| 111 | + const fromN1 = scripts.filter((p) => fs.existsSync(path.join(builds.buildN100000 as string, p))); |
| 112 | + check( |
| 113 | + "the JS that ran is N+1's", |
| 114 | + scripts.length > 0 && fromN1.length === scripts.length, |
| 115 | + `${fromN1.length}/${scripts.length}`, |
| 116 | + ); |
| 117 | + // The browser checks for a new worker on each navigation; it must take over with no prompt. |
| 118 | + let v = ""; |
| 119 | + const t0 = Date.now(); |
| 120 | + while (Date.now() - t0 < 15000 && (v = await swVersion()) !== "buildN100000") await page.waitForTimeout(250); |
| 121 | + check("the new service worker took over with no waiting step", v === "buildN100000", `${v} in ${Date.now() - t0} ms`); |
| 122 | + await page.goto(`${ORIGIN}/how/`); |
| 123 | + check("after the takeover, another page is N+1 too", (await build()) === "Build buildN100000"); |
| 124 | + check( |
| 125 | + "no missing chunk and no page error", |
| 126 | + missing.length === 0 && errors.length === 0, |
| 127 | + [...missing, ...errors].join(", "), |
| 128 | + ); |
| 129 | + |
| 130 | + await page.context().setOffline(true); |
| 131 | + await page.reload(); |
| 132 | + check("offline: the cached page still opens", (await build()) === "Build buildN100000"); |
| 133 | +} finally { |
| 134 | + await browser.close(); |
| 135 | + server.close(); |
| 136 | + fs.rmSync(tmp, { recursive: true, force: true }); |
| 137 | +} |
| 138 | +fs.mkdirSync(path.dirname(OUT), { recursive: true }); |
| 139 | +fs.writeFileSync( |
| 140 | + OUT, |
| 141 | + `${lines.join("\n")}\n${failed ? `${failed} FAILED` : "all passed"} (${new Date().toISOString()})\n`, |
| 142 | +); |
| 143 | +log(failed ? `${failed} FAILED` : "all passed"); |
| 144 | +process.exit(failed ? 1 : 0); |
0 commit comments