Oregami
Repositories/oxedyne/daimond

oxedyne/daimond/dev/verify_pwa.mjs

33.2 KiB, 1 run

created by r2519314175:611, which is this file's identity for as long as the history lasts, whatever it is later renamed to

download · who wrote it · its history

1// verify_pwa.mjs — Daimond is installable, and the cache that makes it possible
2// cannot serve a build the server has moved past.
3//
4// Pinning Daimond to a home screen used to give a Safari bookmark: no manifest,
5// no icons, no worker. Adding those is routine. The part that is not routine is
6// that a service worker is a cache that serves CODE, and Daimond's whole update
7// story is that there is no such thing -- `js/updater.js` polls `build.json` so a
8// tab can never sit on last week's app without being told. A worker that ignored
9// that would quietly undo it, and a user stuck on an old build who cannot tell is
10// worse than no PWA at all.
11//
12// So one check here carries all the others:
13//
14// THE WORKER SERVES NOTHING FROM ITS CACHE ONCE THE BUILD ID HAS MOVED.
15//
16// It is asked at the network, on a server this file owns and can watch: the shell
17// is served, a warm reload is proved to hit nothing (so the cache is real and the
18// rest of the check means something), the build id is then changed on the server,
19// and the next load must fetch the document, the stylesheet AND the script again,
20// with the old cache gone rather than merely bypassed. Both halves, because "it
21// caches" and "it stops caching" are different failures and only one of them is
22// dangerous.
23//
24// The rest:
25//
26// * the manifest is linked, parses, and is `app.webmanifest` -- `manifest.json`
27// is the TRANSPARENCY manifest and was never available;
28// * every icon it names exists and is the size it claims, measured from the
29// PNG header rather than from the file name;
30// * a maskable icon really is inside the 80% safe circle, measured in pixels,
31// with the measurement proved on an image that is not;
32// * no `/api/` path, and not `build.json`, is ever put in a cache -- the second
33// is fatal on its own, since the worker reads that file to decide;
34// * the worker, the manifest and the icons are covered by the seal, so the
35// bytes of the thing that serves the bytes are checkable too;
36// * and the frame holds the hardware off its contents in standalone mode,
37// where there is no browser chrome doing it for you.
38//
39// EACH CHECK IS PROVED AGAINST BROKEN CODE FIRST. `--break <name>` serves a
40// deliberately damaged copy of a real file to the real page and the run is then
41// expected to FAIL; a break whose anchor does not match exactly once aborts,
42// because a check proved against code that was never broken is not proved.
43//
44// node dev/verify_pwa.mjs --break manifest # the document links no manifest
45// node dev/verify_pwa.mjs --break icons # an icon lies about its size
46// node dev/verify_pwa.mjs --break register # nothing registers the worker
47// node dev/verify_pwa.mjs --break stale # THE ONE: a moved build is ignored
48// node dev/verify_pwa.mjs --break api # the worker caches /api/
49// node dev/verify_pwa.mjs --break tokens # the safe-area tokens are not env()
50// node dev/verify_pwa.mjs --break safearea # the frame ignores the tokens
51// node dev/verify_pwa.mjs --break sealed # the worker is left out of the seal
52// node dev/verify_pwa.mjs # and then, clean
53//
54// eval "$(bash dev/world.sh 8 --up)"
55// node dev/verify_pwa.mjs
56//
57// THE SERVER IS THIS FILE'S OWN, not the world's, and not `page.route`. Three
58// reasons, all of them about the worker: a worker's own fetches do not go through
59// `page.route`, so the interception every other verifier uses cannot see the
60// traffic that matters here; the build id has to MOVE mid-run, and `build.json`
61// is a tracked file no test may rewrite; and the request log of a server is the
62// only honest way to ask "did that come from the cache or from you?". It listens
63// on port 0, so it cannot collide with another lane. The world is still used --
64// for its scratch directory, and because that is where a browser belongs.
65import fs from 'node:fs';
66import http from 'node:http';
67import path from 'node:path';
68import { fileURLToPath } from 'node:url';
69
70const HERE = path.dirname(fileURLToPath(import.meta.url));
71const WWW = path.join(HERE, '..', 'www');
72
73const BREAK = (() => {
74 const i = process.argv.indexOf('--break');
75 return i > 0 ? String(process.argv[i + 1] || '') : '';
76})();
77
78const ok = [], bad = [];
79const check = (name, pass, detail) => {
80 (pass ? ok : bad).push(name);
81 console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : ''));
82};
83
84// ── The breaks ───────────────────────────────────────────────────────
85// Each is a real edit to a real file, served in place of it.
86const BREAKS = {
87 // The document no longer declares a manifest, so nothing is installable.
88 manifest: [{
89 file: 'index.html',
90 find: '<link rel="manifest" href="app.webmanifest">',
91 with: '<!-- no manifest -->',
92 }],
93 // An icon that claims a size it is not. iOS picks by the declared size and
94 // then scales, so the mark arrives soft and nobody can say why.
95 icons: [{
96 file: 'app.webmanifest',
97 find: '{ "src": "assets/icons/icon-192.png", "sizes": "192x192"',
98 with: '{ "src": "assets/icons/icon-192.png", "sizes": "512x512"',
99 }],
100 // Nothing registers the worker: a manifest and icons alone give an app that
101 // installs and then cannot start without the network.
102 register: [{
103 file: 'js/pwa.js',
104 find: '\t\tif (!window.isSecureContext) return;',
105 with: '\t\tif (!window.isSecureContext) return;\n\t\tif (true) return;',
106 }],
107 // THE FAILURE THIS FILE EXISTS FOR, and it is the ordinary way a PWA is
108 // written: one cache under a fixed name, filled on first sight and read from
109 // for ever. The build id is still read, and still ignored, so a phone goes on
110 // running last week's app with nothing to show that it is.
111 stale: [
112 {
113 file: 'sw.js',
114 find: '\tif (b !== live) {\n\t\tawait sweep(b);\n\t\tlive = b;\n\t}',
115 with: '\tif (b !== live) {\n\t\tlive = b;\n\t}',
116 },
117 {
118 file: 'sw.js',
119 find: '\t\t\tconst c = await caches.open(PREFIX + live);',
120 with: "\t\t\tconst c = await caches.open(PREFIX + 'any');",
121 },
122 {
123 file: 'sw.js',
124 find: '\tconst c = await caches.open(PREFIX + at);',
125 with: "\tconst c = await caches.open(PREFIX + 'any');",
126 },
127 ],
128 // The worker treats the gateway as part of the shell. A user's mail, their
129 // spend and their model traffic go into a cache on disk, and a stale answer
130 // is served for one of them.
131 api: [
132 {
133 file: 'sw.js',
134 find: "const SHELL_DIRS = ['css/', 'js/', 'i18n/', 'fonts/', 'assets/', 'pkg/'];",
135 with: "const SHELL_DIRS = ['css/', 'js/', 'i18n/', 'fonts/', 'assets/', 'pkg/', 'api/'];",
136 },
137 {
138 file: 'sw.js',
139 find: "const NEVER_DIRS = ['api/', 'webhook/', 'vendor/', 'console/'];",
140 with: "const NEVER_DIRS = ['webhook/', 'vendor/', 'console/'];",
141 },
142 ],
143 // The tokens exist but are not fed by the platform, so they are nought on a
144 // phone and the whole safe area is a decoration that measures right in a test
145 // and does nothing on the device.
146 tokens: [{
147 file: 'css/variables.css',
148 find: '\t--safe-t: env(safe-area-inset-top, 0px);',
149 with: '\t--safe-t: 0px;',
150 }],
151 // The frame ignores them: the old padding, back as it was, with the top bar
152 // under the status bar. BOTH frames -- a phone in portrait is under the
153 // narrow breakpoint and takes responsive.css's rule, and turned on its side
154 // it is 852px wide and takes app.css's. Breaking one leaves the other doing
155 // the job, and the run would rightly stay green for half the screen.
156 safearea: [
157 {
158 file: 'css/app.css',
159 find: 'padding: calc(10px + var(--safe-t)) calc(12px + var(--safe-r)) calc(12px + var(--safe-b)) calc(12px + var(--safe-l));',
160 with: 'padding: 10px 12px 12px;',
161 },
162 {
163 file: 'css/responsive.css',
164 find: '\t\tpadding: calc(8px + var(--safe-t)) calc(8px + var(--safe-r)) 0 calc(8px + var(--safe-l));',
165 with: '\t\tpadding: 8px 8px 0;',
166 },
167 // And the drawer, which is fixed to the viewport and so takes neither.
168 {
169 file: 'css/mobile.css',
170 find: '\t\tpadding: calc(8px + var(--safe-t)) 8px calc(8px + var(--safe-b)) calc(8px + var(--safe-l));',
171 with: '\t\tpadding: 8px;',
172 },
173 ],
174};
175
176// `sealed` is not in the table above and cannot be: the file that would have to
177// be damaged is `verify/lib.mjs`, the tool the seal is computed with, and a
178// verifier that edits its own instrument proves nothing. What is damaged instead
179// is the QUESTION -- the covered set is asked for with `sw.js` excluded, which is
180// exactly what a future edit to that file's EXCLUDE list would do.
181const SEALED_BREAK = BREAK === 'sealed';
182
183if (BREAK && !BREAKS[BREAK] && !SEALED_BREAK) {
184 console.error(`unknown break '${BREAK}'; one of: ${Object.keys(BREAKS).join(', ')}, sealed`);
185 process.exit(2);
186}
187
188/// The damaged source, or a hard stop.
189function damaged(src, spec) {
190 const n = src.split(spec.find).length - 1;
191 if (n !== 1) {
192 console.error(`break '${BREAK}': the anchor appears ${n} times in ${spec.file}, `
193 + 'so nothing was broken and the run below would prove nothing.');
194 process.exit(2);
195 }
196 return src.replace(spec.find, spec.with);
197}
198
199/// The damaged files, by relative path, built once so the anchors are checked
200/// before a browser is started.
201///
202/// Each spec is applied to what the LAST one left, not to the file on disk. A
203/// break that edits one file twice -- and two of them do -- otherwise keeps only
204/// its final edit, and then runs green while claiming to have broken something.
205const DAMAGED = {};
206for (const spec of (BREAKS[BREAK] || [])) {
207 const src = Object.prototype.hasOwnProperty.call(DAMAGED, spec.file)
208 ? DAMAGED[spec.file]
209 : fs.readFileSync(path.join(WWW, spec.file), 'utf8');
210 DAMAGED[spec.file] = damaged(src, spec);
211}
212
213// ── The server ───────────────────────────────────────────────────────
214
215const TYPES = {
216 '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8',
217 '.mjs': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8',
218 '.json': 'application/json', '.webmanifest': 'application/manifest+json',
219 '.wasm': 'application/wasm', '.svg': 'image/svg+xml', '.png': 'image/png',
220 '.woff2': 'font/woff2', '.map': 'application/json', '.txt': 'text/plain; charset=utf-8',
221};
222
223/// Every path asked of the server, in order. This is the oracle: a file that was
224/// served from the cache is a file that is not in here.
225const hits = [];
226const since = () => hits.length;
227const asked = (from, p) => hits.slice(from).filter(h => h === p).length;
228
229/// The build id the server is currently on. Moving it is what the whole
230/// verifier turns on, and it is why this cannot be the world's dev server.
231const state = { build: 'build-one' };
232
233function send(res, status, type, body, cc) {
234 res.writeHead(status, { 'content-type': type, 'cache-control': cc });
235 res.end(body);
236}
237
238const server = http.createServer((req, res) => {
239 let p;
240 try { p = decodeURIComponent(new URL(req.url, 'http://x').pathname); }
241 catch (e) { return send(res, 400, 'text/plain', 'bad path', 'no-store'); }
242 hits.push(p);
243
244 // The staleness stamp, served from memory so the test can move it.
245 if (p === '/build.json') {
246 return send(res, 200, 'application/json',
247 JSON.stringify({ build: state.build, note: 'verify_pwa' }), 'no-cache');
248 }
249 // The one gateway route this needs. Everything else under /api/ answers the
250 // way dev/serve.mjs does with no gateway running, which every browser-only
251 // verifier already tolerates. `no-cache`, not `no-store`, because that is
252 // what Steel stamps a generated response with -- a worker that only kept
253 // API responses out because of a header would pass a weaker test.
254 if (p === '/api/ping') {
255 return send(res, 200, 'application/json', JSON.stringify({ ok: true, at: Date.now() }), 'no-cache');
256 }
257 if (p.startsWith('/api/') || p.startsWith('/webhook/')) {
258 return send(res, 502, 'application/json', JSON.stringify({ error: 'no gateway' }), 'no-store');
259 }
260
261 const rel = p === '/' ? 'index.html' : p.replace(/^\/+/, '');
262 if (rel.includes('..')) return send(res, 403, 'text/plain', 'no', 'no-store');
263 const type = TYPES[path.extname(rel)] || 'application/octet-stream';
264
265 if (Object.prototype.hasOwnProperty.call(DAMAGED, rel)) {
266 return send(res, 200, type, DAMAGED[rel], 'no-cache');
267 }
268 let body;
269 try { body = fs.readFileSync(path.join(WWW, rel)); }
270 catch (e) { return send(res, 404, 'text/plain', 'not found: ' + rel, 'no-store'); }
271 // `no-cache` on everything, which is what Steel serves (srv/cache.rs): a
272 // store may hold it and may not use it without asking. The worker's whole
273 // design is that BUILD.JSON is what it asks, once, for the lot.
274 send(res, 200, type, body, 'no-cache');
275});
276
277await new Promise(r => server.listen(0, '127.0.0.1', r));
278const PORT = server.address().port;
279const APP = `http://127.0.0.1:${PORT}`;
280console.log(`serving ${WWW} on ${APP}\n`);
281
282// The harness reads the app's address once, at import, so this is set first.
283process.env.DAIMOND_APP = APP;
284const { open, shot, scratch, errors, signInAs } = await import('./harness.mjs');
285
286const PROFILE = scratch('pw', 'pwa' + (BREAK ? '-' + BREAK : ''));
287fs.rmSync(PROFILE, { recursive: true, force: true });
288
289// ── Measurements that need no browser ────────────────────────────────
290
291/// The pixel size a PNG really is, from its IHDR. The manifest's `sizes` is a
292/// claim; this is the file.
293function pngSize(file) {
294 const b = fs.readFileSync(file);
295 if (b.length < 24 || b.readUInt32BE(0) !== 0x89504e47) return null;
296 return { w: b.readUInt32BE(16), h: b.readUInt32BE(20) };
297}
298
299/// The app's ground colour, from the stylesheet that defines it. The manifest
300/// has to agree with it, or the splash and the app are two different blacks.
301function groundColour() {
302 const css = fs.readFileSync(path.join(WWW, 'css', 'variables.css'), 'utf8');
303 const m = /:root\s*\{[\s\S]*?--bg-primary:\s*(#[0-9a-fA-F]{3,8})\s*;/.exec(css);
304 return m ? m[1].toLowerCase() : null;
305}
306
307const sleep = (ms) => new Promise(r => setTimeout(r, ms));
308
309const s = await open({ name: 'pwa', profile: PROFILE, signIn: false, connect: false });
310const { page } = s;
311
312// The browser's OWN cache is switched off for the whole run. It sits in a
313// different place from the worker's -- Chrome will reuse a subresource on a
314// reload without asking anybody, which is a perfectly good thing for it to do and
315// makes "did that come from the cache or from the server?" unanswerable. With it
316// off, every request either reaches the server or was answered by the worker,
317// and the request log means exactly what it says. Nothing about the worker's own
318// cache is affected.
319const cdp = await s.browser.newCDPSession(page);
320await cdp.send('Network.enable');
321await cdp.send('Network.setCacheDisabled', { cacheDisabled: true });
322
323try {
324 // ── 0. The seal covers the new files ─────────────────────────
325 // A worker is code, served from Daimond's origin, and every other served
326 // byte is hashed into www/manifest.json and chained in the transparency log.
327 // One that was not would be the only part of the client nobody could check.
328 {
329 const lib = await import('../verify/lib.mjs');
330 const exclude = SEALED_BREAK ? new Set([...lib.EXCLUDE, 'sw.js']) : lib.EXCLUDE;
331 const covered = new Set(await lib.coveredFiles(WWW, { exclude }));
332 const want = [
333 'sw.js', 'app.webmanifest', 'js/pwa.js',
334 'assets/icons/icon-180.png', 'assets/icons/icon-192.png',
335 'assets/icons/icon-512.png', 'assets/icons/maskable-192.png',
336 'assets/icons/maskable-512.png',
337 ];
338 const missing = want.filter(f => !covered.has(f));
339 check('THE WORKER AND THE MANIFEST ARE SEALED WITH EVERYTHING ELSE',
340 missing.length === 0, missing.length ? 'not covered: ' + missing.join(', ') : `${want.length} files`);
341 }
342
343 // ── 1. The manifest is linked, and parses ────────────────────
344 // The plain URL, which is the one a user has: nothing here needs the cache.
345 await page.goto(APP + '/', { waitUntil: 'domcontentloaded' });
346 await sleep(1500);
347
348 const link = await page.evaluate(() => {
349 const el = document.querySelector('link[rel="manifest"]');
350 return el ? { href: el.getAttribute('href'), abs: el.href } : null;
351 });
352 check('the document links a web app manifest', !!link, link ? link.href : 'no <link rel="manifest">');
353 check('and it is app.webmanifest, because manifest.json is the transparency manifest',
354 !!link && link.href === 'app.webmanifest', link ? link.href : '—');
355
356 const mf = link ? await page.evaluate(async (href) => {
357 try {
358 const r = await fetch(href);
359 return { status: r.status, type: r.headers.get('content-type'), text: await r.text() };
360 } catch (e) { return { status: 0, type: '', text: '' }; }
361 }, link.href) : { status: 0, type: '', text: '' };
362
363 let man = null;
364 try { man = JSON.parse(mf.text); } catch (e) { man = null; }
365 check('the manifest is served and parses', mf.status === 200 && !!man,
366 `status ${mf.status}, ${mf.text.length} bytes`);
367 check('and is served as a manifest rather than a download',
368 /manifest\+json/.test(mf.type || ''), mf.type || 'none');
369
370 if (man) {
371 check('it asks for a window of its own', man.display === 'standalone', String(man.display));
372 check('and names a start url and a scope', !!man.start_url && !!man.scope,
373 `${man.start_url} / ${man.scope}`);
374 check('and carries a name for the home screen',
375 !!man.name && !!man.short_name && man.short_name.length <= 12,
376 `${man.name} / ${man.short_name}`);
377 const bg = groundColour();
378 check('its colours are the app\'s own, not invented',
379 (man.theme_color || '').toLowerCase() === bg
380 && (man.background_color || '').toLowerCase() === bg,
381 `manifest ${man.theme_color}/${man.background_color}, css --bg-primary ${bg}`);
382 }
383
384 // The meta tag the platform paints its furniture with, after the app has
385 // booted and chosen a palette.
386 const metaColour = await page.evaluate(() => {
387 const m = document.querySelector('meta[name="theme-color"]');
388 const bg = getComputedStyle(document.documentElement).getPropertyValue('--bg-primary').trim();
389 return { meta: m ? m.getAttribute('content') : null, bg };
390 });
391 check('the theme colour follows the palette in use',
392 !!metaColour.meta && metaColour.meta.toLowerCase() === metaColour.bg.toLowerCase(),
393 `${metaColour.meta} vs ${metaColour.bg}`);
394
395 // iOS reads its own tags and will not take an SVG for a home-screen icon.
396 const apple = await page.evaluate(() => {
397 const icon = document.querySelector('link[rel="apple-touch-icon"]');
398 const cap = document.querySelector('meta[name="apple-mobile-web-app-capable"]');
399 return { icon: icon ? icon.getAttribute('href') : null, cap: cap ? cap.content : null };
400 });
401 check('iOS is told it may open without Safari\'s chrome', apple.cap === 'yes', String(apple.cap));
402 check('and is given a PNG for the home screen, which is all it accepts',
403 !!apple.icon && /\.png$/.test(apple.icon), String(apple.icon));
404 if (apple.icon) {
405 const sz = pngSize(path.join(WWW, apple.icon));
406 check('and that icon is the 180px iOS asks for',
407 !!sz && sz.w === 180 && sz.h === 180, sz ? `${sz.w}x${sz.h}` : 'unreadable');
408 }
409
410 // ── 2. The icons are the sizes they claim ────────────────────
411 if (man && Array.isArray(man.icons)) {
412 const wrong = [];
413 for (const ic of man.icons) {
414 const file = path.join(WWW, ic.src);
415 const sz = pngSize(file);
416 const want = String(ic.sizes || '').split('x').map(Number);
417 if (!sz) { wrong.push(`${ic.src}: missing or not a PNG`); continue; }
418 if (sz.w !== want[0] || sz.h !== want[1]) {
419 wrong.push(`${ic.src}: says ${ic.sizes}, is ${sz.w}x${sz.h}`);
420 }
421 }
422 check('every icon the manifest names exists and is the size it claims',
423 wrong.length === 0 && man.icons.length >= 2, wrong.join('; ') || `${man.icons.length} icons`);
424 check('and one of them is maskable, so a round crop is survivable',
425 man.icons.some(i => /maskable/.test(i.purpose || '')),
426 man.icons.map(i => i.purpose || 'any').join(', '));
427 }
428
429 // A maskable icon may be cropped to any shape, and only the centre circle of
430 // 80% diameter is safe. Measured in PIXELS, in the browser, and the
431 // measurement is proved on an image that fails it -- otherwise "the ink is
432 // inside the circle" is a sentence that passes on a blank square.
433 const mask = await page.evaluate(async (src) => {
434 function measure(ctx, n) {
435 const d = ctx.getImageData(0, 0, n, n).data;
436 const bg = [d[0], d[1], d[2]];
437 let ink = 0, far = 0;
438 const c = n / 2;
439 for (let y = 0; y < n; y++) {
440 for (let x = 0; x < n; x++) {
441 const i = (y * n + x) * 4;
442 if (Math.abs(d[i] - bg[0]) + Math.abs(d[i + 1] - bg[1]) + Math.abs(d[i + 2] - bg[2]) < 24) continue;
443 ink++;
444 const r = Math.hypot(x + 0.5 - c, y + 0.5 - c);
445 if (r > far) far = r;
446 }
447 }
448 return { ink, far, safe: n * 0.4 };
449 }
450 const img = new Image();
451 img.src = src;
452 await img.decode();
453 const n = img.naturalWidth;
454 const cv = document.createElement('canvas');
455 cv.width = cv.height = n;
456 const ctx = cv.getContext('2d', { willReadFrequently: true });
457 ctx.drawImage(img, 0, 0);
458 const real = measure(ctx, n);
459 // The negative control: the same image with ink in a corner. If the
460 // measurement cannot see that, it cannot see anything.
461 ctx.fillStyle = '#00ff00';
462 ctx.fillRect(0, 0, 12, 12);
463 const spoilt = measure(ctx, n);
464 return { real, spoilt, n };
465 }, 'assets/icons/maskable-512.png');
466 check('the maskable icon\'s ink is inside the safe circle',
467 mask.real.ink > 1000 && mask.real.far <= mask.real.safe,
468 `${mask.real.ink} ink px, furthest ${mask.real.far.toFixed(1)} of ${mask.real.safe}`);
469 check('and the measurement rejects ink outside it, so the line above means something',
470 mask.spoilt.far > mask.spoilt.safe,
471 `furthest ${mask.spoilt.far.toFixed(1)} of ${mask.spoilt.safe}`);
472
473 // ── 3. The worker registers ──────────────────────────────────
474 // On the plain URL, which is the registration a user gets.
475 const waitForWorker = (flagged) => page.evaluate((want) => new Promise((res) => {
476 if (!('serviceWorker' in navigator)) { res(false); return; }
477 const has = () => {
478 const c = navigator.serviceWorker.controller;
479 return !!c && (!want || /cache=on/.test(c.scriptURL));
480 };
481 if (has()) { res(true); return; }
482 const t = setInterval(() => { if (has()) { clearInterval(t); res(true); } }, 200);
483 setTimeout(() => { clearInterval(t); res(has()); }, 12000);
484 }), flagged);
485 check('a shell worker registers and takes control', await waitForWorker(false) === true, '');
486
487 // From here the cache has to be ON, which it is not on a loopback host (see
488 // sw.js). The flag rides on the page's URL, so the plain registration made
489 // above is retired first: waiting for "a controller" alone can catch the
490 // outgoing one, whose cache is off, and the run then measures the wrong
491 // worker -- which it did, intermittently, until this was written.
492 await page.evaluate(async () => {
493 const rs = await navigator.serviceWorker.getRegistrations();
494 await Promise.all(rs.map(r => r.unregister()));
495 });
496 await page.goto(APP + '/?cache=on', { waitUntil: 'load' });
497 const flagged = await waitForWorker(true);
498 check('and with the cache switched on it takes control too', flagged === true, String(flagged));
499
500 const swState = await page.evaluate(() => window.DaimondPWA.state());
501 check('and its cache is switched on for this run', !!swState && swState.shell === true,
502 JSON.stringify(swState));
503 check('and it agrees with the server about which build is live',
504 !!swState && swState.live === 'build-one', swState ? String(swState.live) : 'no worker');
505
506 // ── 4. THE ONE: a moved build id empties the cache ───────────
507 // First, prove the cache is real. A reload with the worker in control should
508 // reach the server for nothing at all; if it reaches for everything, the
509 // check below would pass with the whole feature absent.
510 await page.reload({ waitUntil: 'load' }); // prime: every shell file, through the worker
511 await sleep(2500);
512 let from = since();
513 await page.reload({ waitUntil: 'load' });
514 await sleep(2000);
515 const warm = ['/index.html', '/css/app.css', '/js/daimond.js', '/js/updater.js']
516 .map(p => `${p}:${asked(from, p)}`);
517 const warmTotal = ['/', '/index.html', '/css/app.css', '/js/daimond.js', '/js/updater.js']
518 .reduce((n, p) => n + asked(from, p), 0);
519 if (process.env.PWA_DEBUG) console.log(' warm window:', JSON.stringify(hits.slice(from)));
520 check('a warm reload is served from the cache, not from the server',
521 warmTotal === 0, warm.join(' '));
522 check('and the stamp is still fetched every time, because that is what decides',
523 asked(from, '/build.json') >= 1, `${asked(from, '/build.json')} fetch(es)`);
524
525 // Now move the build. Everything the cache holds is a build the server has
526 // left behind, and none of it may be served again.
527 state.build = 'build-two';
528 from = since();
529 await page.reload({ waitUntil: 'load' });
530 await sleep(2500);
531 const cold = {
532 doc: asked(from, '/') + asked(from, '/index.html'),
533 css: asked(from, '/css/app.css'),
534 js: asked(from, '/js/daimond.js'),
535 };
536 check('THE WORKER SERVES NOTHING FROM THE CACHE ONCE THE BUILD ID HAS MOVED',
537 cold.doc >= 1 && cold.css >= 1 && cold.js >= 1,
538 `document ${cold.doc}, stylesheet ${cold.css}, script ${cold.js} fetched from the server`);
539
540 const after = await page.evaluate(() => window.DaimondPWA.state());
541 // Deleted, not merely stepped over -- and nothing else left standing either.
542 // A cache under some name of its own that the build does not enter into is
543 // how a worker ends up holding a build for ever without appearing to.
544 check('and no cache but the live build\'s is left standing',
545 !!after && after.caches.length > 0 && after.caches.every(n => n === 'daimond-shell-build-two'),
546 after ? after.caches.join(', ') || 'none' : 'no worker');
547 check('and the worker has adopted the build the server is actually on',
548 !!after && after.live === 'build-two', after ? String(after.live) : 'no worker');
549
550 // ── 5. Nothing of the user's, and nothing that decides ───────
551 from = since();
552 const twice = await page.evaluate(async () => {
553 const a = await (await fetch('api/ping')).json();
554 const b = await (await fetch('api/ping')).json();
555 return [a.at, b.at];
556 });
557 check('an API call reaches the gateway every time it is made',
558 asked(from, '/api/ping') === 2 && twice[0] !== twice[1],
559 `${asked(from, '/api/ping')} request(s), ${twice[0]} vs ${twice[1]}`);
560
561 const held = await page.evaluate(async () => {
562 const names = await caches.keys();
563 const out = [];
564 for (const n of names) {
565 const c = await caches.open(n);
566 for (const r of await c.keys()) out.push(r.url);
567 }
568 return out;
569 });
570 const forbidden = held.filter(u =>
571 /\/api\//.test(u) || /\/webhook\//.test(u)
572 || /\/build\.json$/.test(u) || /\/manifest\.json$/.test(u) || /\/releases\.json$/.test(u));
573 check('and nothing under /api/ is in a cache, nor any file that says what the server is doing',
574 forbidden.length === 0, forbidden.join(', ') || `${held.length} shell entries held`);
575 if (process.env.PWA_DEBUG) console.log(' cached:', JSON.stringify(held));
576 check('while the shell itself IS held, so the app opens without a network',
577 held.some(u => /\/js\/daimond\.js$/.test(u)) && held.some(u => /index\.html$/.test(u)),
578 `${held.length} entries`);
579
580 // ── 6. Standalone: the hardware takes its share of the screen ─
581 // Chrome reports nought for every inset on a desktop, so the four tokens are
582 // set to an iPhone 15 Pro's real numbers and the frame is measured against
583 // them. That is only honest because the tokens are the ONE place the app
584 // reads the platform, which is asserted first.
585 const varsCss = fs.readFileSync(path.join(WWW, 'css', 'variables.css'), 'utf8');
586 const tokenSrc = ['t:top', 'r:right', 'b:bottom', 'l:left'].every(pair => {
587 const [k, side] = pair.split(':');
588 return new RegExp(`--safe-${k}:\\s*env\\(safe-area-inset-${side}`).test(
589 BREAK === 'tokens' ? DAMAGED['css/variables.css'] : varsCss);
590 });
591 check('the safe-area tokens are fed by the platform, not by a number',
592 tokenSrc, 'css/variables.css --safe-t/r/b/l');
593
594 await cdp.send('Emulation.setEmulatedMedia', {
595 media: 'screen',
596 features: [{ name: 'display-mode', value: 'standalone' }],
597 });
598
599 const INSET = { t: 59, r: 0, b: 34, l: 0 }; // iPhone 15 Pro, portrait
600 const setInsets = (i) => page.evaluate((v) => {
601 let el = document.getElementById('pwa-insets');
602 if (!el) { el = document.createElement('style'); el.id = 'pwa-insets'; document.head.appendChild(el); }
603 el.textContent = `:root{--safe-t:${v.t}px;--safe-r:${v.r}px;--safe-b:${v.b}px;--safe-l:${v.l}px;}`;
604 }, i);
605
606 await page.setViewportSize({ width: 393, height: 852 });
607 await signInAs(s, 'pwa');
608 await setInsets(INSET);
609 await sleep(600);
610
611 const port = await page.evaluate(() => {
612 const r = (sel) => {
613 const el = document.querySelector(sel);
614 if (!el) return null;
615 const b = el.getBoundingClientRect();
616 return { top: b.top, bottom: b.bottom, left: b.left, right: b.right };
617 };
618 const btns = [...document.querySelectorAll('#mnav button')].map(b => b.getBoundingClientRect().bottom);
619 return {
620 bar: r('.topbar'),
621 brand: r('.brand-wordmark:not([hidden])') || r('.brand'),
622 nav: btns.length ? Math.max(...btns) : null,
623 h: window.innerHeight,
624 };
625 });
626 check('the top bar clears the status bar and the notch',
627 !!port.bar && port.bar.top >= INSET.t,
628 port.bar ? `bar top ${port.bar.top.toFixed(1)}, inset ${INSET.t}` : 'no top bar');
629 check('and so does its ink, not merely its box',
630 !!port.brand && port.brand.top >= INSET.t,
631 port.brand ? `wordmark top ${port.brand.top.toFixed(1)}` : 'no wordmark');
632 check('the bottom bar clears the home indicator',
633 port.nav !== null && port.nav <= port.h - INSET.b,
634 port.nav === null ? 'no bottom bar' : `lowest button ${port.nav.toFixed(1)}, floor ${port.h - INSET.b}`);
635 await shot(s, 'pwa-standalone-portrait' + (BREAK ? '-' + BREAK : ''));
636
637 // The drawer is fixed to the viewport, outside the frame's padding, so it
638 // has to hold the hardware off its own contents.
639 await page.click('#drawer-btn', { force: true });
640 await sleep(700);
641 const drawer = await page.evaluate((inset) => {
642 const rail = document.querySelector('.panel.rail');
643 if (!rail) return null;
644 const cs = getComputedStyle(rail);
645 const first = rail.querySelector('button, input, a');
646 return {
647 padTop: parseFloat(cs.paddingTop),
648 padBot: parseFloat(cs.paddingBottom),
649 firstTop: first ? first.getBoundingClientRect().top : null,
650 open: document.body.classList.contains('drawer-open'),
651 inset,
652 };
653 }, INSET);
654 check('the drawer holds its contents off the status bar and the home indicator',
655 !!drawer && drawer.padTop >= INSET.t && drawer.padBot >= INSET.b,
656 drawer ? `padding ${drawer.padTop}/${drawer.padBot}, insets ${INSET.t}/${INSET.b}` : 'no drawer');
657 check('and its first control is below the notch',
658 !!drawer && drawer.firstTop !== null && drawer.firstTop >= INSET.t,
659 drawer ? String(drawer.firstTop) : '—');
660 await shot(s, 'pwa-standalone-drawer' + (BREAK ? '-' + BREAK : ''));
661 // Escape, not a click on the scrim: a forced click lands at a point whether
662 // or not anything is over it, and once the drawer began closing it went
663 // through to the control underneath and opened a dialog over the shot.
664 await page.keyboard.press('Escape');
665 await sleep(500);
666
667 // Landscape: the notch moves to one side, and 852px wide is above the phone
668 // breakpoint, so this is the desktop frame taking the inset.
669 await page.setViewportSize({ width: 852, height: 393 });
670 await setInsets({ t: 0, r: 59, b: 21, l: 59 });
671 await sleep(700);
672 const land = await page.evaluate(() => {
673 const bar = document.querySelector('.topbar');
674 const b = bar ? bar.getBoundingClientRect() : null;
675 const main = document.querySelector('.main');
676 return b ? {
677 left: b.left, right: b.right,
678 foot: main ? main.getBoundingClientRect().bottom : null,
679 w: window.innerWidth, h: window.innerHeight,
680 } : null;
681 });
682 check('in landscape the frame clears the notch on both sides',
683 !!land && land.left >= 59 && land.right <= land.w - 59,
684 land ? `bar ${land.left.toFixed(1)}..${land.right.toFixed(1)} of ${land.w}` : 'no top bar');
685 // And the foot of the panels clears the home indicator. This is the desktop
686 // frame -- a phone on its side is 852px wide, above the narrow breakpoint --
687 // so it is a different rule from the bottom bar's on the portrait screen.
688 check('and its foot clears the home indicator',
689 !!land && land.foot !== null && land.foot <= land.h - 21,
690 land ? `panels end ${land.foot && land.foot.toFixed(1)}, floor ${land.h - 21}` : '—');
691 await shot(s, 'pwa-standalone-landscape' + (BREAK ? '-' + BREAK : ''));
692
693 // A worker that throws on every navigation is a worker nobody will notice is
694 // broken, so the console is part of the result.
695 const errs = errors(s).filter(e =>
696 !/Failed to load resource/.test(e) && !/no gateway/.test(e) && !/502/.test(e));
697 check('nothing was served by way of an unhandled error', errs.length === 0,
698 errs.slice(0, 3).join(' | '));
699} finally {
700 await s.close();
701 server.close();
702}
703
704console.log(`\nrequests seen: ${hits.length}`);
705if (BREAK) {
706 console.log(`\nbreak '${BREAK}': ${bad.length} check(s) failed`
707 + (bad.length ? ' — ' + bad.join('; ') : ' — NOTHING FAILED, so the checks above prove nothing'));
708 process.exit(bad.length ? 0 : 1); // a break MUST fail something
709}
710console.log(bad.length === 0 ? '\nall checks passed' : `\n${bad.length} check(s) FAILED`);
711process.exit(bad.length === 0 ? 0 : 1);