oxedyne/daimond/dev/verify_chathead.mjs
12.3 KiB, 1 run
created by r2519314175:273, 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_chathead.mjs — the conversation can be taken out whole, and on a phone |
| 2 | // the header's controls can be reached and the box you type in has room. |
| 3 | // |
| 4 | // THREE THINGS REPORTED FROM A PHONE, 2026-08-19, in the user's own words: |
| 5 | // |
| 6 | // "I want a copy button in the header of chats and daimons that copies the |
| 7 | // entire transcript to the clipboard." |
| 8 | // "In mobile, this header does not scroll so I can't see a lot of buttons." |
| 9 | // "In mobile, the text prompt input box is too narrow beside the three arrow |
| 10 | // icons." |
| 11 | // |
| 12 | // MEASURED BEFORE ANY OF IT WAS CHANGED, on a 390px viewport: the input bar is |
| 13 | // 372px wide and the text box had 170 of it — Send and the two walk-back |
| 14 | // chevrons take 46px each plus gaps, so three buttons held more of the row than |
| 15 | // the thing the row exists for. The header's chip row did not overflow either; |
| 16 | // it squeezed the title to an ellipsis and then ran off the end of the panel, |
| 17 | // which is why buttons could not be reached rather than merely not seen. |
| 18 | // |
| 19 | // FOUR PROPERTIES: |
| 20 | // |
| 21 | // 1. THE WHOLE CONVERSATION COPIES, and what lands on the clipboard is the |
| 22 | // words — both sides, in order, as plain text. Read back out of the |
| 23 | // clipboard and not off the button, because a button that says "Copied" |
| 24 | // having copied nothing is the failure this is for. |
| 25 | // 2. AND THE BUTTON SAYS SO, briefly, then goes back to being a button. |
| 26 | // 3. ON A PHONE THE HEADER SCROLLS. Asserted as `scrollWidth > clientWidth` |
| 27 | // together with an overflow that permits scrolling — the pair, because a row |
| 28 | // that fits scrolls nowhere and a row that overflows with `visible` is the |
| 29 | // state the user reported. |
| 30 | // 4. AND THE COMPOSER HAS ROOM. The text box takes more than half the bar, |
| 31 | // which it did not: 170 of 372 is 46%. |
| 32 | // 5. AND IT NEVER SAYS YOU CANNOT TYPE WHEN YOU CAN. The box used to read |
| 33 | // "Paused. Press play on its tile" whenever the Diamond's `/self` leaf was |
| 34 | // held — which nothing enforces: that leaf's only reader in the whole app |
| 35 | // was the placeholder, so a held Diamond answered a typed message exactly as |
| 36 | // a running one does. It arrived unasked, too: any Diamond shipping with a |
| 37 | // triggered action is seeded held, and pausing Everything writes the flag |
| 38 | // onto every leaf. Asserted as the PAIR — the ordinary placeholder AND a |
| 39 | // message that actually goes through — because changing the wording alone |
| 40 | // would satisfy either half on its own. |
| 41 | // |
| 42 | // PROVED AGAINST BROKEN CODE FIRST: |
| 43 | // |
| 44 | // node dev/verify_chathead.mjs --break nocopy # 1 and 2 fail: the button copies nothing |
| 45 | // node dev/verify_chathead.mjs --break noscroll # 3 fails: the row overflows unreachably |
| 46 | // node dev/verify_chathead.mjs --break inbar # 4 fails: the chevrons take the row back |
| 47 | // node dev/verify_chathead.mjs --break paused # 5 fails: a held Diamond says you cannot type |
| 48 | // node dev/verify_chathead.mjs # and then, clean |
| 49 | // |
| 50 | // `--break inbar` restores the measured original exactly — three 46px buttons in |
| 51 | // the bar — so check 4's red is the reported defect and not an invented one. |
| 52 | // |
| 53 | // eval "$(bash dev/world.sh 4 --up)" |
| 54 | // node dev/verify_chathead.mjs |
| 55 | // |
| 56 | // Needs dev/serve.mjs and the mock. No gateway, no wasm rebuild. |
| 57 | import fs from 'node:fs'; |
| 58 | import path from 'node:path'; |
| 59 | import { fileURLToPath } from 'node:url'; |
| 60 | import { open, newChat, scratch, shot } from './harness.mjs'; |
| 61 | |
| 62 | const HERE = path.dirname(fileURLToPath(import.meta.url)); |
| 63 | const WWW = path.join(HERE, '..', 'www'); |
| 64 | |
| 65 | const BREAK = (() => { |
| 66 | const i = process.argv.indexOf('--break'); |
| 67 | return i > 0 ? String(process.argv[i + 1] || '') : ''; |
| 68 | })(); |
| 69 | |
| 70 | // Each break is one real edit to one real file, served in its place. |
| 71 | const BREAKS = { |
| 72 | nocopy: { |
| 73 | file: 'js/daimond.js', |
| 74 | find: "\t\tvar text = transcriptOf(current);", |
| 75 | with: "\t\tvar text = '';", |
| 76 | }, |
| 77 | // BOTH AXES. The first version of this break set `overflow-x: visible` and left |
| 78 | // `overflow-y: hidden`, and NOTHING went red — because CSS says that when one |
| 79 | // axis is not `visible` the other computes to `auto`, so the break was a no-op |
| 80 | // that read as a passing check. A break that does not redden is a finding, and |
| 81 | // this one's finding was about the break. |
| 82 | noscroll: { |
| 83 | file: 'css/responsive.css', |
| 84 | find: "\t\toverflow-x: auto;\n\t\toverflow-y: hidden;\n\t\tscrollbar-width: none;", |
| 85 | with: "\t\toverflow: visible;\n\t\tscrollbar-width: none;", |
| 86 | }, |
| 87 | // Restores the sentence this file's fifth property exists to keep out. A break |
| 88 | // that ADDS code rather than removing it, because the defect was an addition. |
| 89 | paused: { |
| 90 | file: 'js/daimond.js', |
| 91 | find: "\t\tchatInput.placeholder = g ? t('chat.queue_ph') : t('chat.input_ph');", |
| 92 | with: "\t\tchatInput.placeholder = g ? t('chat.queue_ph')\n" |
| 93 | + "\t\t\t: (current && current.diamondId && (function (i) { try { return !!(window.DaimondPause" |
| 94 | + " && DaimondPause.isPaused(DaimondPause.id('root','diamonds',i) + '/self')); }" |
| 95 | + " catch (e) { return false; } })(current.diamondId))\n" |
| 96 | + "\t\t\t\t? 'Paused. Press play on its tile'\n" |
| 97 | + "\t\t\t\t: t('chat.input_ph');", |
| 98 | }, |
| 99 | inbar: { |
| 100 | file: 'css/responsive.css', |
| 101 | find: "\t.chat-input-bar #chat-jump,\n\t.chat-input-bar #chat-end {\n\t\tposition: absolute;", |
| 102 | with: "\t.chat-input-bar #chat-jump,\n\t.chat-input-bar #chat-end {\n\t\tposition: static;", |
| 103 | }, |
| 104 | }; |
| 105 | if (BREAK && !BREAKS[BREAK]) { |
| 106 | console.error(`unknown break '${BREAK}'; one of: ${Object.keys(BREAKS).join(', ')}`); |
| 107 | process.exit(2); |
| 108 | } |
| 109 | |
| 110 | let bad = 0; |
| 111 | const check = (pass, name, detail) => { |
| 112 | if (!pass) bad++; |
| 113 | console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : '')); |
| 114 | }; |
| 115 | |
| 116 | const stub = async (page) => { |
| 117 | if (!BREAK) return; |
| 118 | const spec = BREAKS[BREAK]; |
| 119 | const src = fs.readFileSync(path.join(WWW, spec.file), 'utf8'); |
| 120 | // An anchor that is not there exactly once patches nothing and the run would |
| 121 | // pass quietly, which is worse than a red. |
| 122 | if (src.split(spec.find).length !== 2) { |
| 123 | console.error(`break '${BREAK}': its anchor is not in ${spec.file} exactly once`); |
| 124 | process.exit(2); |
| 125 | } |
| 126 | const body = src.replace(spec.find, spec.with); |
| 127 | await page.route('**/' + spec.file, (r) => r.fulfill({ |
| 128 | status: 200, |
| 129 | contentType: spec.file.endsWith('.css') ? 'text/css' : 'application/javascript', |
| 130 | body, |
| 131 | })); |
| 132 | }; |
| 133 | |
| 134 | const s = await open({ |
| 135 | name: 'chathead', |
| 136 | profile: scratch('pw', 'chathead' + (BREAK ? '-' + BREAK : '')), |
| 137 | route: stub, |
| 138 | }); |
| 139 | const { page: p } = s; |
| 140 | if (BREAK) console.log(`\n*** RUNNING UNDER --break ${BREAK}: failures below are the point ***\n`); |
| 141 | |
| 142 | try { |
| 143 | try { await p.context().grantPermissions(['clipboard-read', 'clipboard-write']); } |
| 144 | catch (e) { /* the read below reports it */ } |
| 145 | |
| 146 | await newChat(s); |
| 147 | const ASK = '@text the answer is forty two'; |
| 148 | await p.fill('#chat-input', ASK); |
| 149 | await p.click('#chat-send'); |
| 150 | await p.waitForTimeout(4000); |
| 151 | |
| 152 | // ── 1 and 2. The whole conversation, out ───────────────────── |
| 153 | const btn = await p.evaluate(() => { |
| 154 | const b = document.getElementById('chat-copy-btn'); |
| 155 | return b ? { label: b.innerHTML, w: Math.round(b.getBoundingClientRect().width), |
| 156 | // An icon-only control is unusable to a screen reader without one, and the row is now |
| 157 | // five drawings: if the labels go, nothing on it can be named. |
| 158 | aria: b.getAttribute('aria-label') || '', title: b.title || '' } : null; |
| 159 | }); |
| 160 | check(!!(btn && btn.w > 0), 'there is a copy control in the chat header at all', |
| 161 | btn ? `${btn.w}px wide` : 'no #chat-copy-btn'); |
| 162 | // A PICTURE STILL HAS TO HAVE A NAME. The row was words and is now drawings; without these a |
| 163 | // screen reader is handed five unlabelled buttons and a pointer user has nothing to hover. |
| 164 | check(!!(btn && btn.aria && btn.title), |
| 165 | 'and being a drawing it carries a name and a tooltip', |
| 166 | btn ? `aria=${JSON.stringify(btn.aria)} title=${JSON.stringify(btn.title.slice(0, 30))}` : ''); |
| 167 | |
| 168 | await p.click('#chat-copy-btn'); |
| 169 | await p.waitForTimeout(700); |
| 170 | const said = await p.evaluate(() => document.getElementById('chat-copy-btn').innerHTML); |
| 171 | const clip = await p.evaluate(async () => { |
| 172 | try { return await navigator.clipboard.readText(); } catch (e) { return 'CLIPBOARD UNREADABLE: ' + e.message; } |
| 173 | }); |
| 174 | // BOTH SIDES. Asserting only the question would pass on a transcript that |
| 175 | // copied what the user typed and none of the answer, which is the likelier |
| 176 | // half to go missing and the half worth having. |
| 177 | check(clip.includes(ASK) && clip.includes('the answer is forty two'), |
| 178 | 'THE WHOLE CONVERSATION IS ON THE CLIPBOARD — both sides of it', |
| 179 | JSON.stringify(clip.slice(0, 90))); |
| 180 | check(/##\s*You/.test(clip) && /##\s*Daimond/.test(clip), |
| 181 | 'with who said what, so it reads as a conversation somewhere else', |
| 182 | JSON.stringify(clip.slice(0, 60))); |
| 183 | check(said !== (btn && btn.label), |
| 184 | 'AND THE BUTTON SAYS SO rather than looking as though nothing happened', |
| 185 | `changed: ${said !== (btn && btn.label)}`); |
| 186 | |
| 187 | // ── 3 and 4. A phone ───────────────────────────────────────── |
| 188 | await p.setViewportSize({ width: 390, height: 844 }); |
| 189 | await p.waitForTimeout(1200); |
| 190 | const m = await p.evaluate(() => { |
| 191 | const right = document.querySelector('#panel-ai .chead-right'); |
| 192 | const bar = document.querySelector('.chat-input-bar'); |
| 193 | const inp = document.getElementById('chat-input'); |
| 194 | const r = (el) => el ? Math.round(el.getBoundingClientRect().width) : 0; |
| 195 | return { |
| 196 | scrollW: right ? right.scrollWidth : 0, |
| 197 | clientW: right ? right.clientWidth : 0, |
| 198 | overflow: right ? getComputedStyle(right).overflowX : '', |
| 199 | barW: r(bar), |
| 200 | inputW: r(inp), |
| 201 | // The chevrons, to say in the detail WHY the box is the width it is. |
| 202 | jumpPos: getComputedStyle(document.getElementById('chat-jump')).position, |
| 203 | }; |
| 204 | }); |
| 205 | await shot(s, 'chathead-phone'); |
| 206 | |
| 207 | // THE PAIR, not either alone. A row that fits has nothing to scroll and would |
| 208 | // satisfy an overflow test; a row that overflows with `visible` is exactly the |
| 209 | // reported defect and would satisfy a width test. |
| 210 | check(m.scrollW > m.clientW && (m.overflow === 'auto' || m.overflow === 'scroll'), |
| 211 | 'ON A PHONE THE HEADER SCROLLS — the row overflows AND may be dragged', |
| 212 | `scrollWidth ${m.scrollW} vs client ${m.clientW}, overflow-x ${m.overflow}`); |
| 213 | check(m.inputW > m.barW * 0.6, |
| 214 | 'AND THE BOX YOU TYPE IN HAS ROOM — more than 60% of the bar', |
| 215 | `${m.inputW} of ${m.barW} (${Math.round(100 * m.inputW / m.barW)}%), chevrons ${m.jumpPos}`); |
| 216 | // ── 5. A held Diamond does not claim you cannot type ───────── |
| 217 | await p.setViewportSize({ width: 1500, height: 950 }); |
| 218 | await p.waitForTimeout(600); |
| 219 | await p.evaluate(() => document.getElementById('new-diamond-btn').click()); |
| 220 | await p.waitForSelector('.dlg-card', { timeout: 8000 }); |
| 221 | await p.evaluate(() => { |
| 222 | const card = [...document.querySelectorAll('.dlg-card')].filter(c => c.getClientRects().length).pop(); |
| 223 | const inp = card.querySelector('input.dlg-input'); |
| 224 | inp.value = 'Held'; inp.dispatchEvent(new Event('input', { bubbles: true })); |
| 225 | card.querySelector('.dlg-ok').click(); |
| 226 | }); |
| 227 | await p.waitForTimeout(1600); |
| 228 | // The GLOBAL control, which is how the state arrives without anybody touching |
| 229 | // the Diamond: `set` writes the flag onto every leaf under it. |
| 230 | const held = await p.evaluate(() => { |
| 231 | const g = [...document.querySelectorAll('.pptw')].find(e => (e.dataset.pauseNode || '') === 'root'); |
| 232 | if (!g) return false; |
| 233 | g.querySelector('.pptw-pause').click(); |
| 234 | return true; |
| 235 | }); |
| 236 | await p.waitForTimeout(900); |
| 237 | await p.click('#dview-chat'); |
| 238 | await p.waitForTimeout(900); |
| 239 | const ph = await p.evaluate(() => document.getElementById('chat-input').placeholder); |
| 240 | check(held, 'Everything can be paused from the rail head', held ? '' : 'no root PPTW found'); |
| 241 | check(!/pause/i.test(ph), |
| 242 | 'A HELD DIAMOND DOES NOT TELL YOU THE BOX WILL NOT WORK', |
| 243 | JSON.stringify(ph)); |
| 244 | // The other half, and the one that makes the first mean something: it works. |
| 245 | await p.fill('#chat-input', '@text held and answered'); |
| 246 | await p.click('#chat-send'); |
| 247 | await p.waitForTimeout(5000); |
| 248 | const answered = await p.evaluate(() => |
| 249 | [...document.querySelectorAll('#chat-output .chat-msg-assistant .chat-msg-content')] |
| 250 | .some(e => /held and answered/.test(e.textContent))); |
| 251 | check(answered, |
| 252 | 'and it answers, which is why saying otherwise was wrong rather than merely unhelpful', |
| 253 | answered ? '' : 'the turn did not answer while held'); |
| 254 | } finally { |
| 255 | await s.close(); |
| 256 | } |
| 257 | |
| 258 | console.log(bad ? `\n${bad} check(s) FAILED` : '\nall checks passed'); |
| 259 | process.exit(bad ? 1 : 0); |