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. |
| 35 | import fs from 'node:fs'; |
| 36 | import os from 'node:os'; |
| 37 | import http from 'node:http'; |
| 38 | import path from 'node:path'; |
| 39 | import { pathToFileURL } from 'node:url'; |
| 40 | |
| 41 | const PW = path.join(os.homedir(), '.red-pw/node_modules/playwright-core/index.mjs'); |
| 42 | const { chromium } = await import(pathToFileURL(PW).href); |
| 43 | const CHROME = `${process.env.HOME}/.cache/ms-playwright/chromium-1229/chrome-linux64/chrome`; |
| 44 | import { fileURLToPath } from 'node:url'; |
| 45 | const ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), '..'); // this checkout, not one developer's home |
| 46 | const EXT = `${ROOT}/ext`; |
| 47 | const OUT = path.join(os.homedir(), '.cache/daimond/grant-shots'); |
| 48 | fs.mkdirSync(OUT, { recursive: true }); |
| 49 | |
| 50 | const PROVE = process.argv.includes('--prove'); |
| 51 | const PORT = Number(process.env.GRANT_PORT || 9187); |
| 52 | const 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. |
| 57 | const VH = Number(process.env.GRANT_VH || 520); |
| 58 | |
| 59 | /// App locale code -> Chrome `_locales` directory, as i18n.js has it. |
| 60 | const DIRS = { |
| 61 | 'en': 'en', 'de': 'de', 'es': 'es', 'fr': 'fr', |
| 62 | 'ja': 'ja', 'ko': 'ko', 'pt-BR': 'pt_BR', 'zh-Hans': 'zh_CN', |
| 63 | }; |
| 64 | const MSG = {}; |
| 65 | for (const [code, dir] of Object.entries(DIRS)) { |
| 66 | MSG[code] = JSON.parse(fs.readFileSync(`${EXT}/_locales/${dir}/messages.json`, 'utf8')); |
| 67 | } |
| 68 | const 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. |
| 72 | const CAPS = 'fence:linux landlock:abi-8 seccomp journal root:/home/u/work ws:8f3a1c home:/home/u'; |
| 73 | const 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. |
| 79 | let patch = null; |
| 80 | const TYPES = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', |
| 81 | '.json': 'application/json; charset=utf-8', '.svg': 'image/svg+xml' }; |
| 82 | const 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 | }); |
| 93 | await new Promise((r) => srv.listen(PORT, '127.0.0.1', r)); |
| 94 | |
| 95 | const 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. |
| 99 | async 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. |
| 139 | const 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. |
| 268 | const 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. |
| 280 | const 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 | |
| 289 | const 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 ───────────────────────────────────────────────────── |
| 347 | let bad = 0, ran = 0; |
| 348 | const say = (ok, what, detail) => { |
| 349 | ran++; |
| 350 | if (!ok) bad++; |
| 351 | console.log(`${ok ? 'PASS' : 'FAIL'} ${what}${detail ? ' — ' + detail : ''}`); |
| 352 | }; |
| 353 | |
| 354 | async function measure(opts) { |
| 355 | const { ctx, page } = await open(opts); |
| 356 | const r = await page.evaluate(READ); |
| 357 | return { r, ctx, page }; |
| 358 | } |
| 359 | |
| 360 | if (!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 | |
| 571 | await browser.close(); |
| 572 | await new Promise((r) => srv.close(r)); |
| 573 | console.log(`\n${ran} checks, ${bad ? bad + ' FAILED' : 'all good'} — shots in ${OUT}`); |
| 574 | process.exit(bad ? 1 : 0); |