Oregami
Repositories/oxedyne/daimond

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.
84import fs from 'node:fs';
85import path from 'node:path';
86import { fileURLToPath } from 'node:url';
87import { open, shot, scratch, errors, signInAs, APP } from './harness.mjs';
88
89const HERE = path.dirname(fileURLToPath(import.meta.url));
90const WWW = path.join(HERE, '..', 'www');
91
92const BREAK = (() => {
93 const i = process.argv.indexOf('--break');
94 return i > 0 ? String(process.argv[i + 1] || '') : '';
95})();
96
97const PROFILE = scratch('pw', 'about' + (BREAK ? '-' + BREAK : ''));
98fs.rmSync(PROFILE, { recursive: true, force: true });
99
100const ok = [], bad = [];
101const 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.
113const SPLASH = fs.readFileSync(path.join(WWW, 'assets', 'splash.svg'), 'utf8');
114const 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})();
120const 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.
125const 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
224if (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.
230function 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
240const 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.
249function 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
259async 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
277const IS_UP = () => {
278 const c = document.querySelector('.about-card');
279 return !!c && c.getClientRects().length > 0 && getComputedStyle(c).visibility !== 'hidden';
280};
281
282const 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.
287async 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.
294async 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
302const s = await open({ name: 'about', signIn: false, connect: false, profile: PROFILE });
303const { page } = s;
304await 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.
307await page.goto(APP, { waitUntil: 'domcontentloaded' });
308await signInAs(s, 'about');
309await page.waitForTimeout(1500);
310
311try {
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
715if (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}
720console.log(bad.length === 0
721 ? `\nabout: all ${ok.length} checks passed`
722 : `\nabout: ${bad.length} of ${ok.length + bad.length} checks FAILED`);
723process.exit(bad.length === 0 ? 0 : 1);