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. |
| 65 | import fs from 'node:fs'; |
| 66 | import http from 'node:http'; |
| 67 | import path from 'node:path'; |
| 68 | import { fileURLToPath } from 'node:url'; |
| 69 | |
| 70 | const HERE = path.dirname(fileURLToPath(import.meta.url)); |
| 71 | const WWW = path.join(HERE, '..', 'www'); |
| 72 | |
| 73 | const BREAK = (() => { |
| 74 | const i = process.argv.indexOf('--break'); |
| 75 | return i > 0 ? String(process.argv[i + 1] || '') : ''; |
| 76 | })(); |
| 77 | |
| 78 | const ok = [], bad = []; |
| 79 | const 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. |
| 86 | const 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. |
| 181 | const SEALED_BREAK = BREAK === 'sealed'; |
| 182 | |
| 183 | if (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. |
| 189 | function 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. |
| 205 | const DAMAGED = {}; |
| 206 | for (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 | |
| 215 | const 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. |
| 225 | const hits = []; |
| 226 | const since = () => hits.length; |
| 227 | const 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. |
| 231 | const state = { build: 'build-one' }; |
| 232 | |
| 233 | function send(res, status, type, body, cc) { |
| 234 | res.writeHead(status, { 'content-type': type, 'cache-control': cc }); |
| 235 | res.end(body); |
| 236 | } |
| 237 | |
| 238 | const 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 | |
| 277 | await new Promise(r => server.listen(0, '127.0.0.1', r)); |
| 278 | const PORT = server.address().port; |
| 279 | const APP = `http://127.0.0.1:${PORT}`; |
| 280 | console.log(`serving ${WWW} on ${APP}\n`); |
| 281 | |
| 282 | // The harness reads the app's address once, at import, so this is set first. |
| 283 | process.env.DAIMOND_APP = APP; |
| 284 | const { open, shot, scratch, errors, signInAs } = await import('./harness.mjs'); |
| 285 | |
| 286 | const PROFILE = scratch('pw', 'pwa' + (BREAK ? '-' + BREAK : '')); |
| 287 | fs.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. |
| 293 | function 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. |
| 301 | function 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 | |
| 307 | const sleep = (ms) => new Promise(r => setTimeout(r, ms)); |
| 308 | |
| 309 | const s = await open({ name: 'pwa', profile: PROFILE, signIn: false, connect: false }); |
| 310 | const { 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. |
| 319 | const cdp = await s.browser.newCDPSession(page); |
| 320 | await cdp.send('Network.enable'); |
| 321 | await cdp.send('Network.setCacheDisabled', { cacheDisabled: true }); |
| 322 | |
| 323 | try { |
| 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 | |
| 704 | console.log(`\nrequests seen: ${hits.length}`); |
| 705 | if (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 | } |
| 710 | console.log(bad.length === 0 ? '\nall checks passed' : `\n${bad.length} check(s) FAILED`); |
| 711 | process.exit(bad.length === 0 ? 0 : 1); |