oxedyne/daimond/dev/verify_about.mjs
35.1 KiB, 1 run
created by r2519314175:221, 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_about.mjs — the About dialog, and the button that opens it. |
| 2 | // |
| 3 | // The header's maker's badge has become an About button, and the badge's two |
| 4 | // claims have moved into the dialog it opens. That is a move of something that |
| 5 | // was ALREADY LIVE, so what has to be proved is mostly that nothing was dropped |
| 6 | // on the way: the two links are still two links, still separate claims, still |
| 7 | // pointing where they pointed. |
| 8 | // |
| 9 | // What is asserted, and why each one rather than the next thing: |
| 10 | // |
| 11 | // 1. THE SWAP HAPPENED, BOTH WAYS. An About button is in the header, and the |
| 12 | // badge is NOT still in it. Half a move leaves two things saying the same |
| 13 | // thing in the same row, which is how a "moved" control comes to exist in |
| 14 | // two places for a release. |
| 15 | // |
| 16 | // 2. IT IS THE APP'S OWN DIALOG. Opened by the button, closed by Escape, |
| 17 | // closed by the control it offers, focus trapped inside it while it is up, |
| 18 | // and the focus handed back to the button afterwards. Every one of those is |
| 19 | // free if the dialog is built on `openBodyDialog` and none of them is if it |
| 20 | // is a modal somebody wrote for this screen — so they are the check that it |
| 21 | // really is the shared furniture, rather than something that looks like it. |
| 22 | // |
| 23 | // 3. THE ARTWORK IS THE RIGHT SHAPE. The splash's painted box is measured |
| 24 | // against the aspect of ITS OWN viewBox, read out of the file. A stretched |
| 25 | // splash is what happens when a height is set somewhere up the tree, and it |
| 26 | // is not a thing anybody notices from a screenshot of a picture they have |
| 27 | // not seen undistorted. It is also measured against the card: full bleed |
| 28 | // means EXACTLY the card's padding box, in every skin, because a pixel over |
| 29 | // hangs a horizontal scrollbar under it (`.modal-card` scrolls on y, and |
| 30 | // CSS then computes overflow-x to auto). |
| 31 | // |
| 32 | // 4. BOTH CLAIMS SURVIVED, SEPARATELY. Two links, two accessible names, |
| 33 | // two destinations, each opening in a new tab with rel="noopener |
| 34 | // noreferrer". One link with a name that mentions both is not the same |
| 35 | // thing: they are separate claims and a reader must be able to follow |
| 36 | // either without following the other. |
| 37 | // |
| 38 | // 5. THE BUILD IT SHOWS IS THE BUILD IT IS RUNNING. Compared against |
| 39 | // `www/build.json` on disk, which is the file the app itself reads. |
| 40 | // |
| 41 | // 6. IT FITS A PHONE, AND SO DOES THE ROW IT IS OPENED FROM. The dialog |
| 42 | // inside the viewport at an iPhone width; the header row un-clipped at 320, |
| 43 | // 375 and 390. The second is not incidental: the button is a fifth control |
| 44 | // in a row that only ever had space for four, and the bar's `overflow: |
| 45 | // hidden` amputates the last one rather than complaining. |
| 46 | // |
| 47 | // 7. NOTHING IN IT IS ENGLISH BY ACCIDENT, AND NOTHING IN IT FREEZES. |
| 48 | // Opened under a second language, every string comes back in that |
| 49 | // language; and with the dialog STILL OPEN, a switch to a third repaints |
| 50 | // it where it stands. The second half is not the first one twice: a |
| 51 | // surface built after the switch is built in the new language whatever the |
| 52 | // code does, so it is evidence about the table and none at all about the |
| 53 | // surface. It caught a real one on its first clean run — `openBodyDialog` |
| 54 | // wrote its heading and its buttons with `textContent`, so every dialog |
| 55 | // built on it kept the language it was opened in until it was closed. |
| 56 | // |
| 57 | // EACH CHECK IS PROVED AGAINST BROKEN CODE FIRST. `--break <name>` serves a |
| 58 | // deliberately damaged copy of a real file to the real page (through |
| 59 | // `page.route`, so the browser loads it as it loads anything else) and the run |
| 60 | // is then expected to FAIL. A break whose anchor does not appear exactly once |
| 61 | // aborts rather than passing quietly: a check proved against code that was never |
| 62 | // broken is not proved at all. |
| 63 | // |
| 64 | // node dev/verify_about.mjs --break nobutton # 1: no About button in the bar |
| 65 | // node dev/verify_about.mjs --break oldbadge # 1: the badge is still there too |
| 66 | // node dev/verify_about.mjs --break noescape # 2: Escape does not close it |
| 67 | // node dev/verify_about.mjs --break notrap # 2: Tab walks out of the card |
| 68 | // node dev/verify_about.mjs --break noreturn # 2: the focus never comes back |
| 69 | // node dev/verify_about.mjs --break stretch # 3: the splash is squashed |
| 70 | // node dev/verify_about.mjs --break nobleed # 3: it stops short of the edges |
| 71 | // node dev/verify_about.mjs --break onelink # 4: one claim instead of two |
| 72 | // node dev/verify_about.mjs --break norel # 4: a new tab with a handle back |
| 73 | // node dev/verify_about.mjs --break wrongbuild # 5: a build id that is not ours |
| 74 | // node dev/verify_about.mjs --break crowd # 6: the header row clips again |
| 75 | // node dev/verify_about.mjs --break hardcoded # 7: an English string, always |
| 76 | // node dev/verify_about.mjs --break unmarked # 7: right at open, frozen after |
| 77 | // node dev/verify_about.mjs # and then, clean |
| 78 | // |
| 79 | // eval "$(bash dev/world.sh 6 --up)" |
| 80 | // node dev/verify_about.mjs |
| 81 | // |
| 82 | // Needs dev/serve.mjs only. No gateway and no model: About spends nothing and |
| 83 | // asks nothing of anybody. |
| 84 | import fs from 'node:fs'; |
| 85 | import path from 'node:path'; |
| 86 | import { fileURLToPath } from 'node:url'; |
| 87 | import { open, shot, scratch, errors, signInAs, APP } from './harness.mjs'; |
| 88 | |
| 89 | const HERE = path.dirname(fileURLToPath(import.meta.url)); |
| 90 | const WWW = path.join(HERE, '..', 'www'); |
| 91 | |
| 92 | const BREAK = (() => { |
| 93 | const i = process.argv.indexOf('--break'); |
| 94 | return i > 0 ? String(process.argv[i + 1] || '') : ''; |
| 95 | })(); |
| 96 | |
| 97 | const PROFILE = scratch('pw', 'about' + (BREAK ? '-' + BREAK : '')); |
| 98 | fs.rmSync(PROFILE, { recursive: true, force: true }); |
| 99 | |
| 100 | const ok = [], bad = []; |
| 101 | const check = (name, pass, detail) => { |
| 102 | (pass ? ok : bad).push(name); |
| 103 | console.log((pass ? ' ok ' : ' FAIL ') + name + (detail != null ? ' — ' + detail : '')); |
| 104 | }; |
| 105 | |
| 106 | // ── The oracles, read off disk ─────────────────────────────────────── |
| 107 | // |
| 108 | // Not restated here. The aspect the splash must draw at is the one in the |
| 109 | // artwork's own header, and the build id it must show is the one in the file |
| 110 | // the app fetches — so re-drawing the splash or sealing a new build moves both |
| 111 | // of these on their own, and a check that has silently stopped meaning anything |
| 112 | // is not a thing this file can leave lying about. |
| 113 | const SPLASH = fs.readFileSync(path.join(WWW, 'assets', 'splash.svg'), 'utf8'); |
| 114 | const VIEWBOX = (() => { |
| 115 | const m = /viewBox="([\d.\s-]+)"/.exec(SPLASH); |
| 116 | if (!m) { console.error('splash.svg carries no viewBox; nothing to measure against.'); process.exit(2); } |
| 117 | const n = m[1].trim().split(/\s+/).map(Number); |
| 118 | return { w: n[2], h: n[3], ar: n[2] / n[3] }; |
| 119 | })(); |
| 120 | const BUILD = JSON.parse(fs.readFileSync(path.join(WWW, 'build.json'), 'utf8')).build; |
| 121 | |
| 122 | // ── The breaks ─────────────────────────────────────────────────────── |
| 123 | // Each is a real edit to a real file, served in place of it. `find` must appear |
| 124 | // exactly once, or the run below would prove the opposite of what it claims. |
| 125 | const BREAKS = { |
| 126 | // No About button at all: the slot is empty and nothing opens the dialog. |
| 127 | nobutton: [{ |
| 128 | file: 'index.html', |
| 129 | find: '<button class="icon-btn" id="about-btn"', |
| 130 | with: '<button class="icon-btn" id="about-btn" hidden style="display:none" data-broken="1"', |
| 131 | }], |
| 132 | // Half a move: the button arrives and the badge stays. Two things in one row |
| 133 | // saying the same thing, which is the state a "moved" control passes through |
| 134 | // and must not be released in. |
| 135 | oldbadge: [{ |
| 136 | file: 'index.html', |
| 137 | find: '\t\t\t<button class="icon-btn" id="guide-btn"', |
| 138 | with: '\t\t\t<span class="made-by"><img src="assets/made_by_oxedyne.svg" alt="" aria-hidden="true">' |
| 139 | + '<a class="mb-hit mb-oxedyne" href="https://oxedyne.com"></a>' |
| 140 | + '<a class="mb-hit mb-ai" href="https://need2know.ai/mostly-ai/code"></a></span>\n' |
| 141 | + '\t\t\t<button class="icon-btn" id="guide-btn"', |
| 142 | }], |
| 143 | // The shared dialog's Escape, gone. If About had a modal of its own this |
| 144 | // break would not touch it, and the check would pass with the app broken — |
| 145 | // which is exactly what it is here to notice. |
| 146 | noescape: [{ |
| 147 | file: 'js/daimond.js', |
| 148 | find: '\t\t\t\tif (e.key === \'Escape\') { e.preventDefault(); close(true); }\n' |
| 149 | + '\t\t\t\telse if (e.key === \'Tab\') keepFocusIn(card, e);', |
| 150 | with: '\t\t\t\tif (e.key === \'Tab\') keepFocusIn(card, e);', |
| 151 | }], |
| 152 | // The focus trap, gone: Tab walks out of the card and into the app behind it. |
| 153 | notrap: [{ |
| 154 | file: 'js/daimond.js', |
| 155 | find: '\tfunction keepFocusIn(card, e) {', |
| 156 | with: '\tfunction keepFocusIn(card, e) {\n\t\tif (card) return;', |
| 157 | }], |
| 158 | // The focus is not handed back. A dialog opened from the keyboard and closed |
| 159 | // from the keyboard leaves the user at the top of the document. |
| 160 | noreturn: [{ |
| 161 | file: 'js/daimond.js', |
| 162 | find: '\tfunction refocus(prev, host) {\n\t\tif (tookFocus(prev)) return;', |
| 163 | with: '\tfunction refocus(prev, host) {\n\t\tif (prev || !prev) return;\n\t\tif (tookFocus(prev)) return;', |
| 164 | }], |
| 165 | // A height on the splash: the artwork is squashed to fit a box that is not |
| 166 | // its shape, and on a phone the card grows past the screen with it. |
| 167 | stretch: [{ |
| 168 | file: 'css/app.css', |
| 169 | find: '\theight: auto;\n\tmargin: calc(var(--about-pad) * -1) calc(var(--about-pad) * -1) 0;', |
| 170 | with: '\theight: 520px;\n\tmargin: calc(var(--about-pad) * -1) calc(var(--about-pad) * -1) 0;', |
| 171 | }], |
| 172 | // The bleed cancelled: the splash sits inside the card's padding like a |
| 173 | // paragraph, with a margin of card showing round it. |
| 174 | nobleed: [{ |
| 175 | file: 'css/app.css', |
| 176 | find: '\twidth: calc(100% + var(--about-pad) * 2);\n\t/* Stated, not inherited', |
| 177 | with: '\twidth: 100%;\n\t/* Stated, not inherited', |
| 178 | }], |
| 179 | // One claim where there were two. |
| 180 | onelink: [{ |
| 181 | file: 'js/daimond.js', |
| 182 | find: '\t\t\t[\'mb-ai\', \'https://need2know.ai/mostly-ai/code\', \'topbar.made_with_ai\'],\n', |
| 183 | with: '', |
| 184 | }], |
| 185 | // A new tab with a handle back on this window, and a referrer off it. |
| 186 | norel: [{ |
| 187 | file: 'js/daimond.js', |
| 188 | find: '\t\t\ta.rel = \'noopener noreferrer\';', |
| 189 | with: '\t\t\ta.rel = \'\';', |
| 190 | }], |
| 191 | // A build id that is not the one this tab is running. Truncated rather than |
| 192 | // blanked, because a blank would be caught by "there is a build id at all" |
| 193 | // and this has to be caught by "it is the RIGHT one". |
| 194 | wrongbuild: [{ |
| 195 | file: 'js/daimond.js', |
| 196 | find: '\t\tvar known = buildId();\n\t\tvid.textContent = known;', |
| 197 | with: '\t\tvar known = buildId();\n\t\tvid.textContent = known.slice(0, 6);', |
| 198 | }], |
| 199 | // The header row back at the breakpoint it had before About was a fifth |
| 200 | // control in it, which is where the bar starts amputating the last one. |
| 201 | crowd: [{ |
| 202 | file: 'css/responsive.css', |
| 203 | find: '@media (max-width: 380px) {\n\t.topbar { gap: 8px; }', |
| 204 | with: '@media (max-width: 360px) {\n\t.topbar { gap: 8px; }', |
| 205 | }], |
| 206 | // An English sentence written into the dialog rather than looked up. This is |
| 207 | // the failure the project shipped twice in one day, so it gets a break. |
| 208 | hardcoded: [{ |
| 209 | file: 'js/daimond.js', |
| 210 | find: '\t\tDaimondI18n.bind(said, \'\', \'about.what\');', |
| 211 | with: '\t\tsaid.textContent = \'Daimond is an AI agent workspace that runs entirely in your browser.\';', |
| 212 | }], |
| 213 | // Looked up, but not MARKED. Right in every language at the moment the |
| 214 | // dialog is built, and frozen in that language for as long as it stays open |
| 215 | // — which is the shape that left the Admin drawer in Spanish through a |
| 216 | // switch to Japanese on 2026-08-10, and had shipped once before that. |
| 217 | unmarked: [{ |
| 218 | file: 'js/daimond.js', |
| 219 | find: '\t\tDaimondI18n.bind(said, \'\', \'about.what\');', |
| 220 | with: '\t\tsaid.textContent = DaimondI18n.t(\'about.what\');', |
| 221 | }], |
| 222 | }; |
| 223 | |
| 224 | if (BREAK && !BREAKS[BREAK]) { |
| 225 | console.error(`unknown break '${BREAK}'; one of: ${Object.keys(BREAKS).join(', ')}`); |
| 226 | process.exit(2); |
| 227 | } |
| 228 | |
| 229 | /// `src` with `spec` applied, or a hard stop. |
| 230 | function damaged(src, spec) { |
| 231 | const n = src.split(spec.find).length - 1; |
| 232 | if (n !== 1) { |
| 233 | console.error(`break '${BREAK}': the anchor appears ${n} times in ${spec.file}, ` |
| 234 | + 'so nothing was broken and the run below would prove nothing.'); |
| 235 | process.exit(2); |
| 236 | } |
| 237 | return src.replace(spec.find, spec.with); |
| 238 | } |
| 239 | |
| 240 | const TYPE = { '.html': 'text/html', '.css': 'text/css', '.js': 'application/javascript' }; |
| 241 | |
| 242 | /// The damaged files, ONE BODY PER FILE. |
| 243 | /// |
| 244 | /// Every edit a break names for a file goes into the SAME body, in order, and |
| 245 | /// that one body is what the route serves. A `page.route` per edit spec does not |
| 246 | /// work and does not say so: Playwright hands a request to the LAST route |
| 247 | /// registered for its URL, so a two-edit break shipped only its second edit -- |
| 248 | /// and still went red, for half the reason it claims, with nothing to notice it. |
| 249 | function damagedFiles() { |
| 250 | const byFile = new Map(); |
| 251 | for (const spec of (BREAKS[BREAK] || [])) { |
| 252 | const src = byFile.has(spec.file) ? byFile.get(spec.file) |
| 253 | : fs.readFileSync(path.join(WWW, spec.file), 'utf8'); |
| 254 | byFile.set(spec.file, damaged(src, spec)); |
| 255 | } |
| 256 | return byFile; |
| 257 | } |
| 258 | |
| 259 | async function serveBreaks(page) { |
| 260 | if (!BREAK) return; |
| 261 | for (const [file, body] of damagedFiles()) { |
| 262 | const type = TYPE[path.extname(file)] || 'text/plain'; |
| 263 | if (file === 'index.html') { |
| 264 | // The document is served at `/`, not at `/index.html`, so it cannot be |
| 265 | // matched by a glob on the file's name. |
| 266 | await page.route( |
| 267 | (url) => url.pathname === '/' || url.pathname === '/index.html', |
| 268 | r => r.fulfill({ status: 200, contentType: type, body })); |
| 269 | } else { |
| 270 | await page.route('**/' + file, r => r.fulfill({ status: 200, contentType: type, body })); |
| 271 | } |
| 272 | } |
| 273 | } |
| 274 | |
| 275 | // ── Driving ────────────────────────────────────────────────────────── |
| 276 | |
| 277 | const IS_UP = () => { |
| 278 | const c = document.querySelector('.about-card'); |
| 279 | return !!c && c.getClientRects().length > 0 && getComputedStyle(c).visibility !== 'hidden'; |
| 280 | }; |
| 281 | |
| 282 | const sleep = (ms) => new Promise(r => setTimeout(r, ms)); |
| 283 | |
| 284 | /// Open About the way a keyboard user does, so the focus is genuinely ON the |
| 285 | /// button when the dialog takes over — which is what makes "the focus comes |
| 286 | /// back" mean anything. A synthetic `.click()` leaves the focus wherever it was. |
| 287 | async function openFromKeyboard(page) { |
| 288 | await page.focus('#about-btn'); |
| 289 | await page.keyboard.press('Enter'); |
| 290 | await page.waitForTimeout(500); |
| 291 | } |
| 292 | |
| 293 | /// Shut whatever is over the app, without asserting anything about how. |
| 294 | async function quieten(page) { |
| 295 | for (let i = 0; i < 3; i++) { await page.keyboard.press('Escape'); await page.waitForTimeout(90); } |
| 296 | await page.evaluate(() => { |
| 297 | document.querySelectorAll('.modal.dlg').forEach(m => m.remove()); |
| 298 | }); |
| 299 | await page.waitForTimeout(120); |
| 300 | } |
| 301 | |
| 302 | const s = await open({ name: 'about', signIn: false, connect: false, profile: PROFILE }); |
| 303 | const { page } = s; |
| 304 | await serveBreaks(page); |
| 305 | // The routes only bite on a load that comes after them, and signing in reloads |
| 306 | // nothing — so the page is reopened with them in place. |
| 307 | await page.goto(APP, { waitUntil: 'domcontentloaded' }); |
| 308 | await signInAs(s, 'about'); |
| 309 | await page.waitForTimeout(1500); |
| 310 | |
| 311 | try { |
| 312 | // ── 1. The swap happened, both ways ────────────────────────── |
| 313 | const bar = await page.evaluate(() => { |
| 314 | const b = document.getElementById('about-btn'); |
| 315 | const seen = (el) => !!el && el.getClientRects().length > 0 |
| 316 | && getComputedStyle(el).display !== 'none' && getComputedStyle(el).visibility !== 'hidden'; |
| 317 | return { |
| 318 | drawn: seen(b), |
| 319 | inRow: !!(b && b.closest('.top-actions')), |
| 320 | name: b ? (b.getAttribute('aria-label') || '') : '', |
| 321 | classes: b ? b.className : '', |
| 322 | // The icon has to be the app's own `.ic`, at the size the rest of the |
| 323 | // row draws: a button that is the right shape and the wrong weight |
| 324 | // reads as belonging to a different set. |
| 325 | icon: b ? (() => { |
| 326 | const i = b.querySelector('svg.ic'); |
| 327 | if (!i) return null; |
| 328 | const r = i.getBoundingClientRect(); |
| 329 | const cs = getComputedStyle(i); |
| 330 | return { w: Math.round(r.width), h: Math.round(r.height), stroke: cs.strokeWidth }; |
| 331 | })() : null, |
| 332 | // And the same box as its neighbours, so the row does not step. |
| 333 | sameBox: (() => { |
| 334 | if (!b) return false; |
| 335 | const g = document.getElementById('guide-btn'); |
| 336 | if (!g) return false; |
| 337 | const a = b.getBoundingClientRect(), q = g.getBoundingClientRect(); |
| 338 | return Math.abs(a.width - q.width) < 1.5 && Math.abs(a.height - q.height) < 1.5; |
| 339 | })(), |
| 340 | badgeInBar: document.querySelectorAll('.top-actions .made-by, .topbar .made-by').length, |
| 341 | }; |
| 342 | }); |
| 343 | check('an About button is drawn in the header row', bar.drawn && bar.inRow, |
| 344 | `drawn=${bar.drawn} inRow=${bar.inRow}`); |
| 345 | check('and it says what it is', /about/i.test(bar.name) || bar.name.length > 0, bar.name || '(no name)'); |
| 346 | check('and it is one of the row\'s icon buttons, at the row\'s icon size', |
| 347 | /icon-btn/.test(bar.classes) && bar.icon && bar.icon.w === 18 && bar.icon.h === 18 |
| 348 | && bar.icon.stroke === '1.75px' && bar.sameBox, |
| 349 | `${bar.classes} ${JSON.stringify(bar.icon)} sameBox=${bar.sameBox}`); |
| 350 | check('AND THE OLD BADGE IS GONE FROM THE HEADER — a move, not a copy', |
| 351 | bar.badgeInBar === 0, `${bar.badgeInBar} badge(s) still in the bar`); |
| 352 | |
| 353 | // ── 2. It is the app's own dialog ──────────────────────────── |
| 354 | // Opened by a real pointer click on the button, first: that is how most |
| 355 | // people will open it, and a handler bound to the wrong element passes a |
| 356 | // synthetic `.click()` and fails this. |
| 357 | await page.click('#about-btn'); |
| 358 | await page.waitForTimeout(600); |
| 359 | check('a click on it opens the dialog', await page.evaluate(IS_UP)); |
| 360 | |
| 361 | const furniture = await page.evaluate(() => { |
| 362 | const c = document.querySelector('.about-card'); |
| 363 | if (!c) return null; |
| 364 | return { |
| 365 | modalCard: c.classList.contains('modal-card') && c.classList.contains('dlg-card'), |
| 366 | inModal: !!c.closest('.modal'), |
| 367 | role: c.getAttribute('role'), |
| 368 | modal: c.getAttribute('aria-modal'), |
| 369 | // Named by a heading that is IN the tree even though it is not in the |
| 370 | // pixels: `aria-labelledby` pointing at nothing is a dialog with no name. |
| 371 | labelledBy: (() => { |
| 372 | const id = c.getAttribute('aria-labelledby'); |
| 373 | const h = id && c.querySelector('#' + CSS.escape(id)); |
| 374 | return h ? (h.textContent || '').trim() : ''; |
| 375 | })(), |
| 376 | closer: !!c.querySelector('.tile-dlg-done'), |
| 377 | }; |
| 378 | }); |
| 379 | check('it is the app\'s dialog furniture, not a modal of its own', |
| 380 | !!furniture && furniture.modalCard && furniture.inModal |
| 381 | && furniture.role === 'dialog' && furniture.modal === 'true', |
| 382 | JSON.stringify(furniture)); |
| 383 | check('and it is named to the accessibility tree by a real heading', |
| 384 | !!furniture && furniture.labelledBy.length > 0, furniture && furniture.labelledBy); |
| 385 | |
| 386 | // The focus trap, with the dialog up. Fourteen presses, which is more than |
| 387 | // twice round anything in this card: a trap that only holds for one press is |
| 388 | // not a trap, and neither is one that holds until the ring wraps. |
| 389 | // |
| 390 | // Driven one press at a time from OUT here, not from a loop inside the page: |
| 391 | // the trap is a keydown handler, and a synthetic focus walk would never fire |
| 392 | // it and would prove nothing about the thing being asserted. |
| 393 | const walk = []; |
| 394 | let escaped = false; |
| 395 | for (let i = 0; i < 14; i++) { |
| 396 | await page.keyboard.press('Tab'); |
| 397 | await page.waitForTimeout(60); |
| 398 | const where = await page.evaluate(() => { |
| 399 | const card = document.querySelector('.about-card'); |
| 400 | const a = document.activeElement; |
| 401 | return { |
| 402 | inside: !!(card && a && card.contains(a)), |
| 403 | what: a ? (a.tagName.toLowerCase() + (a.id ? '#' + a.id : '') |
| 404 | + (a.className ? '.' + String(a.className).split(' ')[0] : '')) : '(none)', |
| 405 | }; |
| 406 | }); |
| 407 | walk.push(where.what); |
| 408 | if (!where.inside) { escaped = true; break; } |
| 409 | } |
| 410 | check('the focus is trapped inside it — fourteen Tabs and it never leaves', |
| 411 | !escaped, walk.join(' → ')); |
| 412 | |
| 413 | // Escape, from wherever the pointer left the focus. A handler scoped to |
| 414 | // "focus is still in my card" stops working the moment somebody clicks the |
| 415 | // prose they are reading, which is a thing people do. |
| 416 | const textAt = await page.evaluate(() => { |
| 417 | const p = document.querySelector('.about-card .about-said'); |
| 418 | if (!p) return null; |
| 419 | const r = p.getBoundingClientRect(); |
| 420 | return { x: r.left + r.width / 2, y: r.top + r.height / 2 }; |
| 421 | }); |
| 422 | if (textAt) { |
| 423 | await page.mouse.click(textAt.x, textAt.y); |
| 424 | await page.waitForTimeout(150); |
| 425 | } |
| 426 | await page.keyboard.press('Escape'); |
| 427 | await page.waitForTimeout(500); |
| 428 | check('Escape closes it, even after the pointer has moved the focus off it', |
| 429 | !(await page.evaluate(IS_UP))); |
| 430 | |
| 431 | // The pointer's own way out. |
| 432 | await quieten(page); |
| 433 | await page.evaluate(() => document.getElementById('about-btn').click()); |
| 434 | await page.waitForTimeout(500); |
| 435 | await page.evaluate(() => { const b = document.querySelector('.about-card .tile-dlg-done'); if (b) b.click(); }); |
| 436 | await page.waitForTimeout(400); |
| 437 | check('and the control it offers closes it too', !(await page.evaluate(IS_UP))); |
| 438 | |
| 439 | // And the focus comes back to the button that opened it. |
| 440 | await quieten(page); |
| 441 | await openFromKeyboard(page); |
| 442 | check('Enter on the button opens it', await page.evaluate(IS_UP)); |
| 443 | await page.keyboard.press('Escape'); |
| 444 | await page.waitForTimeout(500); |
| 445 | const landed = await page.evaluate(() => { |
| 446 | const a = document.activeElement; |
| 447 | return a ? (a.id || a.tagName.toLowerCase()) : '(none)'; |
| 448 | }); |
| 449 | check('AND THE FOCUS COMES BACK TO THE BUTTON', landed === 'about-btn', landed); |
| 450 | |
| 451 | // ── 3. The artwork is the right shape ──────────────────────── |
| 452 | // Both skins: `--about-pad` mirrors the card's padding and the warm skin |
| 453 | // loosens it, so a bleed measured against one skin's number is a picture |
| 454 | // hanging over the edge of the other's card. |
| 455 | for (const skin of ['sharp', 'warm']) { |
| 456 | await quieten(page); |
| 457 | await page.evaluate((k) => document.documentElement.setAttribute('data-skin', k), skin); |
| 458 | await page.waitForTimeout(250); |
| 459 | await page.evaluate(() => document.getElementById('about-btn').click()); |
| 460 | await page.waitForTimeout(600); |
| 461 | const art = await page.evaluate(() => { |
| 462 | const c = document.querySelector('.about-card'); |
| 463 | const img = c && c.querySelector('.about-splash'); |
| 464 | if (!img) return null; |
| 465 | const r = img.getBoundingClientRect(), cr = c.getBoundingClientRect(); |
| 466 | return { |
| 467 | w: +r.width.toFixed(2), h: +r.height.toFixed(2), ar: r.width / r.height, |
| 468 | natAr: img.naturalWidth / img.naturalHeight, |
| 469 | loaded: img.complete && img.naturalWidth > 0, |
| 470 | // The card's PADDING box, which is what full bleed means here. |
| 471 | padBox: c.clientWidth, |
| 472 | fromTop: +(r.top - cr.top).toFixed(2), |
| 473 | cardH: +cr.height.toFixed(2), |
| 474 | overX: c.scrollWidth - c.clientWidth, |
| 475 | alt: img.getAttribute('alt') || '', |
| 476 | }; |
| 477 | }); |
| 478 | check(`[${skin}] the splash actually loaded`, !!art && art.loaded, art && JSON.stringify(art)); |
| 479 | if (!art) continue; |
| 480 | // Against the artwork's OWN viewBox, read off disk — not against a number |
| 481 | // written here, which would agree with a redrawn splash's box and with |
| 482 | // nothing else. |
| 483 | check(`[${skin}] IT IS NOT STRETCHED — drawn at its own ${VIEWBOX.ar.toFixed(4)} aspect`, |
| 484 | Math.abs(art.ar - VIEWBOX.ar) < 0.02, |
| 485 | `drawn ${art.ar.toFixed(4)} (${art.w}x${art.h}), file ${VIEWBOX.ar.toFixed(4)}, ` |
| 486 | + `natural ${art.natAr.toFixed(4)}`); |
| 487 | check(`[${skin}] it reaches both edges of the card, and not a pixel past`, |
| 488 | Math.abs(art.w - art.padBox) < 1.5 && art.overX === 0, |
| 489 | `splash ${art.w}, card padding box ${art.padBox}, overflow-x ${art.overX}`); |
| 490 | check(`[${skin}] and it is the top of the dialog, at a size that dominates it`, |
| 491 | art.fromTop < 2 && art.h / art.cardH > 0.25, |
| 492 | `${art.fromTop}px from the top, ${(100 * art.h / art.cardH).toFixed(1)}% of the card`); |
| 493 | check(`[${skin}] it says what it is a picture of`, art.alt.length > 12, art.alt); |
| 494 | } |
| 495 | await page.evaluate(() => document.documentElement.setAttribute('data-skin', 'sharp')); |
| 496 | |
| 497 | // ── 4. Both claims survived, separately ────────────────────── |
| 498 | await quieten(page); |
| 499 | await page.evaluate(() => document.getElementById('about-btn').click()); |
| 500 | await page.waitForTimeout(600); |
| 501 | const links = await page.evaluate(() => { |
| 502 | const c = document.querySelector('.about-card'); |
| 503 | return [...c.querySelectorAll('a[href]')].map(a => ({ |
| 504 | href: a.href, |
| 505 | name: a.getAttribute('aria-label') || (a.textContent || '').trim(), |
| 506 | title: a.getAttribute('title') || '', |
| 507 | target: a.getAttribute('target') || '', |
| 508 | rel: a.getAttribute('rel') || '', |
| 509 | box: (() => { const r = a.getBoundingClientRect(); return { w: Math.round(r.width), h: Math.round(r.height), x: Math.round(r.x) }; })(), |
| 510 | })); |
| 511 | }); |
| 512 | const ox = links.find(l => /oxedyne\.com/.test(l.href)); |
| 513 | const ai = links.find(l => /need2know\.ai/.test(l.href)); |
| 514 | check('BOTH of the badge\'s links are in the dialog', |
| 515 | !!ox && !!ai, links.map(l => l.href).join(' | ') || 'none'); |
| 516 | check('and each has an accessible name of its own', |
| 517 | !!ox && !!ai && ox.name.length > 0 && ai.name.length > 0 && ox.name !== ai.name, |
| 518 | `${ox && JSON.stringify(ox.name)} vs ${ai && JSON.stringify(ai.name)}`); |
| 519 | check('and a title of its own, for the pointer', |
| 520 | !!ox && !!ai && ox.title.length > 0 && ai.title.length > 0 && ox.title !== ai.title, |
| 521 | `${ox && JSON.stringify(ox.title)} vs ${ai && JSON.stringify(ai.title)}`); |
| 522 | check('and each opens in a new tab with no handle back on this window', |
| 523 | !!ox && !!ai && [ox, ai].every(l => l.target === '_blank' |
| 524 | && /noopener/.test(l.rel) && /noreferrer/.test(l.rel)), |
| 525 | [ox, ai].map(l => l && `${l.target} rel=${JSON.stringify(l.rel)}`).join(' | ')); |
| 526 | check('and each has a hit area of its own, side by side', |
| 527 | !!ox && !!ai && ox.box.w > 8 && ai.box.w > 8 && ox.box.x !== ai.box.x, |
| 528 | JSON.stringify([ox && ox.box, ai && ai.box])); |
| 529 | |
| 530 | // ── 5. The build it shows is the build it is running ───────── |
| 531 | // `build.json` is the file the app itself fetches, so this is the app's own |
| 532 | // answer checked against the app's own source of it. |
| 533 | await page.waitForTimeout(400); |
| 534 | const said = await page.evaluate(() => { |
| 535 | const c = document.querySelector('.about-card'); |
| 536 | const b = c.querySelector('.about-build'); |
| 537 | return { |
| 538 | build: b ? (b.textContent || '').trim() : '', |
| 539 | row: (c.querySelector('.about-ver').textContent || '').trim(), |
| 540 | copy: !!c.querySelector('.about-ver .copy-id'), |
| 541 | }; |
| 542 | }); |
| 543 | check('THE BUILD ID IT SHOWS IS THE ONE IN build.json', |
| 544 | said.build === BUILD, `dialog "${said.build}" vs build.json "${BUILD}"`); |
| 545 | check('and it is shown the way the status panel shows it: a label, and a copy button', |
| 546 | said.row.length > said.build.length && said.copy, JSON.stringify(said)); |
| 547 | |
| 548 | // ── 6. It fits a phone, and so does the row it opens from ──── |
| 549 | await quieten(page); |
| 550 | await page.setViewportSize({ width: 390, height: 844 }); // iPhone 14/15 |
| 551 | await page.waitForTimeout(400); |
| 552 | await page.evaluate(() => document.getElementById('about-btn').click()); |
| 553 | await page.waitForTimeout(700); |
| 554 | const phone = await page.evaluate(() => { |
| 555 | const c = document.querySelector('.about-card'); |
| 556 | if (!c) return null; |
| 557 | const r = c.getBoundingClientRect(); |
| 558 | const close = c.querySelector('.tile-dlg-done'); |
| 559 | const cb = close && close.getBoundingClientRect(); |
| 560 | return { |
| 561 | top: +r.top.toFixed(1), bottom: +r.bottom.toFixed(1), |
| 562 | left: +r.left.toFixed(1), right: +r.right.toFixed(1), |
| 563 | vw: window.innerWidth, vh: window.innerHeight, |
| 564 | // Anything hidden behind the card's own scroll. The card MAY scroll — |
| 565 | // that is what `overflow-y: auto` is for — but the Close button being |
| 566 | // below the fold on a first open is the failure this exists to catch. |
| 567 | hidden: c.scrollHeight - c.clientHeight, |
| 568 | closeVisible: !!cb && cb.bottom <= window.innerHeight + 0.5 && cb.top >= 0, |
| 569 | pageScrollX: document.documentElement.scrollWidth - document.documentElement.clientWidth, |
| 570 | }; |
| 571 | }); |
| 572 | check('ON A PHONE THE WHOLE DIALOG IS ON THE SCREEN', |
| 573 | !!phone && phone.top >= 0 && phone.bottom <= phone.vh + 0.5 |
| 574 | && phone.left >= 0 && phone.right <= phone.vw + 0.5, |
| 575 | JSON.stringify(phone)); |
| 576 | check('and the way out of it is on the screen with it', |
| 577 | !!phone && phone.closeVisible && phone.hidden === 0, JSON.stringify(phone)); |
| 578 | check('and nothing about it makes the page scroll sideways', |
| 579 | !!phone && phone.pageScrollX === 0, phone && String(phone.pageScrollX)); |
| 580 | await shot(s, 'about-phone' + (BREAK ? '-' + BREAK : '')); |
| 581 | |
| 582 | // The header row itself, at the widths where it is tightest. The bar is |
| 583 | // `overflow: hidden`, which is not a fit but a guillotine: it takes the last |
| 584 | // control in the row off the screen without a word. |
| 585 | await quieten(page); |
| 586 | for (const w of [320, 375, 390]) { |
| 587 | await page.setViewportSize({ width: w, height: 780 }); |
| 588 | await page.waitForTimeout(400); |
| 589 | const row = await page.evaluate(() => { |
| 590 | const bar = document.querySelector('.topbar'); |
| 591 | const act = document.querySelector('.top-actions'); |
| 592 | const br = bar.getBoundingClientRect(); |
| 593 | const vis = [...act.children].filter(e => getComputedStyle(e).display !== 'none' && e.getClientRects().length); |
| 594 | const last = vis[vis.length - 1]; |
| 595 | const about = document.getElementById('about-btn'); |
| 596 | const ar = about && about.getBoundingClientRect(); |
| 597 | return { |
| 598 | clip: +(bar.scrollWidth - bar.clientWidth).toFixed(1), |
| 599 | slack: last ? +(br.right - last.getBoundingClientRect().right).toFixed(1) : null, |
| 600 | n: vis.length, |
| 601 | aboutIn: !!ar && ar.right <= br.right + 0.5 && ar.left >= br.left - 0.5, |
| 602 | aboutBox: ar ? Math.round(ar.width) + 'x' + Math.round(ar.height) : null, |
| 603 | }; |
| 604 | }); |
| 605 | check(`at ${w}px the header row is not clipped`, row.clip === 0 && row.slack >= 0, |
| 606 | `${row.n} control(s), clip ${row.clip}, slack ${row.slack}`); |
| 607 | check(`at ${w}px About is on the screen and still a thumb's target`, |
| 608 | row.aboutIn && parseInt(row.aboutBox, 10) >= 32, |
| 609 | `${row.aboutBox}, inside=${row.aboutIn}`); |
| 610 | } |
| 611 | await page.setViewportSize({ width: 1500, height: 950 }); |
| 612 | await page.waitForTimeout(300); |
| 613 | |
| 614 | // ── 7. Nothing in it is English by accident ────────────────── |
| 615 | // German, because it shares an alphabet with English — a check that switched |
| 616 | // to Japanese would pass on a hardcoded English string that simply had not |
| 617 | // been translated, because the assertion would be "not Latin". |
| 618 | await quieten(page); |
| 619 | await page.evaluate(async () => { await window.DaimondI18n.setLocale('de'); }); |
| 620 | await page.waitForTimeout(900); |
| 621 | await page.evaluate(() => document.getElementById('about-btn').click()); |
| 622 | await page.waitForTimeout(700); |
| 623 | const de = await page.evaluate(() => { |
| 624 | const c = document.querySelector('.about-card'); |
| 625 | const btn = document.getElementById('about-btn'); |
| 626 | return { |
| 627 | btn: btn.getAttribute('aria-label') || '', |
| 628 | said: (c.querySelector('.about-said').textContent || '').trim(), |
| 629 | alt: c.querySelector('.about-splash').getAttribute('alt') || '', |
| 630 | label: (c.querySelector('.about-ver-label').textContent || '').trim(), |
| 631 | close: c.querySelector('.tile-dlg-done').title || '', |
| 632 | links: [...c.querySelectorAll('a[href]')].map(a => a.getAttribute('aria-label') || ''), |
| 633 | }; |
| 634 | }); |
| 635 | // Asserted against the German TABLE, read from the same file the app reads, |
| 636 | // rather than against words written out here — a check that quotes the |
| 637 | // translation is a check that has to be edited every time the translation is. |
| 638 | const deTable = await page.evaluate(() => { |
| 639 | const keys = ['topbar.about', 'about.what', 'about.splash_alt', 'rel.version', |
| 640 | 'common.close', 'topbar.made_by', 'topbar.made_with_ai']; |
| 641 | const out = {}; |
| 642 | keys.forEach(k => { out[k] = window.DaimondI18n.t(k); }); |
| 643 | return out; |
| 644 | }); |
| 645 | check('EVERY STRING IN IT COMES BACK IN THE SECOND LANGUAGE', |
| 646 | de.said === deTable['about.what'] |
| 647 | && de.alt === deTable['about.splash_alt'] |
| 648 | && de.btn === deTable['topbar.about'] |
| 649 | && de.label === deTable['rel.version'] |
| 650 | && de.close === deTable['common.close'] |
| 651 | && de.links.includes(deTable['topbar.made_by']) |
| 652 | && de.links.includes(deTable['topbar.made_with_ai']), |
| 653 | JSON.stringify(de)); |
| 654 | // And the same again with the dialog ALREADY OPEN when the language changes. |
| 655 | // A surface built fresh in the new language is not evidence about one that |
| 656 | // was on screen at the switch: the strings have to be MARKED, not merely |
| 657 | // looked up, or the dialog stays in the language it was opened in for as |
| 658 | // long as it is up. That shape has shipped in this app twice. |
| 659 | await page.evaluate(async () => { await window.DaimondI18n.setLocale('fr'); }); |
| 660 | await page.waitForTimeout(900); |
| 661 | const live = await page.evaluate(() => { |
| 662 | const c = document.querySelector('.about-card'); |
| 663 | const fr = {}; |
| 664 | ['about.what', 'about.splash_alt', 'rel.version', 'common.close', |
| 665 | 'topbar.made_by', 'topbar.made_with_ai'].forEach(k => { fr[k] = window.DaimondI18n.t(k); }); |
| 666 | return { |
| 667 | up: !!c && c.getClientRects().length > 0, |
| 668 | said: c ? (c.querySelector('.about-said').textContent || '').trim() : '', |
| 669 | alt: c ? (c.querySelector('.about-splash').getAttribute('alt') || '') : '', |
| 670 | label: c ? (c.querySelector('.about-ver-label').textContent || '').trim() : '', |
| 671 | close: c ? (c.querySelector('.tile-dlg-done').title || '') : '', |
| 672 | links: c ? [...c.querySelectorAll('a[href]')].map(a => a.getAttribute('aria-label') || '') : [], |
| 673 | fr, |
| 674 | }; |
| 675 | }); |
| 676 | check('AND IT REPAINTS ITSELF WHEN THE LANGUAGE CHANGES UNDER IT', |
| 677 | live.up && live.said === live.fr['about.what'] |
| 678 | && live.alt === live.fr['about.splash_alt'] |
| 679 | && live.label === live.fr['rel.version'] |
| 680 | && live.close === live.fr['common.close'] |
| 681 | && live.links.includes(live.fr['topbar.made_by']) |
| 682 | && live.links.includes(live.fr['topbar.made_with_ai']), |
| 683 | JSON.stringify(live)); |
| 684 | |
| 685 | // And the artwork does NOT change with the language: the splash and the badge |
| 686 | // are marks, not copy. |
| 687 | check('and the artwork is the same artwork in any language', |
| 688 | await page.evaluate(() => { |
| 689 | const c = document.querySelector('.about-card'); |
| 690 | return /splash\.svg$/.test(c.querySelector('.about-splash').getAttribute('src')) |
| 691 | && /made_by_oxedyne\.svg$/.test(c.querySelector('.made-by img').getAttribute('src')); |
| 692 | })); |
| 693 | await page.evaluate(async () => { await window.DaimondI18n.setLocale('en'); }); |
| 694 | await sleep(600); |
| 695 | |
| 696 | // A dialog that draws itself while throwing is not drawing itself. |
| 697 | const errs = errors(s).filter(e => !/Failed to load resource/.test(e)); |
| 698 | check('nothing threw while it was on screen', errs.length === 0, errs.slice(0, 3).join(' | ')); |
| 699 | |
| 700 | await quieten(page); |
| 701 | await page.evaluate(() => document.getElementById('about-btn').click()); |
| 702 | await page.waitForTimeout(600); |
| 703 | await shot(s, 'about' + (BREAK ? '-' + BREAK : '')); |
| 704 | } catch (e) { |
| 705 | // A run that cannot get to the end of itself IS a failure, and one that says |
| 706 | // so in the same voice as the rest. Left to throw, the summary below never |
| 707 | // printed and a `--break` run exited 1 -- which is this file's signal for |
| 708 | // "the break broke nothing", the opposite of what had happened. |
| 709 | check('the run got to the end of itself', false, String(e && e.message ? e.message : e).split('\n')[0]); |
| 710 | try { await shot(s, 'about-threw' + (BREAK ? '-' + BREAK : '')); } catch (e2) { /* no picture either */ } |
| 711 | } finally { |
| 712 | await s.close(); |
| 713 | } |
| 714 | |
| 715 | if (BREAK) { |
| 716 | console.log(`\nbreak '${BREAK}': ${bad.length} check(s) failed` |
| 717 | + (bad.length ? ' — ' + bad.join('; ') : ' — NOTHING FAILED, so the checks above prove nothing')); |
| 718 | process.exit(bad.length ? 0 : 1); // a break MUST fail something |
| 719 | } |
| 720 | console.log(bad.length === 0 |
| 721 | ? `\nabout: all ${ok.length} checks passed` |
| 722 | : `\nabout: ${bad.length} of ${ok.length + bad.length} checks FAILED`); |
| 723 | process.exit(bad.length === 0 ? 0 : 1); |