Oregami
Repositories/oxedyne/daimond

oxedyne/daimond/dev/verify_render.mjs

32.3 KiB, 1 run

created by r2519314175:647, 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_render.mjs — a model's own fold, and what is allowed to ride inside it.
2//
3// `DaimondRender.md` is the one door model text comes through: `marked` first,
4// then a tag/attribute whitelist, and the result goes straight to `innerHTML`
5// on the chat, the crystal, the viewer and the Wire. Until 2026-08-21 neither
6// `DETAILS` nor `SUMMARY` was on that whitelist — nor on the drop list — so a
7// `<details>` fell to the unknown-wrapper branch and the WHOLE fold became one
8// text node. A model that folded part of an answer away had every heading, list
9// and code block in it destroyed for the trouble: worse off than if it had never
10// folded at all. That is the defect this file was written against.
11//
12// What is asserted, and why each one rather than the next thing:
13//
14// 1. THE FOLD IS A FOLD, AND ITS CONTENTS SURVIVED. A real `<details>` in the
15// output, with a real `<h2>` and a real `<ul>` inside it, hidden while it
16// is closed and shown when the summary is pressed. "Hidden" is not read off
17// a computed `display` — see the note on CONTAINMENT below, because the
18// obvious rect measurement is a trap here too.
19//
20// 2. NOTHING EXECUTABLE RIDES IN. The whole payload of an XSS attempt is put
21// INSIDE the fold — a `<script>`, an `<iframe>`, an `onclick`, an image
22// whose `onerror` sets a global, a `javascript:` link and an unknown
23// wrapper — and every one of them is asserted gone. This is the check that
24// admitting two tags did not quietly admit a subtree the sanitiser stops
25// walking.
26//
27// 3. NESTING. A fold inside a fold, opened one level at a time. The renderer
28// is not given a depth limit for `<details>` that it does not have for
29// `<div>`, so what is asserted is that the recursion composes.
30//
31// 4. A FOLD WITH NO SUMMARY. The renderer does NOT invent a label. The
32// browser draws its own, in the BROWSER's language rather than the app's,
33// and that is the accepted cost: a synthesised label needs a string in
34// eight locales, and inventing words the model did not write is not the
35// renderer's job. What is asserted is that the fold still works and that
36// no label was made up — the second half so the decision cannot be
37// reversed silently.
38//
39// 5. AN ORPHAN SUMMARY. A `<summary>` outside a `<details>` is a disclosure
40// label with nothing to disclose: the browser still draws the triangle, so
41// it reads as a control and does nothing. Its words are kept, its markup
42// is not.
43//
44// 6. `open`. A model saying the fold starts expanded is obeyed, and a model
45// saying nothing gets a closed fold.
46//
47// 7. STYLING, BOTH WAYS. A model's fold is drawn in the app's own language,
48// and the app's OWN `<details>` — the release notes' list of sealed
49// builds — is not, because the class is stamped by the sanitiser rather
50// than matched on a bare element.
51//
52// CONTAINMENT, AND WHY NOT A BARE RECT. A closed `<details>` in Chrome puts its
53// contents behind `content-visibility: hidden`, and a skipped subtree KEEPS ITS
54// LAST LAYOUT: `getBoundingClientRect()` on the heading inside a closed fold
55// comes back 588x32.5 with one client rect, sitting below the 28px-tall fold
56// that is supposedly containing it. Measured that way every check below would
57// pass on a fold that never opens. So "shown" is two geometric facts together —
58// the content's rect lies INSIDE the fold's own rect, and the point at its
59// centre hit-tests to something inside the fold — and "hidden" is the negation
60// of both. This is the same family as `verify_view`'s chip-in-a-scroller, found
61// four days earlier, and it is recorded here because the rect looked fine.
62//
63// EACH CHECK IS PROVED AGAINST BROKEN CODE FIRST. `--break <name>` serves a
64// deliberately damaged copy of a real file to the real page (through
65// `page.route`), and the run is then expected to FAIL. A break whose anchor does
66// not appear exactly once aborts rather than passing quietly.
67//
68// node dev/verify_render.mjs --break nofold # 1: THE DEFECT AS IT SHIPPED
69// node dev/verify_render.mjs --break norecurse # 2: scrub stops at the fold
70// node dev/verify_render.mjs --break letscript # 2: a <script> inside it
71// node dev/verify_render.mjs --break letiframe # 2: an <iframe> inside it
72// node dev/verify_render.mjs --break letonerror # 2: a handler that fires
73// node dev/verify_render.mjs --break letjsurl # 2: a javascript: link
74// node dev/verify_render.mjs --break synthsummary # 4: a label we invented
75// node dev/verify_render.mjs --break keeporphan # 5: a stray disclosure
76// node dev/verify_render.mjs --break noopen # 6: `open` thrown away
77// node dev/verify_render.mjs --break bareselector # 7: the app's own details
78// node dev/verify_render.mjs --break quietsummary # 7: the fold's label gone quiet
79// node dev/verify_render.mjs # and then, clean
80//
81// eval "$(bash dev/world.sh 5 --up)"
82// node dev/verify_render.mjs
83//
84// Needs dev/serve.mjs only. No gateway, and no model: the renderer is handed
85// the text a model WOULD have written, so nothing here spends anything.
86import fs from 'node:fs';
87import path from 'node:path';
88import { fileURLToPath } from 'node:url';
89import { open, shot, scratch, errors } from './harness.mjs';
90
91const HERE = path.dirname(fileURLToPath(import.meta.url));
92const WWW = path.join(HERE, '..', 'www');
93
94const BREAK = (() => {
95 const i = process.argv.indexOf('--break');
96 return i > 0 ? String(process.argv[i + 1] || '') : '';
97})();
98
99const PROFILE = scratch('pw', 'render' + (BREAK ? '-' + BREAK : ''));
100fs.rmSync(PROFILE, { recursive: true, force: true });
101
102const ok = [], bad = [];
103const check = (name, pass, detail) => {
104 (pass ? ok : bad).push(name);
105 console.log((pass ? ' ok ' : ' FAIL ') + name + (detail != null ? ' — ' + detail : ''));
106};
107
108// ── The breaks ───────────────────────────────────────────────────────
109// Each is a real edit to a real file, served in place of it. `find` must appear
110// exactly once, or the run below would prove the opposite of what it claims.
111const BREAKS = {
112 // THE DEFECT AS IT SHIPPED, restored exactly: the whitelist without the two
113 // tags on it. Not a synthetic break — this is the file as it stood before
114 // the fix, so what reddens under it is what a reader saw in the product.
115 nofold: [{
116 file: 'js/render.js',
117 find: "\t\t'A', 'ABBR', 'B', 'BLOCKQUOTE', 'BR', 'CODE', 'DEL', 'DETAILS', 'DIV',\n"
118 + "\t\t'EM', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'HR', 'I', 'IMG', 'KBD',\n"
119 + "\t\t'LI', 'OL', 'P', 'PRE', 'S', 'SPAN', 'STRONG', 'SUB', 'SUMMARY', 'SUP',\n"
120 + "\t\t'TABLE', 'TBODY', 'TD', 'TH', 'THEAD', 'TR', 'U', 'UL',\n",
121 with: "\t\t'A', 'ABBR', 'B', 'BLOCKQUOTE', 'BR', 'CODE', 'DEL', 'DIV', 'EM',\n"
122 + "\t\t'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'HR', 'I', 'IMG', 'KBD', 'LI',\n"
123 + "\t\t'OL', 'P', 'PRE', 'S', 'SPAN', 'STRONG', 'SUB', 'SUP', 'TABLE',\n"
124 + "\t\t'TBODY', 'TD', 'TH', 'THEAD', 'TR', 'U', 'UL',\n",
125 }],
126 // The subtree left unwalked. Admitting a container and then not scrubbing
127 // what is in it is the exact shape of a whitelist that has been widened
128 // without thinking, and it is invisible from the outside of the fold.
129 norecurse: [{
130 file: 'js/render.js',
131 find: '\t\t\tscrub(ch);',
132 with: "\t\t\tif (tag !== 'DETAILS') scrub(ch);",
133 }],
134 // A `<script>` no longer dropped whole, and allowed as an ordinary element.
135 letscript: [
136 { file: 'js/render.js',
137 find: "\t\t'SCRIPT', 'STYLE', 'IFRAME', 'OBJECT', 'EMBED', 'LINK', 'META',",
138 with: "\t\t'STYLE', 'IFRAME', 'OBJECT', 'EMBED', 'LINK', 'META'," },
139 { file: 'js/render.js',
140 find: "\t\t'A', 'ABBR', 'B', 'BLOCKQUOTE', 'BR', 'CODE', 'DEL', 'DETAILS', 'DIV',",
141 with: "\t\t'SCRIPT', 'A', 'ABBR', 'B', 'BLOCKQUOTE', 'BR', 'CODE', 'DEL', 'DETAILS', 'DIV'," },
142 ],
143 // The same for an `<iframe>`, which is the one that loads a foreign origin
144 // without any script at all.
145 letiframe: [
146 { file: 'js/render.js',
147 find: "\t\t'SCRIPT', 'STYLE', 'IFRAME', 'OBJECT', 'EMBED', 'LINK', 'META',",
148 with: "\t\t'SCRIPT', 'STYLE', 'OBJECT', 'EMBED', 'LINK', 'META'," },
149 { file: 'js/render.js',
150 find: "\t\t'A', 'ABBR', 'B', 'BLOCKQUOTE', 'BR', 'CODE', 'DEL', 'DETAILS', 'DIV',",
151 with: "\t\t'IFRAME', 'A', 'ABBR', 'B', 'BLOCKQUOTE', 'BR', 'CODE', 'DEL', 'DETAILS', 'DIV'," },
152 ],
153 // An event handler on the attribute whitelist. `onerror` rather than
154 // `onclick` on purpose: an image inserted by `innerHTML` really does fail
155 // and really does fire, so this break proves the "nothing ran" check by
156 // making something run, rather than by making an attribute visible.
157 letonerror: [{
158 file: 'js/render.js',
159 find: "\tvar ATTR_OK = wordSet(['CLASS', 'TITLE', 'ALT', 'ALIGN']);",
160 with: "\tvar ATTR_OK = wordSet(['CLASS', 'TITLE', 'ALT', 'ALIGN', 'ONERROR']);",
161 }],
162 // The URL guard opened to the scheme it exists to refuse.
163 letjsurl: [{
164 file: 'js/render.js',
165 find: '\t\tif (/^(https?:|mailto:|#|\\/)/i.test(v)) return true;',
166 with: '\t\tif (/^(https?:|mailto:|javascript:|#|\\/)/i.test(v)) return true;',
167 }],
168 // The decision about a missing `<summary>`, reversed: a label invented by
169 // the renderer, in English, whatever the app is set to.
170 synthsummary: [{
171 file: 'js/render.js',
172 find: "\t\t\tif (tag === 'DETAILS') ch.classList.add('md-fold');",
173 with: "\t\t\tif (tag === 'DETAILS') {\n"
174 + "\t\t\t\tch.classList.add('md-fold');\n"
175 + "\t\t\t\tif (!ch.querySelector('summary')) {\n"
176 + "\t\t\t\t\tvar sm = document.createElement('summary');\n"
177 + "\t\t\t\t\tsm.textContent = 'Details';\n"
178 + "\t\t\t\t\tch.insertBefore(sm, ch.firstChild);\n"
179 + "\t\t\t\t}\n"
180 + "\t\t\t}",
181 }],
182 // A stray `<summary>` kept as markup: a triangle in the middle of a
183 // paragraph that looks pressable and is not.
184 keeporphan: [{
185 file: 'js/render.js',
186 find: "\t\t\tvar orphanSummary = (tag === 'SUMMARY' && node.nodeName !== 'DETAILS');",
187 with: '\t\t\tvar orphanSummary = false;',
188 }],
189 // `open` thrown away with every other unvetted attribute, so a model that
190 // asks for an expanded fold gets a closed one.
191 noopen: [{
192 file: 'js/render.js',
193 find: "\t\t\t\tif (!keep && up === 'OPEN' && tag === 'DETAILS') keep = true;\n",
194 with: '',
195 }],
196 // The stylesheet matching a bare `details`, so the app's own disclosures
197 // take the styling meant for a model's fold. This is what the stamped class
198 // exists to prevent and nothing else would notice it.
199 bareselector: [{
200 file: 'css/app.css',
201 find: 'details.md-fold {',
202 with: 'details {',
203 }],
204 // The summary handed back to the quiet treatment it carried before 4017d78,
205 // which is the regression that change exists to prevent: a fold's label is
206 // the substance of the half that is hidden, and set in the muted ink at the
207 // small size it reads as a caption for something rather than a thing to
208 // read. Nothing else on the page moves, so only 7a can catch it.
209 quietsummary: [{
210 file: 'css/app.css',
211 find: 'details.md-fold > summary {\n\tpadding: 4px 0;\n\tcolor: var(--text-primary);',
212 with: 'details.md-fold > summary {\n\tpadding: 4px 0;\n\tcolor: var(--text-muted);',
213 }],
214};
215
216if (BREAK && !BREAKS[BREAK]) {
217 console.error(`unknown break '${BREAK}'; one of: ${Object.keys(BREAKS).join(', ')}`);
218 process.exit(2);
219}
220
221/// `src` with `spec` applied, or a hard stop.
222function damaged(src, spec) {
223 const n = src.split(spec.find).length - 1;
224 if (n !== 1) {
225 console.error(`break '${BREAK}': the anchor appears ${n} times in ${spec.file}, `
226 + 'so nothing was broken and the run below would prove nothing.');
227 process.exit(2);
228 }
229 return src.replace(spec.find, spec.with);
230}
231
232const TYPE = { '.html': 'text/html', '.css': 'text/css', '.js': 'application/javascript' };
233
234/// The damaged files, ONE BODY PER FILE. Playwright hands a request to the LAST
235/// route registered for its URL, so two routes on one file ship only the second
236/// edit — and a two-edit break then goes red for half the reason it claims.
237function damagedFiles() {
238 const byFile = new Map();
239 for (const spec of (BREAKS[BREAK] || [])) {
240 const src = byFile.has(spec.file) ? byFile.get(spec.file)
241 : fs.readFileSync(path.join(WWW, spec.file), 'utf8');
242 byFile.set(spec.file, damaged(src, spec));
243 }
244 return byFile;
245}
246
247async function serveBreaks(page) {
248 if (!BREAK) return;
249 for (const [file, body] of damagedFiles()) {
250 const type = TYPE[path.extname(file)] || 'text/plain';
251 await page.route('**/' + file, r => r.fulfill({ status: 200, contentType: type, body }));
252 }
253}
254
255// ── The text a model would have written ──────────────────────────────
256//
257// TWO SHAPES OF THE SAME FOLD, and the difference between them is not this
258// file's to fix. CommonMark says an HTML block runs to the next blank line, so
259// `<details>` with its content pressed up against it is ONE raw block and the
260// markdown inside is never parsed — `## heading` stays four characters and a
261// space. Blank lines around the inner markdown are what make it markdown. Both
262// are asserted, each for what it can prove: the tight one that the fold is no
263// longer flattened, the spaced one that structure inside it survives.
264const TIGHT = '<details><summary>gist</summary>\n## heading\n- a\n- b\n</details>';
265const SPACED = '<details>\n<summary>the short of it</summary>\n\n'
266 + '## heading\n\n- a\n- b\n\n```js\nvar x = 1;\n```\n\n</details>';
267const NESTED = '<details>\n<summary>outer</summary>\n\n'
268 + '<details>\n<summary>inner</summary>\n\nthe innermost thing\n\n</details>\n\n</details>';
269const NOSUM = '<details>\n\n## unlabelled\n\n</details>';
270const ORPHAN = 'before\n\n<summary>stray</summary>\n\nafter';
271const OPENED = '<details open>\n<summary>already open</summary>\n\n## expanded\n\n</details>';
272// The whole of an attack, inside the fold. Every line of it is a separate thing
273// the sanitiser exists to stop, and the image is the one that really fires.
274const ATTACK = '<details>\n<summary onclick="window.__pwned=1">gist</summary>\n\n'
275 + '<script>window.__pwned=1;<\/script>\n\n'
276 + '<iframe src="/index.html"></iframe>\n\n'
277 + '<img src="/no-such-image-9104.png" onerror="window.__pwned=1">\n\n'
278 + '<a href="javascript:window.__pwned=1">a link</a>\n\n'
279 + '<p onclick="window.__pwned=1">a paragraph</p>\n\n'
280 + '<marquee>an unknown wrapper</marquee>\n\n</details>';
281
282// ── Driving ──────────────────────────────────────────────────────────
283//
284// No sign-in and no provider: the renderer is a pure function of its text, and
285// the gate in front of the app does not change what `DaimondRender.md` returns.
286const s = await open({
287 name: 'render', profile: PROFILE, signIn: false, connect: false,
288 route: serveBreaks,
289});
290const page = s.page;
291
292try {
293 await page.waitForFunction(() => window.DaimondRender && window.DaimondRender.md,
294 null, { timeout: 20000 });
295
296 // One host for every sample, laid over the page so a hit test is answered by
297 // the fold rather than by whatever the app happens to be showing behind it.
298 // `.chat-msg-content` because that is the class the chat renders into, so the
299 // message-body rules apply here exactly as they do in the product.
300 await page.evaluate((src) => {
301 const box = document.createElement('div');
302 box.id = 'render-probe';
303 box.className = 'chat-msg-content';
304 box.style.cssText = 'position:fixed;left:0;top:0;width:640px;'
305 + 'z-index:2147483647;background:var(--bg-primary,#000);padding:8px';
306 document.body.appendChild(box);
307 for (const [k, text] of Object.entries(src)) {
308 const cell = document.createElement('div');
309 cell.dataset.sample = k;
310 cell.innerHTML = window.DaimondRender.md(text);
311 box.appendChild(cell);
312 }
313 }, { TIGHT, SPACED, NESTED, NOSUM, ORPHAN, OPENED, ATTACK });
314
315 // The attack image has to be given time to fail before "nothing ran" means
316 // anything: an `onerror` that has not fired yet is not an `onerror` that was
317 // stripped, and the two are indistinguishable at zero milliseconds.
318 await page.waitForTimeout(900);
319
320 /// Everything measured in one pass, so a check below reads a number rather
321 /// than driving the page itself.
322 const geom = await page.evaluate(() => {
323 const box = document.getElementById('render-probe');
324 const cell = (k) => box.querySelector(`[data-sample="${k}"]`);
325 // SHOWN, in two geometric facts at once. See the file header: a closed
326 // fold's contents keep their last layout, so a rect with area proves
327 // nothing on its own.
328 const shown = (el) => {
329 if (!el) return { rect: false, hit: false };
330 const fold = el.closest('details');
331 const a = fold.getBoundingClientRect(), c = el.getBoundingClientRect();
332 const inside = c.height > 0 && c.top >= a.top - 0.5 && c.bottom <= a.bottom + 0.5;
333 const at = document.elementFromPoint(c.left + c.width / 2, c.top + c.height / 2);
334 return { rect: inside, hit: !!at && fold.contains(at) };
335 };
336 const out = {};
337
338 // 1 — the fold, and what is in it.
339 const tight = cell('TIGHT').querySelector('details');
340 out.tight = tight ? {
341 stamped: tight.classList.contains('md-fold'),
342 // The flattened form has NO element children at all; this is the
343 // difference between a fold and one text node.
344 elements: tight.querySelectorAll('*').length,
345 text: cell('TIGHT').textContent.replace(/\s+/g, ' ').trim(),
346 } : null;
347 // THE DEFECT, NAMED. Flattened, the whole fold is one text node and
348 // the cell has nothing else in it. This is the shape the product
349 // shipped, and it is what `--break nofold` puts back.
350 const tcell = cell('TIGHT');
351 out.tightFlat = tcell.childNodes.length === 1 && tcell.firstChild.nodeType === 3;
352 out.tightText = tcell.textContent.replace(/\s+/g, ' ').trim();
353
354 const sp = cell('SPACED').querySelector('details');
355 out.spaced = sp ? {
356 summary: sp.querySelector(':scope > summary')
357 ? sp.querySelector(':scope > summary').textContent.trim() : null,
358 h2: !!sp.querySelector('h2'),
359 lis: sp.querySelectorAll('ul > li').length,
360 closedH: +sp.getBoundingClientRect().height.toFixed(1),
361 shutH2: shown(sp.querySelector('h2')),
362 } : null;
363 if (sp) {
364 sp.querySelector(':scope > summary').click();
365 out.spaced.openH = +sp.getBoundingClientRect().height.toFixed(1);
366 out.spaced.openH2 = shown(sp.querySelector('h2'));
367 // The code-block transform runs on the string AFTER sanitisation, so
368 // this is the check that the trusted pass still finds a fence that
369 // is inside a fold rather than beside one.
370 out.spaced.codeBlock = !!sp.querySelector('.code-block .code-copy-btn');
371 out.spaced.styles = {
372 border: getComputedStyle(sp).borderLeftWidth,
373 colour: getComputedStyle(sp.querySelector(':scope > summary')).color,
374 };
375 sp.open = false;
376 }
377
378 // 2 — the attack.
379 const atk = cell('ATTACK');
380 const fold = atk.querySelector('details');
381 const on = [];
382 if (fold) for (const el of fold.querySelectorAll('*')) {
383 for (const a of el.attributes) if (/^on/i.test(a.name)) on.push(el.tagName + '@' + a.name);
384 }
385 out.attack = {
386 fold: !!fold,
387 scripts: atk.querySelectorAll('script').length,
388 frames: atk.querySelectorAll('iframe').length,
389 handlers: on,
390 jsHrefs: [...atk.querySelectorAll('a')].filter(a => /^javascript:/i.test(a.getAttribute('href') || '')).length,
391 // `<marquee>` is not on the whitelist, so it must have been reduced
392 // to its words — inside the fold, which is where the recursion is.
393 marquees: atk.querySelectorAll('marquee').length,
394 marqueeWords: atk.textContent.includes('an unknown wrapper'),
395 rels: [...atk.querySelectorAll('a')].map(a => a.getAttribute('rel')),
396 ran: typeof window.__pwned !== 'undefined',
397 };
398
399 // 3 — nesting.
400 const outer = cell('NESTED').querySelector('details');
401 const inner = outer ? outer.querySelector('details') : null;
402 out.nested = {
403 both: !!outer && !!inner,
404 stamped: !!inner && inner.classList.contains('md-fold'),
405 body: !!inner && !!inner.querySelector('p'),
406 };
407 if (inner) {
408 out.nested.shut = shown(inner.querySelector('p'));
409 outer.open = true;
410 out.nested.outerOnly = shown(inner.querySelector('p'));
411 inner.open = true;
412 out.nested.bothOpen = shown(inner.querySelector('p'));
413 outer.open = false; inner.open = false;
414 }
415
416 // 4 — a fold with no summary.
417 const ns = cell('NOSUM').querySelector('details');
418 out.nosum = ns ? {
419 invented: !!ns.querySelector('summary'),
420 h2: !!ns.querySelector('h2'),
421 shut: shown(ns.querySelector('h2')),
422 // Where a real mouse must land to hit the label the BROWSER drew.
423 at: (() => { const r = ns.getBoundingClientRect(); return { x: r.left + 24, y: r.top + 8 }; })(),
424 } : null;
425
426 // 5 — an orphan summary.
427 out.orphan = {
428 survived: cell('ORPHAN').querySelectorAll('summary').length,
429 words: cell('ORPHAN').textContent.includes('stray'),
430 };
431
432 // 6 — `open`.
433 const op = cell('OPENED').querySelector('details');
434 out.opened = op ? { open: op.open, h2: shown(op.querySelector('h2')) } : null;
435
436 // 7 — the app's own disclosure, which must NOT be dressed as a model's.
437 const mine = document.createElement('details');
438 mine.innerHTML = '<summary>the app\'s own</summary><p>body</p>';
439 box.appendChild(mine);
440 out.appOwn = { border: getComputedStyle(mine).borderLeftWidth };
441 mine.remove();
442 return out;
443 });
444
445 console.log(JSON.stringify(geom, null, 1));
446
447 // ── 1. The fold is a fold, and its contents survived ──────────────
448 check('1a a `<details>` survives the renderer at all',
449 !!geom.tight, geom.tight ? null : 'flattened to: ' + JSON.stringify(geom.tightText));
450 // The tight form's INNER markdown is not parsed and is not expected to be:
451 // CommonMark runs an HTML block to the next blank line, so `## heading` here
452 // is four characters and a space, exactly as it was before the fix. What
453 // this asserts is the fold itself — that the cell is not one text node with
454 // every element in it gone.
455 check('1b the fold is not one flat text node',
456 geom.tightFlat === false && !!geom.tight && geom.tight.elements >= 1,
457 geom.tight ? geom.tight.elements + ' element(s), flat=' + geom.tightFlat
458 : 'flat=' + geom.tightFlat + ' ' + JSON.stringify(geom.tightText));
459 check('1c the sanitiser stamped it, so the stylesheet can find it',
460 !!geom.tight && geom.tight.stamped);
461 check('1d a heading and a two-item list survive INSIDE the fold',
462 !!geom.spaced && geom.spaced.h2 && geom.spaced.lis === 2,
463 geom.spaced ? `h2=${geom.spaced.h2} li=${geom.spaced.lis}` : 'no fold');
464 check('1e the summary carries the model\'s own words',
465 !!geom.spaced && geom.spaced.summary === 'the short of it',
466 geom.spaced ? JSON.stringify(geom.spaced.summary) : null);
467 check('1f closed, the heading is not shown — neither contained nor hit',
468 !!geom.spaced && !geom.spaced.shutH2.rect && !geom.spaced.shutH2.hit,
469 geom.spaced ? JSON.stringify(geom.spaced.shutH2) : null);
470 check('1g pressing the summary shows it — contained AND hit, and the fold grew',
471 !!geom.spaced && geom.spaced.openH2.rect && geom.spaced.openH2.hit
472 && geom.spaced.openH > geom.spaced.closedH,
473 geom.spaced ? `${geom.spaced.closedH} → ${geom.spaced.openH}px, ${JSON.stringify(geom.spaced.openH2)}` : null);
474
475 check('1h a fenced code block inside the fold keeps its Copy button',
476 !!geom.spaced && geom.spaced.codeBlock === true,
477 geom.spaced ? String(geom.spaced.codeBlock) : 'no fold');
478
479 // ── 2. Nothing executable rides in ──────────────────────────────────
480 check('2a no `<script>` survives inside the fold',
481 geom.attack.scripts === 0, String(geom.attack.scripts));
482 check('2b no `<iframe>` survives inside the fold',
483 geom.attack.frames === 0, String(geom.attack.frames));
484 check('2c no `on*` handler survives on anything inside the fold',
485 geom.attack.handlers.length === 0, geom.attack.handlers.join(', '));
486 check('2d and nothing RAN: the image failed with no handler left on it',
487 geom.attack.ran === false, geom.attack.ran ? 'window.__pwned was set' : null);
488 check('2e a `javascript:` link did not survive inside the fold',
489 geom.attack.jsHrefs === 0, String(geom.attack.jsHrefs));
490 check('2f an unknown wrapper inside the fold is still reduced to its words',
491 geom.attack.marquees === 0 && geom.attack.marqueeWords,
492 `marquee=${geom.attack.marquees} words=${geom.attack.marqueeWords}`);
493 check('2g links inside the fold still get rel="noopener noreferrer nofollow"',
494 geom.attack.rels.length > 0 && geom.attack.rels.every(r => r === 'noopener noreferrer nofollow'),
495 JSON.stringify(geom.attack.rels));
496
497 // ── 3. Nesting ────────────────────────────────────────────────────
498 check('3a a fold inside a fold is two folds, both stamped',
499 geom.nested.both && geom.nested.stamped, JSON.stringify(geom.nested));
500 check('3b the inner fold\'s body survives as markup',
501 geom.nested.body);
502 // Absent measurements read as "not shown" rather than throwing, so a break
503 // that removes the fold altogether still reports every check it damaged
504 // instead of stopping the run at the first missing property.
505 const sh = (x) => x || { rect: false, hit: false };
506 check('3c it opens one level at a time — shut, outer only, then both',
507 !sh(geom.nested.shut).rect && !sh(geom.nested.shut).hit
508 && !sh(geom.nested.outerOnly).rect && !sh(geom.nested.outerOnly).hit
509 && sh(geom.nested.bothOpen).rect && sh(geom.nested.bothOpen).hit,
510 JSON.stringify({ shut: geom.nested.shut, outerOnly: geom.nested.outerOnly, both: geom.nested.bothOpen }));
511
512 // ── 4. A fold with no summary ─────────────────────────────────────
513 check('4a the renderer invents no label for an unlabelled fold',
514 !!geom.nosum && geom.nosum.invented === false,
515 geom.nosum ? String(geom.nosum.invented) : 'no fold');
516 check('4b its content survives and is hidden while it is shut',
517 !!geom.nosum && geom.nosum.h2 && !geom.nosum.shut.rect && !geom.nosum.shut.hit,
518 geom.nosum ? JSON.stringify(geom.nosum.shut) : null);
519 if (geom.nosum) {
520 // A real mouse, on the label the browser drew for itself. `details.click()`
521 // would not do: the UA's summary is in a shadow root, so a synthetic click
522 // on the `<details>` never reaches the thing that toggles it.
523 await page.mouse.click(geom.nosum.at.x, geom.nosum.at.y);
524 await page.waitForTimeout(150);
525 const after = await page.evaluate(() => {
526 const ns = document.querySelector('[data-sample="NOSUM"] details');
527 const h2 = ns.querySelector('h2');
528 const a = ns.getBoundingClientRect(), c = h2.getBoundingClientRect();
529 const at = document.elementFromPoint(c.left + c.width / 2, c.top + c.height / 2);
530 return { open: ns.open, rect: c.top >= a.top - 0.5 && c.bottom <= a.bottom + 0.5,
531 hit: !!at && ns.contains(at) };
532 });
533 check('4c the browser\'s own label still works as a disclosure',
534 after.open && after.rect && after.hit, JSON.stringify(after));
535 }
536
537 // ── 5. An orphan summary ──────────────────────────────────────────
538 check('5a a `<summary>` outside a fold does not survive as markup',
539 geom.orphan.survived === 0, String(geom.orphan.survived));
540 check('5b but its words do',
541 geom.orphan.words);
542
543 // ── 6. `open` ─────────────────────────────────────────────────────
544 check('6a `<details open>` starts expanded',
545 !!geom.opened && geom.opened.open && geom.opened.h2.rect && geom.opened.h2.hit,
546 geom.opened ? JSON.stringify(geom.opened) : 'no fold');
547 check('6b `<details>` without it starts closed',
548 !!geom.spaced && geom.spaced.closedH < geom.spaced.openH,
549 geom.spaced ? `${geom.spaced.closedH}px closed` : null);
550
551 // ── 7. Styling, both ways ─────────────────────────────────────────
552 // WHICH INK, AND WHY IT MOVED. This read `--text-muted` until 4017d78, when
553 // the summary was deliberately given the answer's own size and ink: a fold's
554 // label is now a sentence or two of what the fold concludes, so it has to be
555 // legible at the weight of the reply it belongs to, and the left edge and
556 // the triangle are what say it is foldED. The muted treatment stayed with
557 // the thinking tile, which is the model's own working and must not compete.
558 // So the token asked for here is `--text-primary`. Left pointing at the old
559 // one, this check has been red since seq 147 about a change it was never
560 // told of -- three releases went out over it.
561 const ink = await page.evaluate(() =>
562 getComputedStyle(document.documentElement).getPropertyValue('--text-primary').trim());
563 const asRgb = await page.evaluate((hex) => {
564 const d = document.createElement('span'); d.style.color = hex;
565 document.body.appendChild(d); const c = getComputedStyle(d).color; d.remove(); return c;
566 }, ink);
567 check('7a a model\'s fold is drawn in the app\'s language, not the browser\'s',
568 !!geom.spaced && geom.spaced.styles.border === '2px'
569 && geom.spaced.styles.colour === asRgb,
570 geom.spaced ? JSON.stringify(geom.spaced.styles) + ' vs ' + asRgb : null);
571 check('7b the app\'s OWN `<details>` is untouched by it',
572 geom.appOwn.border === '0px', geom.appOwn.border);
573
574 // ── 8. The copy button COPIES, which is not the same as being there ──
575 //
576 // `7`'s `out.spaced.codeBlock` asserts a `.code-copy-btn` is on the page, and that
577 // is all it asserts. On 2026-08-28 `dev/mutate.mjs` changed the class the delegated
578 // listener in www/js/render.js looks for -- from `code-copy-btn` to something
579 // nothing wears -- and every check in this file stayed green while the button under
580 // every code block silently stopped working. The element was still there; only the
581 // behaviour had gone, and presence was the only thing being asked about.
582 //
583 // So this presses it and reads what the press did. The clipboard itself is not
584 // available to a headless page without a permission grant, and the point here is
585 // the WIRING rather than the platform: a press that reaches the handler changes the
586 // button's label and adds `copied`, and a press that reaches nothing leaves both
587 // exactly as they were. That is the difference the mutation makes, so that is what
588 // is measured.
589 const copied = await s.page.evaluate(async () => {
590 const btn = document.querySelector('.code-block .code-copy-btn');
591 if (!btn) return { found: false };
592 const before = btn.textContent;
593 btn.click();
594 // The handler runs a promise before it relabels, so give the microtask queue a
595 // turn rather than reading the button in the same tick that pressed it.
596 await new Promise(r => setTimeout(r, 250));
597 return {
598 found: true,
599 before,
600 after: btn.textContent,
601 marked: btn.classList.contains('copied'),
602 changed: btn.textContent !== before || btn.classList.contains('copied'),
603 };
604 });
605 check('8 the copy button under a code block is WIRED, not merely present',
606 copied.found && copied.changed,
607 copied.found
608 ? `label ${JSON.stringify(copied.before)} -> ${JSON.stringify(copied.after)}, `
609 + `copied=${copied.marked}; unchanged means the press reached no handler`
610 : 'no .code-copy-btn on the page at all');
611
612 // The renderer drawing itself while throwing is not drawing itself. The
613 // attack's own 404 is expected — the image is MEANT to fail, that is what
614 // makes 2d mean anything — so it is not counted as the app throwing.
615 const errs = errors(s).filter(e => !/no-such-image-9104/.test(e))
616 .filter(e => !/Failed to load resource/.test(e));
617 check('nothing threw while it was on screen', errs.length === 0, errs.slice(0, 3).join(' | '));
618
619 await shot(s, 'render-fold' + (BREAK ? '-' + BREAK : ''));
620} catch (e) {
621 // A run that cannot get to the end of itself IS a failure, and one that says
622 // so in the same voice as the rest.
623 check('the run got to the end of itself', false, String(e && e.message ? e.message : e).split('\n')[0]);
624 try { await shot(s, 'render-threw' + (BREAK ? '-' + BREAK : '')); } catch (e2) { /* no picture either */ }
625} finally {
626 await s.close();
627}
628
629if (BREAK) {
630 console.log(`\nbreak '${BREAK}': ${bad.length} check(s) failed`
631 + (bad.length ? ' — ' + bad.join('; ') : ' — NOTHING FAILED, so the checks above prove nothing'));
632 process.exit(bad.length ? 0 : 1); // a break MUST fail something
633}
634console.log(bad.length === 0
635 ? `\nrender: all ${ok.length} checks passed`
636 : `\nrender: ${bad.length} of ${ok.length + bad.length} checks FAILED`);
637process.exit(bad.length === 0 ? 0 : 1);