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. |
| 86 | import fs from 'node:fs'; |
| 87 | import path from 'node:path'; |
| 88 | import { fileURLToPath } from 'node:url'; |
| 89 | import { open, shot, scratch, errors } from './harness.mjs'; |
| 90 | |
| 91 | const HERE = path.dirname(fileURLToPath(import.meta.url)); |
| 92 | const WWW = path.join(HERE, '..', 'www'); |
| 93 | |
| 94 | const BREAK = (() => { |
| 95 | const i = process.argv.indexOf('--break'); |
| 96 | return i > 0 ? String(process.argv[i + 1] || '') : ''; |
| 97 | })(); |
| 98 | |
| 99 | const PROFILE = scratch('pw', 'render' + (BREAK ? '-' + BREAK : '')); |
| 100 | fs.rmSync(PROFILE, { recursive: true, force: true }); |
| 101 | |
| 102 | const ok = [], bad = []; |
| 103 | const 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. |
| 111 | const 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 | |
| 216 | if (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. |
| 222 | function 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 | |
| 232 | const 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. |
| 237 | function 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 | |
| 247 | async 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. |
| 264 | const TIGHT = '<details><summary>gist</summary>\n## heading\n- a\n- b\n</details>'; |
| 265 | const 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>'; |
| 267 | const NESTED = '<details>\n<summary>outer</summary>\n\n' |
| 268 | + '<details>\n<summary>inner</summary>\n\nthe innermost thing\n\n</details>\n\n</details>'; |
| 269 | const NOSUM = '<details>\n\n## unlabelled\n\n</details>'; |
| 270 | const ORPHAN = 'before\n\n<summary>stray</summary>\n\nafter'; |
| 271 | const 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. |
| 274 | const 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. |
| 286 | const s = await open({ |
| 287 | name: 'render', profile: PROFILE, signIn: false, connect: false, |
| 288 | route: serveBreaks, |
| 289 | }); |
| 290 | const page = s.page; |
| 291 | |
| 292 | try { |
| 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 | |
| 629 | if (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 | } |
| 634 | console.log(bad.length === 0 |
| 635 | ? `\nrender: all ${ok.length} checks passed` |
| 636 | : `\nrender: ${bad.length} of ${ok.length + bad.length} checks FAILED`); |
| 637 | process.exit(bad.length === 0 ? 0 : 1); |