Oregami
Repositories/oxedyne/daimond

oxedyne/daimond/dev/verify_granthand.mjs

26.7 KiB, 1 run

created by r2519314175:445, 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_granthand.mjs — the Daimond Hands consent window, rendered.
2//
3// The window was too long to read at the moment it matters, so it now has two
4// screens: what a person needs IN ORDER TO DECIDE on the surface, and
5// everything else behind one disclosure. That change can fail in two opposite
6// directions, and both are checked here.
7//
8// Too long still — the first screen is a wall, or the two buttons are pushed
9// below the fold in the longest language, which is the same failure the split
10// was meant to end.
11//
12// Too short — a sentence was DELETED rather than moved. So every string the
13// window said before is looked for in the DOM, wherever it now sits, and the
14// ones that carry the decision (that programs run as you with your files, how
15// far they reach, which folder, which page asked, and that this is the
16// strongest thing Daimond can be allowed) are looked for on the FIRST screen
17// specifically.
18//
19// Also: the mark, so the window's provenance is visible before its words are
20// read; the contrast floors, 4.5:1 for text and 3:1 for the edge of anything
21// clickable; and the keyboard, because a window that can only be answered with
22// a mouse cannot be answered by everyone.
23//
24// node dev/verify_granthand.mjs the checks
25// node dev/verify_granthand.mjs --prove each check, against broken output
26//
27// `--prove` is the point of the file. A check that has never failed is a check
28// nobody has tested, so every one of them is run a second time against markup
29// or CSS deliberately broken in the one way that check exists to catch, and the
30// run fails unless the check FAILS there.
31//
32// Needs nothing running: it serves ext/ itself and stubs the two chrome APIs
33// i18n.js reaches for, so the strings come from _locales exactly as they do in
34// the browser.
35import fs from 'node:fs';
36import os from 'node:os';
37import http from 'node:http';
38import path from 'node:path';
39import { pathToFileURL } from 'node:url';
40
41const PW = path.join(os.homedir(), '.red-pw/node_modules/playwright-core/index.mjs');
42const { chromium } = await import(pathToFileURL(PW).href);
43const CHROME = `${process.env.HOME}/.cache/ms-playwright/chromium-1229/chrome-linux64/chrome`;
44import { fileURLToPath } from 'node:url';
45const ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), '..'); // this checkout, not one developer's home
46const EXT = `${ROOT}/ext`;
47const OUT = path.join(os.homedir(), '.cache/daimond/grant-shots');
48fs.mkdirSync(OUT, { recursive: true });
49
50const PROVE = process.argv.includes('--prove');
51const PORT = Number(process.env.GRANT_PORT || 9187);
52const BASE = `http://127.0.0.1:${PORT}`;
53/// The viewport the window has AFTER grant.js has sized it to its own first
54/// screen -- 520 is what the wordiest language asks for. background.js opens at
55/// a guess; `fit()` corrects it, and that correction is checked separately at a
56/// height too small for French.
57const VH = Number(process.env.GRANT_VH || 520);
58
59/// App locale code -> Chrome `_locales` directory, as i18n.js has it.
60const DIRS = {
61 'en': 'en', 'de': 'de', 'es': 'es', 'fr': 'fr',
62 'ja': 'ja', 'ko': 'ko', 'pt-BR': 'pt_BR', 'zh-Hans': 'zh_CN',
63};
64const MSG = {};
65for (const [code, dir] of Object.entries(DIRS)) {
66 MSG[code] = JSON.parse(fs.readFileSync(`${EXT}/_locales/${dir}/messages.json`, 'utf8'));
67}
68const m = (code, key) => MSG[code][key].message;
69
70/// A hand that fences, keeps a journal, and names its folder — what the window
71/// is drawn for on a working Linux machine.
72const CAPS = 'fence:linux landlock:abi-8 seccomp journal root:/home/u/work ws:8f3a1c home:/home/u';
73const ORIGIN = 'https://daimond.oxedyne.com';
74
75// ── The server ───────────────────────────────────────────────────────
76//
77// ext/ as it sits on disk, plus whatever a prove-run wants broken. A patch is
78// applied to the BYTES on the way out, so the source tree is never touched.
79let patch = null;
80const TYPES = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8',
81 '.json': 'application/json; charset=utf-8', '.svg': 'image/svg+xml' };
82const srv = http.createServer((req, res) => {
83 const rel = decodeURIComponent(req.url.split('?')[0]).replace(/^\/+/, '');
84 const file = path.join(EXT, rel);
85 if (!file.startsWith(EXT) || !fs.existsSync(file) || fs.statSync(file).isDirectory()) {
86 res.writeHead(404); res.end('no'); return;
87 }
88 let body = fs.readFileSync(file);
89 if (patch) body = Buffer.from(patch(rel, body.toString('utf8')) ?? body.toString('utf8'));
90 res.writeHead(200, { 'content-type': TYPES[path.extname(file)] || 'application/octet-stream' });
91 res.end(body);
92});
93await new Promise((r) => srv.listen(PORT, '127.0.0.1', r));
94
95const browser = await chromium.launch({ executablePath: CHROME });
96
97/// Open the window, in one language and one palette, with the chrome APIs
98/// i18n.js needs stubbed the way the browser would answer them.
99async function open(opts) {
100 const { kind = 'hand', code = 'en', scheme = 'light', caps = CAPS,
101 width = 480, height = VH } = opts || {};
102 const ctx = await browser.newContext({ colorScheme: scheme, viewport: { width, height } });
103 const page = await ctx.newPage();
104 await page.addInitScript(({ code, base }) => {
105 globalThis.chrome = {
106 runtime: {
107 getURL: (p) => base + '/' + p,
108 sendMessage: (msgObj, cb) => { globalThis.__answered = msgObj; if (cb) cb(); },
109 },
110 storage: { local: { get: async () => ({ locale: code }) } },
111 i18n: { getMessage: () => '' },
112 permissions: { request: async () => true },
113 // The window sizes itself to its first screen; record what it asks
114 // for. `height` here stands for the outer window, frame included,
115 // which is how chrome.windows reports it.
116 windows: {
117 getCurrent: (cb) => cb({ id: 1, height: innerHeight + 40 }),
118 update: (id, o) => { globalThis.__resized = o; },
119 },
120 };
121 globalThis.close = () => { globalThis.__closed = true; };
122 // A headless screen is exactly the viewport, so the cap grant.js puts on
123 // how tall a window may grow would bite at every size and hide the very
124 // behaviour being measured. Give it a real display to work against.
125 try { Object.defineProperty(screen, 'availHeight', { value: 1080, configurable: true }); }
126 catch (e) { /* whatever the runtime allows */ }
127 }, { code, base: BASE });
128 const q = new URLSearchParams({ nonce: 'probe', kind, origin: ORIGIN, caps,
129 host: 'example.com', pattern: '*://*.example.com/*' });
130 await page.goto(`${BASE}/grant.html?${q}`, { waitUntil: 'networkidle' });
131 await page.waitForTimeout(200);
132 return { ctx, page };
133}
134
135// ── Measuring ────────────────────────────────────────────────────────
136//
137// Run in the page: what is visible above the fold, what the DOM holds anywhere,
138// where the buttons are, and every colour actually painted.
139const READ = () => {
140 const vis = (el) => {
141 const r = el.getBoundingClientRect();
142 if (r.width < 1 || r.height < 1) return false;
143 for (let n = el; n; n = n.parentElement) {
144 if (n.hidden) return false;
145 if (n.tagName === 'DETAILS' && !n.open && !el.closest('summary')) return false;
146 }
147 return true;
148 };
149 /// Any computed colour as [r, g, b, a] in 0..255.
150 ///
151 /// `color-mix()` with a system colour computes to `color(srgb 0.22 …)`,
152 /// whose components are 0..1 — read as 0..255 they make every mixed colour
153 /// near-black and every contrast ratio a lie. This is the bug that made the
154 /// first run of this file report 1.01:1 for black text on white.
155 const rgb = (s) => {
156 const n = (s.match(/-?[\d.]+(?:e-?\d+)?/g) || []).map(Number);
157 const k = /^color\(/.test(s) ? 255 : 1;
158 return [n[0] * k, n[1] * k, n[2] * k, n.length > 3 ? n[3] : 1];
159 };
160 /// What the page paints where nothing else does.
161 const canvas = (() => {
162 const d = document.createElement('div');
163 d.style.background = 'Canvas';
164 document.body.appendChild(d);
165 const c = rgb(getComputedStyle(d).backgroundColor);
166 d.remove();
167 return c.slice(0, 3);
168 })();
169 /// The painted background behind an element: the first ancestor that has one.
170 const bgOf = (el) => {
171 for (let n = el; n; n = n.parentElement) {
172 const c = rgb(getComputedStyle(n).backgroundColor);
173 if (c[3] > 0) return c.slice(0, 3);
174 }
175 return canvas;
176 };
177 const over = (fg, bg) => fg.slice(0, 3).map((v, i) => v * fg[3] + bg[i] * (1 - fg[3]));
178 const lum = (c) => {
179 const f = c.map((v) => { const s = v / 255; return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4); });
180 return 0.2126 * f[0] + 0.7152 * f[1] + 0.0722 * f[2];
181 };
182 const ratio = (a, b) => { const [x, y] = [lum(a), lum(b)].sort((p, q) => q - p); return (x + 0.05) / (y + 0.05); };
183
184 const out = { firstScreen: '', allText: document.body.innerText, dom: '', problems: [],
185 buttons: [], mark: null, tabbable: [], detailsOpen: false, scrollH: 0, clientH: 0,
186 sheetFits: false };
187 out.detailsOpen = !!document.querySelector('details').open;
188 out.scrollH = document.documentElement.scrollHeight;
189 out.clientH = document.documentElement.clientHeight;
190
191 // Everything the DOM holds, whether shown or not.
192 out.dom = [...document.querySelectorAll('h1, p, div, summary, button')]
193 .map((n) => n.textContent).join('\n');
194
195 // What a person reads before touching anything.
196 //
197 // WHOLLY visible, and clipped by the SHEET rather than by the window: the
198 // sheet scrolls inside itself, so a line an inch below its bottom edge is as
199 // unread as one below the window's. Measuring against innerHeight alone
200 // reported a first screen that a screenshot showed cut in half.
201 const sheet = document.querySelector('.sheet');
202 const sr = sheet.getBoundingClientRect();
203 out.sheetFits = sheet.scrollHeight <= sheet.clientHeight + 1;
204 out.sheetNeeds = sheet.scrollHeight;
205 out.sheetHas = sheet.clientHeight;
206 const seen = [];
207 for (const el of document.querySelectorAll('h1, p, div.host, div.val, summary, button')) {
208 if (!vis(el)) continue;
209 const r = el.getBoundingClientRect();
210 const lo = sheet.contains(el) ? Math.max(0, sr.top) : 0;
211 const hi = sheet.contains(el) ? Math.min(sr.bottom, innerHeight) : innerHeight;
212 if (r.top < lo - 1 || r.bottom > hi + 1) continue;
213 const own = [...el.childNodes].filter((n) => n.nodeType === 3).map((n) => n.textContent).join('').trim();
214 if (own) seen.push(own);
215 }
216 out.firstScreen = seen.join('\n');
217
218 // The two buttons, and whether they are on screen at all.
219 for (const b of document.querySelectorAll('.row button')) {
220 const r = b.getBoundingClientRect();
221 out.buttons.push({ id: b.id, top: r.top, bottom: r.bottom, inView: r.top >= 0 && r.bottom <= innerHeight + 0.5 });
222 }
223
224 // The mark: it must have actually loaded, not merely be referenced.
225 const img = document.querySelector('.brand img');
226 out.mark = img ? { src: img.getAttribute('src'), w: img.naturalWidth, h: img.naturalHeight } : null;
227
228 // Focus order, by walking it the way a keyboard does. `tabindex="-1"` takes a
229 // node OUT of that order however focusable its tag normally is, so it is
230 // filtered after the selector rather than in it -- a `summary` term matches
231 // a summary that has been taken out, which is precisely the mistake this
232 // check exists to catch.
233 out.tabbable = [...document.querySelectorAll('button, summary, a[href], [tabindex]')]
234 .filter((n) => n.getAttribute('tabindex') !== '-1' && !n.disabled)
235 .filter(vis).map((n) => n.id || n.tagName.toLowerCase());
236
237 // Contrast. Text against what is painted behind it, and the border of
238 // anything clickable against the same.
239 for (const el of document.querySelectorAll('h1, p, div.host, div.val, summary, button, b')) {
240 if (!vis(el)) continue;
241 const own = [...el.childNodes].filter((n) => n.nodeType === 3).map((n) => n.textContent).join('').trim();
242 if (!own) continue;
243 const cs = getComputedStyle(el);
244 const bg = bgOf(el.parentElement || el);
245 const fg = over(rgb(cs.color), bgOf(el));
246 const r = ratio(fg, bgOf(el));
247 if (r < 4.5) out.problems.push(`text ${r.toFixed(2)}:1 — ${JSON.stringify(own.slice(0, 40))}`);
248 void bg;
249 }
250 for (const el of document.querySelectorAll('button, div.host, div.val')) {
251 if (!vis(el)) continue;
252 const cs = getComputedStyle(el);
253 if (cs.borderTopStyle === 'none' || parseFloat(cs.borderTopWidth) < 0.5) continue;
254 const edge = over(rgb(cs.borderTopColor), bgOf(el.parentElement || el));
255 const r = ratio(edge, bgOf(el.parentElement || el));
256 if (r < 3) out.problems.push(`edge ${r.toFixed(2)}:1 — <${el.tagName.toLowerCase()}${el.id ? '#' + el.id : ''}>`);
257 }
258 return out;
259};
260
261// ── The checks ───────────────────────────────────────────────────────
262//
263// Each one is a named function of the measurement, returning [pass, detail], so
264// the same function can be run against sound output and against broken output.
265
266/// Sentences that must be readable BEFORE the disclosure is opened. Each is
267/// here because a person cannot answer the question without it.
268const MUST_SURFACE = (code) => [
269 ['what is being asked', m(code, 'grant_hand_head')],
270 ['how far it reaches', m(code, 'grant_hand_lead')],
271 ['how much it weighs', m(code, 'grant_hand_strongest')],
272 ['which page asked', ORIGIN],
273 ['which folder', '/home/u/work'],
274 ['the way out', m(code, 'grant_deny')],
275 ['the way in', m(code, 'grant_hand_allow')],
276];
277
278/// Everything the window said before the split. None of it may be gone; it may
279/// only have moved.
280const MUST_KEEP = (code) => [
281 m(code, 'grant_hand_body'),
282 m(code, 'grant_hand_fine'),
283 m(code, 'grant_hand_strongest'),
284 m(code, 'grant_hand_head'),
285 m(code, 'grant_hand_allow'),
286 m(code, 'grant_deny'),
287];
288
289const CHECKS = {
290 'the mark is there, and really loaded':
291 (r) => [!!r.mark && r.mark.w > 0 && r.mark.h > 0,
292 JSON.stringify(r.mark)],
293
294 'the first screen carries every fact the decision needs':
295 (r, code) => {
296 const miss = MUST_SURFACE(code).filter(([, s]) => !r.firstScreen.includes(s));
297 return [miss.length === 0, miss.map(([w]) => w).join(', ')];
298 },
299
300 'nothing that was said has been dropped, only moved':
301 (r, code) => {
302 const gone = MUST_KEEP(code).filter((s) => !r.dom.includes(s));
303 return [gone.length === 0, gone.map((s) => JSON.stringify(s.slice(0, 40))).join(', ')];
304 },
305
306 'the long prose is behind the disclosure, not on the first screen':
307 (r, code) => {
308 const leaked = [m(code, 'grant_hand_body'), m(code, 'grant_hand_fine')]
309 .filter((s) => r.firstScreen.includes(s));
310 return [leaked.length === 0, `${leaked.length} leaked`];
311 },
312
313 // A guard against creep rather than a design target: the real constraint is
314 // the one below, which is measured rather than counted. 85 is French, the
315 // wordiest of the eight, plus a little.
316 'the first screen is short enough to read':
317 (r) => {
318 const n = r.firstScreen.replace(/\s+/g, ' ').trim().split(' ').length;
319 return [n <= 85, `${n} words`];
320 },
321
322 // The sharper form of the same thing: it does not merely fit a word budget,
323 // it fits the window, so nothing on it has to be scrolled to.
324 'the first screen fits the window without scrolling':
325 (r) => [r.sheetFits, `needs ${r.sheetNeeds}px, has ${r.sheetHas}px`],
326
327 // The document itself must never scroll: only the sheet between the brand
328 // and the buttons does. Without that clause the check is toothless, because
329 // focusing the cautious button scrolls it into view and the buttons look
330 // fine while the question they answer has gone off the top.
331 'both buttons are on screen, and the window itself never scrolls':
332 (r) => [r.buttons.length === 2 && r.buttons.every((b) => b.inView)
333 && r.scrollH <= r.clientH + 1,
334 `${JSON.stringify(r.buttons)} scroll ${r.scrollH}/${r.clientH}`],
335
336 'the disclosure is in the keyboard\'s path, before the buttons':
337 (r) => {
338 const i = r.tabbable.indexOf('more-sum'), d = r.tabbable.indexOf('deny');
339 return [i >= 0 && d > i, r.tabbable.join(' > ')];
340 },
341
342 'every visible run clears the contrast floors':
343 (r) => [r.problems.length === 0, r.problems.join('; ')],
344};
345
346// ── Running them ─────────────────────────────────────────────────────
347let bad = 0, ran = 0;
348const say = (ok, what, detail) => {
349 ran++;
350 if (!ok) bad++;
351 console.log(`${ok ? 'PASS' : 'FAIL'} ${what}${detail ? ' — ' + detail : ''}`);
352};
353
354async function measure(opts) {
355 const { ctx, page } = await open(opts);
356 const r = await page.evaluate(READ);
357 return { r, ctx, page };
358}
359
360if (!PROVE) {
361 // The window as it ships: both palettes, the narrow height a popup really
362 // gets, and every language — German and Japanese are the ones that overrun.
363 for (const scheme of ['light', 'dark']) {
364 for (const code of Object.keys(DIRS)) {
365 const { r, ctx, page } = await measure({ code, scheme });
366 for (const [name, fn] of Object.entries(CHECKS)) {
367 const [ok, detail] = fn(r, code);
368 say(ok, `${scheme} ${code}: ${name}`, ok ? '' : detail);
369 }
370 if (['en', 'de', 'fr', 'ja'].includes(code)) {
371 await page.screenshot({ path: path.join(OUT, `hand-${scheme}-${code}-closed.png`) });
372 // Opened with the keyboard, which is also the check that the
373 // keyboard can open it.
374 await page.focus('#more-sum');
375 await page.keyboard.press('Enter');
376 await page.waitForTimeout(120);
377 const open2 = await page.evaluate(READ);
378 say(open2.detailsOpen, `${scheme} ${code}: Enter on the disclosure opens it`);
379 say(open2.buttons.every((b) => b.inView) && open2.scrollH <= open2.clientH + 1,
380 `${scheme} ${code}: the buttons stay on screen with it open`,
381 `${JSON.stringify(open2.buttons)} scroll ${open2.scrollH}/${open2.clientH}`);
382 say(open2.problems.length === 0,
383 `${scheme} ${code}: the detail clears the contrast floors too`, open2.problems.join('; '));
384 await page.screenshot({ path: path.join(OUT, `hand-${scheme}-${code}-open.png`) });
385 }
386 await ctx.close();
387 }
388 }
389
390 // The other two questions still work, and the mirror one still names no site.
391 for (const kind of ['site', 'mirror']) {
392 const { r, ctx, page } = await measure({ kind });
393 say(r.buttons.every((b) => b.inView), `${kind}: both buttons are on screen`, JSON.stringify(r.buttons));
394 say(r.problems.length === 0, `${kind}: contrast floors`, r.problems.join('; '));
395 say(r.dom.includes(m('en', kind === 'site' ? 'grant_site_fine' : 'grant_mirror_fine')),
396 `${kind}: the fine print is still in the window`);
397 if (kind === 'mirror') {
398 const named = await page.evaluate(() => !document.getElementById('host').hidden);
399 say(!named, 'mirror: the live-view question still names no site');
400 }
401 await page.screenshot({ path: path.join(OUT, `${kind}-light-en.png`) });
402 await ctx.close();
403 }
404
405 // A narrow window, because a popup can be dragged narrow and the strings do
406 // not get shorter.
407 {
408 const { r, ctx } = await measure({ width: 360 });
409 say(r.buttons.every((b) => b.inView), '360px wide: both buttons are on screen', JSON.stringify(r.buttons));
410 say(r.problems.length === 0, '360px wide: contrast floors', r.problems.join('; '));
411 await ctx.close();
412 }
413
414 // The window sizing itself. At a height French does not fit in, it must ASK
415 // for more -- otherwise the whole first-screen promise rests on a constant in
416 // background.js being right about a frame nobody measured.
417 {
418 const { ctx, page } = await open({ code: 'fr', height: 420 });
419 const asked = await page.evaluate(() => globalThis.__resized || null);
420 say(!!asked && asked.height > 460, 'a first screen that does not fit asks for a taller window',
421 JSON.stringify(asked));
422 await ctx.close();
423 }
424 {
425 const { ctx, page } = await open({ code: 'zh-Hans', height: 700 });
426 const asked = await page.evaluate(() => globalThis.__resized || null);
427 say(!!asked && asked.height < 700, 'and one with room to spare gives it back',
428 JSON.stringify(asked));
429 await ctx.close();
430 }
431
432 // A short window, which is what a small screen leaves after grant.js has
433 // asked for all the height there is. The first screen has to give way here
434 // -- but it gives way by SCROLLING THE SHEET, with the brand still naming
435 // who is asking and both buttons still on screen. Degrading any other way
436 // would put the answer out of reach on exactly the machines least able to
437 // spare the room.
438 {
439 const { r, ctx } = await measure({ code: 'fr', height: 360 });
440 say(r.buttons.every((b) => b.inView) && r.scrollH <= r.clientH + 1,
441 '360px tall: the window still does not scroll and both buttons are on screen',
442 `${JSON.stringify(r.buttons)} scroll ${r.scrollH}/${r.clientH}`);
443 say(r.firstScreen.includes(m('fr', 'grant_hand_head')),
444 '360px tall: the question itself is still the first thing shown');
445 await ctx.close();
446 }
447
448 // Release gate 1 of hand/README.md, which dev/verify_hand.mjs reads off the
449 // same three elements: the wording is chosen from `caps`, so it can only
450 // claim what THIS machine enforces. Moving those three behind the disclosure
451 // must not have moved the gate.
452 {
453 const say3 = async (caps) => {
454 const { ctx, page } = await open({ caps });
455 const got = await page.evaluate(() => ({
456 body: (document.getElementById('body') || {}).textContent || '',
457 fine: (document.getElementById('fine') || {}).textContent || '',
458 scope: (document.getElementById('scope') || {}).textContent || '',
459 lead: (document.getElementById('lead') || {}).textContent || '',
460 }));
461 await ctx.close();
462 return got;
463 };
464 const none = await say3('fence:none');
465 const real = await say3('fence:linux landlock:abi-8 journal root:/home/u/work');
466 const silent = await say3('');
467 say(!/folders the workspace/.test(none.body) && /cannot limit which files/.test(none.body),
468 'a fenceless machine is not described as fencing anything', none.body);
469 say(/folders the workspace/.test(real.body),
470 'a machine that fences gets the sentence about folders', real.body);
471 say(none.body !== real.body, 'the two are not the same words');
472 say(/journal/i.test(real.fine) && !/journal/i.test(none.fine),
473 'a hand that keeps a journal is the only one that promises one');
474 say(/landlock:abi-8/.test(real.scope) && /fence:none/.test(none.scope),
475 'what the machine can enforce is still shown verbatim');
476 say(/did not say/.test(silent.scope) && /did not say/.test(silent.body),
477 'a hand that said nothing is a third answer, not a promise');
478 // And the same three answers on the FIRST screen, in one line each.
479 say(/cannot limit which files/.test(none.lead) && /can hold them to one folder/.test(real.lead)
480 && /did not say/.test(silent.lead),
481 'the first screen carries the same three answers, not a fourth', JSON.stringify(silent.lead));
482 }
483
484 // The mark in ext/ is the app's own, not a redrawing of it.
485 {
486 const a = fs.readFileSync(`${ROOT}/www/assets/daimond_mark.svg`);
487 const b = fs.readFileSync(`${EXT}/daimond_mark.svg`);
488 say(a.equals(b), 'the mark in ext/ is byte-identical to the app\'s', `${b.length} bytes`);
489 }
490} else {
491 // ── Proving the checks ───────────────────────────────────────
492 //
493 // One breakage per check, chosen to be exactly what that check is for. The
494 // check must FAIL. A check that passes here is decoration.
495 const BREAK = {
496 'the mark is there, and really loaded':
497 (rel, s) => rel === 'grant.html' ? s.replace('src="daimond_mark.svg"', 'src="nope.svg"') : s,
498
499 'the first screen carries every fact the decision needs':
500 (rel, s) => rel === 'grant.js'
501 ? s.replace("$('strongest').hidden\t\t= false;", "$('strongest').hidden\t\t= true;") : s,
502
503 'nothing that was said has been dropped, only moved':
504 (rel, s) => rel === 'grant.js'
505 ? s.replace(/\$\('fine'\)\.textContent\t= journalled\(\)[^;]*;/, "$('fine').textContent = '';") : s,
506
507 'the long prose is behind the disclosure, not on the first screen':
508 (rel, s) => rel === 'grant.js' ? s.replace('conceal($(\'body\'), $(\'scope\'));', '') : s,
509
510 'the first screen is short enough to read':
511 (rel, s) => rel === 'grant.js' ? s.replace('conceal($(\'body\'), $(\'scope\'));', '') : s,
512
513 // The pinned row is what keeps the buttons reachable once the detail is
514 // open, so the breakage is exactly that: ordinary block flow, detail
515 // open, and the row slides off the bottom.
516 'both buttons are on screen, and the window itself never scrolls':
517 (rel, s) => rel === 'grant.html'
518 ? s.replace('flex-direction:\tcolumn;', 'flex-direction:\tcolumn; display: block;')
519 .replace('<details id="more">', '<details id="more" open>') : s,
520
521 'the disclosure is in the keyboard\'s path, before the buttons':
522 (rel, s) => rel === 'grant.html'
523 ? s.replace('<summary id="more-sum"', '<summary tabindex="-1" id="more-sum"') : s,
524
525 'the first screen fits the window without scrolling':
526 (rel, s) => rel === 'grant.html'
527 ? s.replace('padding:\t16px 20px 14px;', 'padding: 16px 20px 220px;') : s,
528
529 'every visible run clears the contrast floors':
530 (rel, s) => rel === 'grant.html'
531 ? s.replace('--dim:\tcolor-mix(in srgb, CanvasText 78%, Canvas);',
532 '--dim:\tcolor-mix(in srgb, CanvasText 28%, Canvas);') : s,
533 };
534
535 /// A breakage that changes nothing proves nothing, and a stale search string
536 /// is silent about it -- which is how both of the first two written here got
537 /// past their own proof. So the patch must be seen to bite.
538 let bit = false;
539 for (const [name, fn] of Object.entries(CHECKS)) {
540 const breaker = BREAK[name];
541 if (!breaker) { say(false, `${name}: has a breakage to prove it`, 'none written'); continue; }
542 bit = false;
543 patch = (rel, s) => { const o = breaker(rel, s); if (o !== s) bit = true; return o; };
544 const { r, ctx } = await measure({ code: 'en', scheme: 'light' });
545 const [ok, detail] = fn(r, 'en');
546 say(bit, `the breakage for "${name}" actually bit`, bit ? '' : 'the patch matched nothing');
547 say(!ok, `broken on purpose: "${name}" catches it`, ok ? 'it PASSED on broken output' : `(said: ${detail})`);
548 await ctx.close();
549 patch = null;
550 }
551 // The window sizing itself, proved the same way: with the call taken out, a
552 // first screen that does not fit must go unnoticed.
553 {
554 patch = (rel, str) => rel === 'grant.js' ? str.replace(/^\t\tfit\(\);$/m, '') : str;
555 const { ctx, page } = await open({ code: 'fr', height: 420 });
556 const asked = await page.evaluate(() => globalThis.__resized || null);
557 say(!asked, 'broken on purpose: "a first screen that does not fit asks for a taller window" catches it',
558 asked ? 'it still asked' : '');
559 await ctx.close();
560 patch = null;
561 }
562
563 // The one check with no page behind it.
564 {
565 const a = fs.readFileSync(`${ROOT}/www/assets/daimond_mark.svg`);
566 const b = Buffer.from(fs.readFileSync(`${EXT}/daimond_mark.svg`).toString('utf8') + '<!--x-->');
567 say(!a.equals(b), 'broken on purpose: "the mark in ext/ is byte-identical" catches it');
568 }
569}
570
571await browser.close();
572await new Promise((r) => srv.close(r));
573console.log(`\n${ran} checks, ${bad ? bad + ' FAILED' : 'all good'} — shots in ${OUT}`);
574process.exit(bad ? 1 : 0);