Oregami
Repositories/oxedyne/daimond

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.
57import fs from 'node:fs';
58import path from 'node:path';
59import { fileURLToPath } from 'node:url';
60import { open, newChat, scratch, shot } from './harness.mjs';
61
62const HERE = path.dirname(fileURLToPath(import.meta.url));
63const WWW = path.join(HERE, '..', 'www');
64
65const 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.
71const 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};
105if (BREAK && !BREAKS[BREAK]) {
106 console.error(`unknown break '${BREAK}'; one of: ${Object.keys(BREAKS).join(', ')}`);
107 process.exit(2);
108}
109
110let bad = 0;
111const check = (pass, name, detail) => {
112 if (!pass) bad++;
113 console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : ''));
114};
115
116const 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
134const s = await open({
135 name: 'chathead',
136 profile: scratch('pw', 'chathead' + (BREAK ? '-' + BREAK : '')),
137 route: stub,
138});
139const { page: p } = s;
140if (BREAK) console.log(`\n*** RUNNING UNDER --break ${BREAK}: failures below are the point ***\n`);
141
142try {
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
258console.log(bad ? `\n${bad} check(s) FAILED` : '\nall checks passed');
259process.exit(bad ? 1 : 0);