1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137 | // M6 update-banner e2e — proves the PWA update flow end-to-end at the UI
// level: the built app is served from a scratch copy of dist/, the service
// worker installs, then the SERVED copy is mutated (sw.js bytes + a bumped
// version.json) exactly like a new deploy. A registration update must surface
// the #upd-banner with the LIVE version number (fetchLiveVersion bypasses the
// SW precache), and tapping it must reload the page into a controlled state.
//
// Scope honesty: the reload is driven by the app's own click handler
// (skip-waiting message → controllerchange, with the 4 s fallback timer), so
// this asserts "banner → tap → reload with the new SW controlling". What it
// cannot assert is new-asset delivery on a real gateway deploy — the mutated
// sw.js here precaches the same assets. That path is covered by the identical
// workbox revision mechanics in production.
//
// Run: node e2e/update.e2e.mjs (builds dist/ if missing; FORCE_BUILD=1 to rebuild)
import { execSync } from "node:child_process";
import { cpSync, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { createServer } from "node:http";
import { tmpdir } from "node:os";
import { extname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { chromium } from "playwright";
const fail = (msg) => {
throw new Error(msg);
};
const ok = (msg) => console.log("✅ " + msg);
const CLIENT_DIR = fileURLToPath(new URL("..", import.meta.url));
const DIST = join(CLIENT_DIR, "dist");
const PORT = 4179;
const LIVE_VERSION = "9.9.9"; // what the "server" reports after the fake deploy
const MIME = {
".html": "text/html; charset=utf-8",
".js": "text/javascript",
".mjs": "text/javascript",
".css": "text/css",
".json": "application/json",
".webmanifest": "application/manifest+json",
".png": "image/png",
".svg": "image/svg+xml",
".ico": "image/x-icon",
".txt": "text/plain",
".woff2": "font/woff2",
};
async function main() {
if (process.env.FORCE_BUILD === "1" || !existsSync(join(DIST, "sw.js"))) {
console.log("building dist/ …");
execSync("npm run build", { cwd: CLIENT_DIR, stdio: "inherit" });
} else {
console.log("reusing existing dist/ (FORCE_BUILD=1 to rebuild)");
}
// serve a SCRATCH copy — the fake deploy mutates it, never the repo's dist
const site = mkdtempSync(join(tmpdir(), "cp-e2e-upd-"));
cpSync(DIST, site, { recursive: true });
const server = createServer((req, res) => {
const path = decodeURIComponent(new URL(req.url, `http://localhost:${PORT}`).pathname);
const file = join(site, path === "/" ? "index.html" : path.slice(1));
try {
const body = readFileSync(file);
res.writeHead(200, {
"content-type": MIME[extname(file)] ?? "application/octet-stream",
"cache-control": "no-store", // like the IPFS gateway's revalidation, keeps the test deterministic
});
res.end(body);
} catch {
res.writeHead(404, { "content-type": "text/plain" });
res.end("not found");
}
});
await new Promise((r) => server.listen(PORT, r));
const browser = await chromium.launch({ headless: !process.env.HEADED });
const page = await (await browser.newContext({ viewport: { width: 390, height: 844 }, locale: "ro-RO" })).newPage();
page.on("pageerror", (e) => console.log("[pageerror]", e.message));
try {
await page.goto(`http://localhost:${PORT}/`);
await page.waitForSelector('[data-testid="onboarding"], [data-testid="tab-profile"]', { timeout: 20000 });
await page.evaluate(() => navigator.serviceWorker.ready);
// let the first SW finish precaching + claiming before the "deploy"
await page.waitForFunction(() => navigator.serviceWorker.controller !== null, undefined, { timeout: 20000 });
await page.waitForTimeout(1500);
ok("app served from dist copy; first service worker installed and controlling");
if (await page.$("#upd-banner")) fail("update banner visible before any update exists");
// ---- the fake deploy: new sw.js bytes + a bumped live version.json ------
const swFile = join(site, "sw.js");
writeFileSync(swFile, readFileSync(swFile, "utf8") + `\n// deploy ${LIVE_VERSION}\n`);
writeFileSync(join(site, "version.json"), JSON.stringify({ version: LIVE_VERSION }));
await page.evaluate(async () => (await navigator.serviceWorker.getRegistration())?.update());
await page.waitForSelector("#upd-banner", { timeout: 30000 });
const banner = (await page.textContent("#upd-banner")).trim();
console.log("banner:", banner);
if (!banner.includes(LIVE_VERSION))
fail(`banner does not announce the live version ${LIVE_VERSION}: "${banner}"`);
ok("update banner appeared with the live version number (precache-bypassing fetch)");
// ---- tap → the app owns the reload (controllerchange or 4 s fallback) ---
await page.evaluate(() => {
window.__preReload = true;
});
const reloaded = page.waitForEvent("load", { timeout: 25000 });
await page.click("#upd-banner");
await reloaded;
// a fresh window proves a REAL reload, not a re-render
const fresh = await page.evaluate(() => window.__preReload === undefined);
if (!fresh) fail("banner tap did not actually reload the page");
await page.waitForFunction(() => navigator.serviceWorker.controller !== null, undefined, { timeout: 20000 });
const waitingLeft = await page.evaluate(async () => {
const r = await navigator.serviceWorker.getRegistration();
return !!r?.waiting;
});
if (waitingLeft) fail("a service worker is still waiting after the update tap");
if (await page.$("#upd-banner")) fail("update banner survived the reload");
ok("banner tap reloaded the app; new service worker active + controlling, no waiting SW");
} finally {
await browser.close();
server.close();
rmSync(site, { recursive: true, force: true });
}
}
try {
await main();
console.log("\n🎉 update-banner flow passed");
process.exit(0);
} catch (err) {
console.error("❌ " + (err?.message ?? err));
process.exit(1);
}
|