Oregami
Repositories/oxedyne/daimond

oxedyne/daimond/dev/verify_cappdelivery.mjs

22.8 KiB, 1 run

created by r2519314175:261, 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_cappdelivery.mjs — the guide's capp delivery button, and the update path
2// that keeps what it delivered current.
3//
4// Not to be confused with `dev/verify_capp.mjs`, which is about what the app REFUSES
5// an untrusted page. The guide's Capps page carries one button that asks the app for
6// a furnished Diamond, and this drives that. What is asserted:
7//
8// * the ask is answered with a dialog in APP chrome, not silently obeyed;
9// * saying yes leaves a Diamond called "Log Life" with the template's page in
10// it, and opens it;
11// * asking twice does not make a second one -- the entries are in the first;
12// * a message from anywhere but the guide frame is ignored;
13// * A NEWER SERVED TEMPLATE REACHES AN INSTANCE THAT HAS NOT BEEN TOUCHED, and
14// `log/` is byte-identical afterwards;
15// * A FILE THE USER HAS CHANGED IS LEFT ALONE while the rest updates, and they
16// are told ONCE;
17// * AN INSTANCE WITH NO DELIVERY RECORD IS NOT SILENTLY REWRITTEN -- it is asked
18// about, and "no" means no.
19//
20// The template is CODE, so it gets fixes; the lanes are DATA the page rewrites, so
21// they are the user's the moment he edits one. One rule covers both, and the rule is
22// a measurement: a stored file whose SHA-256 still equals the hash recorded at
23// delivery is one nobody has touched. See the "A capp, kept current" section of
24// `www/js/daimond.js` and §11 of `dev/CAPP_CONTRACT.md`.
25//
26// EACH CHECK IS PROVED AGAINST BROKEN CODE FIRST.
27//
28// node dev/verify_cappdelivery.mjs --break stale # 5 fails: the version is never compared
29// node dev/verify_cappdelivery.mjs --break clobber # 6 fails: the user's own file is overwritten
30// node dev/verify_cappdelivery.mjs --break pushy # 7 fails: "no" rewrites the page anyway
31// node dev/verify_cappdelivery.mjs --break logwritable # 5's log check fails: `log/` becomes writable
32// node dev/verify_cappdelivery.mjs # and then, clean
33//
34// `logwritable` is why the served manifest in this file NAMES a path under `log/`
35// and serves bytes for it. A guard nothing ever tries to cross is a guard that
36// cannot be seen to hold: without a template that asks to write the user's entries,
37// "the log is unchanged" would pass against an app with no log rule at all.
38//
39// It writes no path down: `harness.mjs` is imported relative to this file, and the
40// profile and screenshot go to the harness scratch root — never into `www/`.
41import fs from 'node:fs';
42import path from 'node:path';
43import { fileURLToPath } from 'node:url';
44import { open, connectMock, scratch } from './harness.mjs';
45
46const HERE = path.dirname(fileURLToPath(import.meta.url));
47const WWW = path.join(HERE, '..', 'www');
48const TPL = path.join(WWW, 'capps', 'lifelog');
49
50let failures = 0;
51const check = (cond, msg, detail) => {
52 console.log((cond ? ' ok ' : ' FAIL ') + msg + (detail != null ? ' — ' + detail : ''));
53 if (!cond) failures++;
54};
55
56const BREAK = (() => {
57 const i = process.argv.indexOf('--break');
58 return i > 0 ? String(process.argv[i + 1] || '') : '';
59})();
60
61// Every break is a line of the APP's own guard, replaced in the file the browser is
62// served. The anchor must appear exactly once or nothing was broken and the run
63// below would prove nothing.
64const BREAKS = {
65 // The served version is never compared, so no fix ever reaches an instance —
66 // which is the defect this whole path exists to close.
67 stale: {
68 file: 'js/daimond.js',
69 find: ' if (have >= man.v) return out; // current',
70 with: ' if (true) return out; // current',
71 },
72 // The hash comparison goes, so a file the user has edited is overwritten by the
73 // template. The silent-overwrite failure, which is the expensive direction.
74 clobber: {
75 file: 'js/daimond.js',
76 find: ' if (!had || !files[rel] || had !== files[rel]) {',
77 with: ' if (false) {',
78 },
79 // The answer to the legacy question is ignored, so an instance nothing is known
80 // about is rewritten whatever the person said.
81 pushy: {
82 file: 'js/daimond.js',
83 find: ' if (!go) { await writeCappRecord(id, { capp: key, offered: man.v }); return false; }',
84 with: ' if (!go) { await writeCappRecord(id, { capp: key, offered: man.v }); }',
85 },
86 // `log/` becomes an ordinary template path, so a manifest that names one can lay
87 // bytes over the user's entries.
88 logwritable: {
89 file: 'js/daimond.js',
90 find: " return p === 'capp.json' || /^log(\\/|$)/.test(p);",
91 with: " return p === 'capp.json';",
92 },
93};
94
95/// The real template on disk, which is what an unbroken run delivers at version 2.
96const real = (rel) => fs.readFileSync(path.join(TPL, rel), 'utf8');
97
98/// What the bundle SERVES, when this file wants it to be something else.
99///
100/// `null` lets every request through to the dev server, which is how the delivery
101/// half of this file runs against the shipped template. Setting it is how a new
102/// build is put in front of a running app without a reload: the app re-fetches the
103/// manifest on every open, deliberately and uncached, so that this is possible.
104let plan = null;
105
106/// The manifest's file list. It NAMES A PATH UNDER `log/`, which the app must
107/// refuse whatever else it does — see `logwritable` above.
108const MANIFEST = [
109 'crystal.html', 'index.json',
110 'lanes/diet.json', 'lanes/gym.json', 'lanes/body.json',
111 'cat/diet.json', 'cat/gym.json',
112 'log/gym/2026-08.jsonl',
113];
114
115const s = await open({
116 name: 'cappprobe',
117 profile: scratch('pw', 'cappprobe-' + process.pid),
118 route: async (page) => {
119 if (BREAK) {
120 const spec = BREAKS[BREAK];
121 if (!spec) { console.error('no such break: ' + BREAK); process.exit(2); }
122 const src = fs.readFileSync(path.join(WWW, spec.file), 'utf8');
123 const n = src.split(spec.find).length - 1;
124 if (n !== 1) {
125 console.error(`break '${BREAK}': the anchor appears ${n} times in ${spec.file}, `
126 + 'so nothing was broken and the run below would prove nothing.');
127 process.exit(2);
128 }
129 const body = src.replace(spec.find, spec.with);
130 await page.route('**/' + spec.file, (r) => r.fulfill({
131 status: 200, contentType: 'application/javascript', body,
132 }));
133 }
134 await page.route('**/capps/lifelog/**', async (r) => {
135 if (!plan) return r.continue();
136 const rel = new URL(r.request().url()).pathname.replace(/^.*\/capps\/lifelog\//, '');
137 if (rel === 'capp.json') {
138 return r.fulfill({
139 status: 200, contentType: 'application/json',
140 body: JSON.stringify({ v: plan.v, files: MANIFEST }),
141 });
142 }
143 if (plan.files[rel] != null) {
144 return r.fulfill({ status: 200, contentType: 'text/plain', body: plan.files[rel] });
145 }
146 return r.continue();
147 });
148 },
149});
150const p = s.page;
151
152/// The Diamonds as the store holds them, and what is inside one.
153const diamonds = (p) => p.evaluate(async () => {
154 const m = await import('/pkg/oxedyne_daimond.js');
155 const app = new m.DaimondApp('http://127.0.0.1/v1/chat/completions', '', 'none', 4096, '', true);
156 try { return JSON.parse(await app.list_diamonds()); } catch (e) { return []; }
157});
158
159/// One stored file of an instance, or `null`. The page comes through the app's own
160/// reader; everything else straight off the store, so what is asserted on is what is
161/// on disk rather than what the app believes it wrote.
162const stored = (id, rel) => p.evaluate(async ({ id, rel }) => {
163 const m = await import('/pkg/oxedyne_daimond.js');
164 if (rel === 'crystal.html') {
165 const app = new m.DaimondApp('http://127.0.0.1/v1/chat/completions', '', 'none', 4096, '', true);
166 try { return await app.read_crystal_page(id); } catch (e) { return null; }
167 }
168 try { return await m.store_read('diamonds/' + id + '/' + rel); } catch (e) { return null; }
169}, { id, rel });
170
171/// Write a file into an instance, as the user's page does through `save`.
172const put = (id, rel, text) => p.evaluate(async ({ id, rel, text }) => {
173 const m = await import('/pkg/oxedyne_daimond.js');
174 await m.store_write('diamonds/' + id + '/' + rel, text);
175 return true;
176}, { id, rel, text });
177
178/// EVERYTHING under `log/`, path → contents. The user's entries: the one thing here
179/// that cannot be re-derived from anything.
180const logTree = (id) => p.evaluate(async (id) => {
181 const m = await import('/pkg/oxedyne_daimond.js');
182 const out = {};
183 const walk = async (rel) => {
184 let lines = '';
185 const dir = ('diamonds/' + id + '/' + rel).replace(/\/$/, '');
186 try { lines = await m.store_list(dir); } catch (e) { return; }
187 for (const ln of String(lines).split('\n').filter(Boolean)) {
188 const bits = ln.split('\t');
189 if (bits[1] === 'dir') { await walk(rel + bits[0] + '/'); continue; }
190 try { out[rel + bits[0]] = await m.store_read('diamonds/' + id + '/' + rel + bits[0]); }
191 catch (e) { out[rel + bits[0]] = 'UNREADABLE'; }
192 }
193 };
194 await walk('log/');
195 return out;
196}, id);
197
198/// Put a file's TRUE hash into the delivery record, as though it had been
199/// delivered.
200///
201/// Used on a path under `log/`. It writes through the WASM DOOR, `store_write`, which
202/// is what the app itself uses -- not through the page's `save`, which cannot reach
203/// `capp.json` at all: `PAGE_NEVER_WRITES` covers it since 2026-08-17
204/// (`www/js/crystal.js:755`). This comment said the opposite until 2026-08-28, and
205/// justified the fixture by that gap; the fixture never depended on it, but a reader
206/// would have reasoned from a fence that had already been closed.
207///
208/// What it stands in for now is the shape that is still reachable: a corrupted or a
209/// half-migrated record, or one written by a build that predates the guard.
210///
211/// It exists because without it the log assertions pass for the WRONG REASON. A log
212/// file has no recorded hash, so the divergence rule already refuses to replace it
213/// and the `log/` guard is never the thing being tested — running `--break
214/// logwritable` against the first version of this file showed every log check still
215/// green. Claiming the file puts the path refusal on its own, which is the only way
216/// to see it hold.
217const claimInRecord = (id, rel) => p.evaluate(async ({ id, rel }) => {
218 const m = await import('/pkg/oxedyne_daimond.js');
219 const body = await m.store_read('diamonds/' + id + '/' + rel);
220 const rec = JSON.parse(await m.store_read('diamonds/' + id + '/capp.json'));
221 rec.files[rel] = await DaimondCloud.sha256(body);
222 await m.store_write('diamonds/' + id + '/capp.json', JSON.stringify(rec));
223 return rec.files[rel];
224}, { id, rel });
225
226/// The delivery record beside an instance, parsed.
227const record = (id) => p.evaluate(async (id) => {
228 const m = await import('/pkg/oxedyne_daimond.js');
229 let t = '';
230 try { t = await m.store_read('diamonds/' + id + '/capp.json'); } catch (e) { return null; }
231 try { return t ? JSON.parse(t) : null; } catch (e) { return null; }
232}, id);
233
234/// Answer whichever confirm box is on screen.
235const answer = async (p, yes) => {
236 await p.waitForSelector('.dlg-card', { timeout: 8000 });
237 const said = await p.evaluate(() => {
238 const c = [...document.querySelectorAll('.dlg-card')].filter(x => x.getClientRects().length).pop();
239 return c ? (c.textContent || '').replace(/\s+/g, ' ').trim().slice(0, 240) : '';
240 });
241 await p.evaluate((y) => {
242 const c = [...document.querySelectorAll('.dlg-card')].filter(x => x.getClientRects().length).pop();
243 const b = c.querySelector(y ? '.dlg-ok' : '.dlg-cancel') || c.querySelector('.dlg-ok');
244 b.click();
245 }, yes);
246 await p.waitForTimeout(1500);
247 return said;
248};
249
250/// Press the guide's own button, inside the guide frame.
251const pressInGuide = async (p) => {
252 const f = p.frames().find(fr => /guide\/capps\.html/.test(fr.url()));
253 if (!f) throw new Error('the guide frame is not showing capps.html');
254 await f.click('#make-lifelog');
255};
256
257/// OPEN THE CAPP, the way a person does: off its face and back onto it. Both
258/// buttons go through `selectDiamond`, which is the one path to `renderCrystal` —
259/// so this is the real open and not a poke at an internal.
260const reopen = async () => {
261 await p.evaluate(() => { const b = document.getElementById('dview-chat'); if (b) b.click(); });
262 await p.waitForTimeout(500);
263 await p.evaluate(() => { const b = document.getElementById('dview-crystal'); if (b) b.click(); });
264 await p.waitForTimeout(1800);
265};
266
267/// The one quiet line that says which files were left as the user has them.
268const noteText = () => p.evaluate(() => {
269 const els = [...document.querySelectorAll('#capp-note, .capp-note')];
270 return { n: els.length, text: els.length ? (els[0].textContent || '').trim() : '' };
271});
272
273try {
274 await connectMock(s);
275 await p.evaluate(() => DaimondWeb.guide('capps.html'));
276 await p.waitForTimeout(2500);
277 const framed = p.frames().some(f => /guide\/capps\.html/.test(f.url()));
278 check(framed, 'the guide is showing its Capps page');
279
280 // ── A stranger's message is not an instruction.
281 await p.evaluate(() => window.postMessage({ daimondGuide: 'make', what: 'lifelog' }, '*'));
282 await p.waitForTimeout(900);
283 check(!(await p.$('.dlg-card')), 'a message from the page itself is ignored');
284
285 // ── Refused.
286 await pressInGuide(p);
287 const said = await answer(p, false);
288 console.log(' dialog: ' + said);
289 check(/Log Life/.test(said), 'the ask is answered with a dialog naming what it will make');
290 check(!(await diamonds(p)).some(d => d.name === 'Log Life'), 'saying no makes nothing');
291
292 // ── Accepted.
293 await pressInGuide(p);
294 await answer(p, true);
295 const made = (await diamonds(p)).filter(d => d.name === 'Log Life');
296 check(made.length === 1, 'saying yes makes exactly one Log Life', made.length + ' found');
297
298 const id = made.length ? made[0].id : '';
299 if (id) {
300 const inside = await p.evaluate(async (id) => {
301 const m = await import('/pkg/oxedyne_daimond.js');
302 const app = new m.DaimondApp('http://127.0.0.1/v1/chat/completions', '', 'none', 4096, '', true);
303 const out = {};
304 try { out.page = (await app.read_crystal_page(id) || '').length; } catch (e) { out.page = 'threw: ' + e; }
305 try { out.data = (await app.read_crystal_data(id) || '').length; } catch (e) { out.data = 'threw: ' + e; }
306 return out;
307 }, id);
308 console.log(' inside: ' + JSON.stringify(inside));
309 check(typeof inside.page === 'number' && inside.page > 500, 'with the template page in it', inside.page);
310 check(typeof inside.data === 'number' && inside.data > 0,
311 'AND A CRYSTAL, without which the face never mounts the page', inside.data);
312 }
313
314 // It is on screen, which is what "delivered" means.
315 const showing = await p.evaluate(() => ({
316 name: (document.getElementById('current-session-name') || {}).textContent || '',
317 frame: !!document.querySelector('#crystal-frame-wrap'),
318 }));
319 console.log(' showing: ' + JSON.stringify(showing));
320 check(/Log Life/.test(showing.name), 'and it is the Diamond on screen');
321 check(showing.frame, 'with its page mounted');
322 await p.waitForTimeout(1200);
323 await p.screenshot({ path: scratch('capp-made.png') });
324
325 // ── Asked again: the first one, not a second.
326 await p.evaluate(() => DaimondWeb.guide('capps.html'));
327 await p.waitForTimeout(2000);
328 await pressInGuide(p);
329 const again = await answer(p, true);
330 console.log(' second dialog: ' + again);
331 check(/already have/i.test(again), 'the second ask offers to OPEN the one that exists');
332 check((await diamonds(p)).filter(d => d.name === 'Log Life').length === 1,
333 'and there is still exactly one');
334
335 // ══ 4. The delivery left a record of exactly what went in ══════════
336 //
337 // Without it nothing later can tell the template's bytes from the user's, and
338 // every update afterwards would have to guess or ask.
339 const rec0 = id ? await record(id) : null;
340 console.log(' record: ' + JSON.stringify(rec0 && { capp: rec0.capp, v: rec0.v, files: Object.keys(rec0.files || {}) }));
341 check(!!rec0 && rec0.capp === 'lifelog', 'delivery writes a record naming the template');
342 check(!!rec0 && rec0.v === JSON.parse(real('capp.json')).v,
343 'at the version the bundle serves', rec0 ? rec0.v : 'no record');
344 check(!!rec0 && !!(rec0.files || {})['crystal.html'] && !!(rec0.files || {})['lanes/gym.json'],
345 'with a hash per delivered file');
346 check(!!rec0 && !(rec0.files || {})['crystal.json'],
347 'and NOT for the seeded crystal, which came from no served file');
348
349 // ══ 5. A newer template reaches an instance nobody has touched ═════
350 //
351 // And the user's entries do not move. Both halves matter: an update that also
352 // took the log would be a worse defect than the one this closes.
353 await put(id, 'log/gym/2026-08.jsonl', '{"t":"2026-08-01","lift":"squat","kg":100}\n');
354 await put(id, 'log/diet/2026-08.jsonl', '{"t":"2026-08-01","food":"porridge"}\n');
355 const logBefore = await logTree(id);
356 console.log(' log before: ' + JSON.stringify(Object.keys(logBefore)));
357 // The record now CLAIMS one of the user's log files as delivered bytes, so the
358 // only thing standing between the template and a year of entries is the `log/`
359 // refusal itself. See `claimInRecord`.
360 const claimed = await claimInRecord(id, 'log/gym/2026-08.jsonl');
361 check(/^[0-9a-f]{64}$/.test(String(claimed)),
362 'the record is made to claim a log file, so the path guard stands alone', claimed);
363
364 const GYM3 = JSON.stringify(Object.assign(JSON.parse(real('lanes/gym.json')), { v3: true }));
365 plan = {
366 v: 3,
367 files: {
368 'crystal.html': real('crystal.html') + '\n<!-- delivered v3 -->\n',
369 'lanes/gym.json': GYM3,
370 // A template that asks to write the user's entries. It must not be able to.
371 'log/gym/2026-08.jsonl': '{"t":"1999-01-01","lift":"OVERWRITTEN"}\n',
372 },
373 };
374 await reopen();
375
376 const page3 = await stored(id, 'crystal.html');
377 check(/<!-- delivered v3 -->/.test(String(page3 || '')),
378 'a newer served version replaces the page of an untouched instance');
379 check(String(await stored(id, 'lanes/gym.json')) === GYM3,
380 'and its seeded data, which nobody had edited');
381 const rec3 = await record(id);
382 check(!!rec3 && rec3.v === 3, 'and the stored version moves with it', rec3 ? rec3.v : 'no record');
383 const logAfter = await logTree(id);
384 check(JSON.stringify(logAfter) === JSON.stringify(logBefore),
385 'THE LOG IS BYTE-IDENTICAL, though the manifest named a path inside it',
386 JSON.stringify(logAfter));
387 const n3 = await noteText();
388 check(n3.n === 0, 'and nothing was said, because nothing was left behind', n3.text);
389
390 // ══ 6. A file the user has changed is left alone, and said so ONCE ══
391 const MINE = JSON.stringify({ id: 'diet', mine: true, note: 'the user edited this' });
392 await put(id, 'lanes/diet.json', MINE);
393 const GYM4 = JSON.stringify(Object.assign(JSON.parse(real('lanes/gym.json')), { v4: true }));
394 plan = {
395 v: 4,
396 files: {
397 'crystal.html': real('crystal.html') + '\n<!-- delivered v4 -->\n',
398 'lanes/gym.json': GYM4,
399 'lanes/diet.json': JSON.stringify({ id: 'diet', fromTemplate: 'v4' }),
400 'log/gym/2026-08.jsonl': '{"t":"1999-01-01","lift":"OVERWRITTEN"}\n',
401 },
402 };
403 await reopen();
404
405 check(String(await stored(id, 'lanes/diet.json')) === MINE,
406 'A FILE THE USER CHANGED IS LEFT EXACTLY AS IT IS');
407 check(String(await stored(id, 'lanes/gym.json')) === GYM4,
408 'while the files nobody touched still update');
409 check(/<!-- delivered v4 -->/.test(String(await stored(id, 'crystal.html'))),
410 'including the page, which is code and not data');
411 const n4 = await noteText();
412 console.log(' note: ' + n4.text);
413 check(n4.n === 1, 'and the person is told ONCE, not once per file', n4.n + ' notes');
414 check(/lanes\/diet\.json/.test(n4.text), 'naming what was kept', n4.text);
415 const rec4 = await record(id);
416 check(!!rec4 && rec4.v === 4, 'the version moves even though a file did not', rec4 ? rec4.v : 'no record');
417 check(JSON.stringify(await logTree(id)) === JSON.stringify(logBefore), 'and the log is still untouched');
418
419 // ══ 7. An instance with NO record is not silently rewritten ════════
420 //
421 // The owner's own Log Life is this case: made before capps carried a version,
422 // so nothing is known about what was delivered and no file can be shown to be
423 // ours. The only honest move is to ask.
424 const LEGACY = real('crystal.html') + '\n<!-- the user\'s own page -->\n';
425 await p.evaluate(async ({ id, page }) => {
426 const m = await import('/pkg/oxedyne_daimond.js');
427 const app = new m.DaimondApp('http://127.0.0.1/v1/chat/completions', '', 'none', 4096, '', true);
428 await app.write_crystal_page(id, page);
429 // No record at all, which is what an instance made before this existed has.
430 try {
431 let dir = await DaimondCloud.opfsRoot();
432 for (const seg of ['diamonds', id]) dir = await dir.getDirectoryHandle(seg);
433 await dir.removeEntry('capp.json');
434 } catch (e) { await m.store_write('diamonds/' + id + '/capp.json', ''); }
435 }, { id, page: LEGACY });
436 check(!(await record(id)), 'the instance now has no delivery record');
437
438 plan = { v: 5, files: { 'crystal.html': real('crystal.html') + '\n<!-- delivered v5 -->\n' } };
439 await reopen();
440 const asked = await p.$('.dlg-card');
441 check(!!asked, 'a record-less instance is ASKED about rather than updated');
442 check(String(await stored(id, 'crystal.html')) === LEGACY,
443 'and nothing has been written while the question is on screen');
444 const legacySaid = asked ? await answer(p, false) : '';
445 console.log(' legacy dialog: ' + legacySaid);
446 check(/before capps carried a version/i.test(legacySaid),
447 'the question says why it is being asked', legacySaid);
448 check(String(await stored(id, 'crystal.html')) === LEGACY,
449 'SAYING NO LEAVES THE PAGE EXACTLY AS IT WAS');
450 const rec5 = await record(id);
451 check(!!rec5 && rec5.offered === 5 && !rec5.files,
452 'the refusal is remembered, at the version it was offered at', JSON.stringify(rec5));
453
454 await reopen();
455 check(!(await p.$('.dlg-card')), 'and it is not asked again at the same version');
456 check(String(await stored(id, 'crystal.html')) === LEGACY, 'nor rewritten behind the refusal');
457
458 // Taken, this time: the page moves, everything else stays, and the instance
459 // joins the automatic path.
460 plan = { v: 6, files: { 'crystal.html': real('crystal.html') + '\n<!-- delivered v6 -->\n' } };
461 await reopen();
462 const asked6 = await p.$('.dlg-card');
463 check(!!asked6, 'something newer asks again');
464 if (asked6) await answer(p, true);
465 check(/<!-- delivered v6 -->/.test(String(await stored(id, 'crystal.html'))),
466 'and saying yes brings the page up to the current one');
467 check(String(await stored(id, 'lanes/diet.json')) === MINE,
468 'while the lanes it knows nothing about are left alone');
469 check(JSON.stringify(await logTree(id)) === JSON.stringify(logBefore),
470 'and so are the entries');
471 const rec6 = await record(id);
472 check(!!rec6 && rec6.v === 6 && !!(rec6.files || {})['crystal.html']
473 && !(rec6.files || {})['lanes/diet.json'],
474 'a record is written claiming the PAGE only, so no later version overwrites a lane',
475 JSON.stringify(rec6 && { v: rec6.v, files: Object.keys(rec6.files || {}) }));
476 await p.screenshot({ path: scratch('capp-updated.png') });
477} catch (e) {
478 console.log(' FAIL threw — ' + (e && e.message));
479 failures++;
480} finally {
481 const errs = s.errs.filter(e => !/favicon|manifest|502|Bad Gateway|gateway/i.test(e));
482 if (errs.length) console.log(' console errors: ' + errs.slice(0, 6).join(' | '));
483 await s.close();
484}
485console.log(failures ? failures + ' failure(s)' : 'all checks passed');
486process.exit(failures ? 1 : 0);