Oregami
Repositories/oxedyne/daimond

oxedyne/daimond/dev/verify_daimonface.mjs

31.6 KiB, 1 run

created by r2519314175:345, 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_daimonface.mjs — the Centre knows which face it is showing, and says so once.
2//
3// WHAT THIS IS FOR. The Centre has three faces and one variable that names the one it is
4// showing: `centreMode`, written only by `showCentre`, holding one of `CENTRE_FACES` —
5// `'chat'` (an ordinary chat), `'focus'` (a Diamond's crystal) and `'daimon'` (a Diamond's
6// own conversation, drawn in the chat's thread). Three guards ask which face is up before
7// they draw, and all three are about the same thing: IS THIS CONVERSATION THE ONE ON SCREEN.
8//
9// * "Fresh daimon" empties a daimon's conversation, and has to repaint the thread. The
10// dialog it is pressed in overlays the app, so what is behind it is stale until
11// something repaints it — and closing a dialog repaints nothing. It also has to make the
12// emptying STICK, which is a separate claim and was the false one: the transcript merge
13// is a deliberate UNION, so an emptied array saved straight back was refilled from the
14// store, and the conversation returned on the next reload with the screen none the wiser.
15// * Fold belongs to the conversation a hand fold can REACH, and to no other. It was
16// drawn on the daimon's face and nowhere else, which is precisely backwards: a
17// Diamond engine's session holds no messages, so `fold_now` there compacts nothing
18// and bills for the round; an ordinary chat is the one that can be folded by hand.
19// * A daimon's reply streams into the thread AS IT ARRIVES, and only when that thread is
20// the one on screen. A turn can run from a gather round with its Diamond nowhere near
21// the screen, and drawing then puts one conversation's words in another's transcript.
22//
23// WHY THIS FILE EXISTS, which is not the same as what it checks. `centreMode` was declared
24// with the comment `'chat' | 'focus'` and had been holding `'daimon'` since a Diamond grew
25// two faces — `selectDiamond` calls `showCentre('daimon')`. Read from the declaration alone,
26// the three guards above are three dead branches, each a real feature that has supposedly
27// never run, and the obvious repair is to make all three live at once. They were already
28// live. The COMMENT was the defect, and it was very nearly paid for three times over.
29//
30// So the check that matters most here is the fourth, and it is worth more than the other
31// three: THE DOCUMENTED FACES, THE FACES ANYTHING ENTERS, AND THE FACES ANYTHING COMPARES
32// AGAINST MUST BE THE SAME SET. A comparison against a face nothing enters is a dead branch;
33// a face documented but never entered is a promise nothing keeps; a write to `centreMode`
34// from outside `showCentre` puts the app in a face the list never sanctioned. None of the
35// three can be seen by looking at any one line.
36//
37// EACH CHECK IS PROVED AGAINST BROKEN CODE FIRST.
38//
39// node dev/verify_daimonface.mjs --break no-redraw # 1a fails: Fresh daimon stops repainting
40// node dev/verify_daimonface.mjs --break resurrect # 1b+1d fail: the union puts it all back
41// node dev/verify_daimonface.mjs --break sessionless # 1c fails: the model keeps the conversation
42// node dev/verify_daimonface.mjs --break clear-midturn # 1e fails: a clear a turn then undoes
43// node dev/verify_daimonface.mjs --break fold-drift # 2 fails: Fold goes back to the daimon
44// node dev/verify_daimonface.mjs --break crosstalk # 3a fails: a reply lands in another thread
45// node dev/verify_daimonface.mjs --break mute # 3b fails: nothing streams into its own
46// node dev/verify_daimonface.mjs --break dead-compare # 4a+4b fail: a face nothing enters
47// node dev/verify_daimonface.mjs --break ghost-face # 4c fails: a face documented and unreachable
48// node dev/verify_daimonface.mjs --break side-assign # 4d+4e fail: `centreMode` written elsewhere
49// node dev/verify_daimonface.mjs --break indirect-compare # 4e fails: the comparison hides
50// node dev/verify_daimonface.mjs --break unhelped # NOTHING fails, and that is the point
51// node dev/verify_daimonface.mjs # and then, clean
52//
53// WHAT THE FOURTH CHECK STILL CANNOT SEE, attacked and confirmed rather than guessed at:
54//
55// * A face entered ONLY from code that never runs — `if (false) showCentre('stage');` —
56// counts as entered, so 4b and 4c are satisfied by a face nothing reaches. Reading the
57// source cannot tell a live call from a dead one, and this file does not pretend to.
58// * The comment stripper knows strings but not REGEX LITERALS, so a regex containing a lone
59// quote (`/['"]/`) desynchronises it and can swallow the code after it. Nothing in the
60// file does that today and 4e's residue scan would notice the loss of a whole comparison,
61// but a regex is where this breaks first, and that is where to look.
62// * It is a scan of one file. `centreMode` is a closure variable and no other file can see
63// it, which is the only reason that is enough.
64//
65// `crosstalk` and `mute` are two breaks and not one because the guard has TWO ways to be
66// wrong and a check for either alone passes while the other is broken: `mute` makes it always
67// false, so nothing is ever drawn live and no thread is ever polluted; `crosstalk` makes it
68// forget WHICH thread, so every thread on screen is drawn into and the right one is drawn
69// into too. A single check would have been green under one of them.
70//
71// `unhelped` is the odd one and is not a break at all: it restores the raw compound conditions
72// the one helper replaced. It MUST leave every check green, which is how this file says that
73// collapsing the copies into `daimonOnScreen` was a refactor and not a change of behaviour. Red
74// there is a failure of the refactor, and the run says so. It covers TWO sites and not three:
75// `syncFoldBtn` no longer asks which face is up at all, since a daimon's record is `current` on
76// both of a Diamond's faces and `diamondId` therefore answers for both.
77//
78// THE HARNESS. Use a world, or the run silently drives world 0 on :8777 and says nothing:
79//
80// eval "$(bash dev/world.sh 3 --env)" && node dev/verify_daimonface.mjs
81import fs from 'node:fs';
82import path from 'node:path';
83import { fileURLToPath } from 'node:url';
84import { open, signInAs, steerDiamond, newChat } from './harness.mjs';
85
86const HERE = path.dirname(fileURLToPath(import.meta.url));
87const WWW = path.join(HERE, '..', 'www');
88const SRC = 'js/daimond.js';
89
90const BREAK = (() => {
91 const i = process.argv.indexOf('--break');
92 return i > 0 ? String(process.argv[i + 1] || '') : '';
93})();
94
95// Every break names its sites. A break may name several, and every one of them has to land:
96// an anchor that no longer matches is a break that quietly stopped applying, and a green run
97// under it proves nothing at all.
98const BREAKS = {
99 // The repaint after "Fresh daimon" simply never runs. The record still clears — which is
100 // the point of asserting on what is DRAWN rather than on what is stored.
101 'no-redraw': {
102 find: " if (daimonOnScreen(rec)) {\n await selectDiamond(f, 'chat');",
103 with: " if (false) {\n await selectDiamond(f, 'chat');",
104 },
105 // THE OLD CONDITION, restored: Fold drawn on the daimon's face and on no other. It is a
106 // real comparison against a real face, so every static check stays green and only the
107 // control checks move — which is the point, because for months this was the shipped
108 // behaviour and nothing in this file objected. It must redden 2a (the control is back on
109 // the face that cannot use it) and 2c (it is gone from the one that can).
110 'fold-drift': {
111 find: " b.style.display = (current && !current.diamondId && chatSaid(current)) ? '' : 'none';",
112 with: " b.style.display = (daimonOnScreen(current) && current.diamondId) ? '' : 'none';",
113 },
114 // The guard forgets WHICH conversation and remembers only that some conversation is up.
115 crosstalk: {
116 find: " var onScreen = function () { return daimonOnScreen(rec); };",
117 with: " var onScreen = function () { return !!(current && centreMode === 'daimon'); };",
118 },
119 // The tombstones come off, so the union puts the conversation straight back. The SCREEN
120 // still clears, which is what made this so hard to see: every check that watches the
121 // thread stays green and only the store, and a reload, know anything happened.
122 resurrect: {
123 find: " msgTombstone((rec.messages || []).map(function (m) { return m.mid; }));\n",
124 with: "",
125 },
126 // The model's own conversation goes back to `null` — "this tab does not know" — which
127 // `persistChats` fills in from the store. Invisible on screen: the thread is empty and the
128 // next turn quietly carries the whole discarded conversation to the model, and is billed
129 // for it.
130 sessionless: {
131 find: " rec.session = { v: 1, msgs: [], upto: '', uptoTs: 0 };",
132 with: " rec.session = null;",
133 },
134 // The refusal comes off, so a clear can be taken mid-turn and undone by the turn.
135 'clear-midturn': {
136 find: " if (rec._generating) {\n await noticeDialog(t('tile.daimon_reset_busy_title'), t('tile.daimon_reset_busy'));\n return;\n }",
137 with: " if (false) { return; }",
138 },
139 // The guard is always false — the state the whole file was mistakenly believed to be in.
140 mute: {
141 find: " var onScreen = function () { return daimonOnScreen(rec); };",
142 with: " var onScreen = function () { return false; };",
143 },
144 // A comparison against a face nothing ever enters: the dead branch, in its natural form —
145 // somebody writing the DIAMOND view's word ('crystal') where the CENTRE's word ('focus')
146 // belongs. Deliberately placed at a site none of the three behaviour checks measures, so
147 // it proves the static check and not one of them.
148 'dead-compare': {
149 find: " toast(centreMode === 'focus'",
150 with: " toast(centreMode === 'crystal'",
151 },
152 // A face in the list that nothing enters: a promise the app does not keep.
153 'ghost-face': {
154 find: " var CENTRE_FACES = ['chat', 'focus', 'daimon'];",
155 with: " var CENTRE_FACES = ['chat', 'focus', 'daimon', 'stage'];",
156 },
157 // A second writer for `centreMode`, which makes every reading above unsound: the set of
158 // faces the app can be in is no longer the set `showCentre` sanctions. Behaviour here is
159 // unchanged, which is exactly why nothing but the static check can see it.
160 'side-assign': {
161 find: " showCentre('daimon');\n renderHistory(rec.messages);",
162 with: " centreMode = 'daimon';\n showCentre('daimon');\n renderHistory(rec.messages);",
163 },
164 // The comparison goes through a variable, so the scan for literal comparisons stops seeing
165 // it. Nothing about the app changes except that one face is now compared against a name --
166 // and 4a and 4b go green on a set one face SMALLER without a word about the one that left.
167 // This is the original defect wearing a disguise, and 4e is what strips it.
168 'indirect-compare': {
169 find: " return !!(rec && current && current.id === rec.id && centreMode === 'daimon');",
170 with: " var face = 'daimon';\n return !!(rec && current && current.id === rec.id && centreMode === face);",
171 },
172 // Not a break: the three raw conditions the helper replaced, restored. Everything must
173 // stay green.
174 unhelped: [
175 {
176 find: " if (daimonOnScreen(rec)) {",
177 with: " if (currentDiamond && currentDiamond.id === id && centreMode === 'daimon') {",
178 },
179 {
180 find: " var onScreen = function () { return daimonOnScreen(rec); };",
181 with: " var onScreen = function () {\n return !!(current && rec && current.id === rec.id && centreMode === 'daimon');\n };",
182 },
183 ],
184};
185// The one that must stay green. Named here rather than tested by string, so adding another
186// equivalence break is one line and not a scattered special case.
187const MUST_STAY_GREEN = new Set(['unhelped']);
188
189const ok = [], bad = [];
190const check = (name, pass, detail) => {
191 (pass ? ok : bad).push(name + (detail ? ' — ' + detail : ''));
192 console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : ''));
193};
194
195// ── The source under test, broken or not ────────────────────────────────
196//
197// The STATIC checks below read this string and not the file on disk, so a break that is a
198// static regression really does turn its check red.
199let source = fs.readFileSync(path.join(WWW, SRC), 'utf8');
200if (BREAK) {
201 const spec = BREAKS[BREAK];
202 if (!spec) { console.error(`no such break: ${BREAK}`); process.exit(2); }
203 for (const site of (Array.isArray(spec) ? spec : [spec])) {
204 const n = source.split(site.find).length - 1;
205 if (n !== 1) {
206 console.error(`break '${BREAK}': the anchor appears ${n} times in ${SRC}, so nothing `
207 + 'was broken and the run below would prove nothing. The file has moved on; '
208 + 'move the anchor with it.\n ' + site.find.split('\n')[0].trim());
209 process.exit(2);
210 }
211 source = source.replace(site.find, site.with);
212 }
213}
214
215// ── 4. The regression check: one set of faces, agreed on from three directions ──
216//
217// COMMENTS ARE STRIPPED FIRST, and they have to be: the declaration of `CENTRE_FACES` carries
218// a paragraph about the day this went wrong, and that paragraph quotes `centreMode ===
219// 'daimon'`. Scanned raw, the check reads its own explanation as code. This is a line-and-block
220// stripper that respects quotes; it does not understand regex literals, which is stated rather
221// than hidden — `centreMode` never appears in one, and if it ever does, this comment is the
222// place the next reader will look.
223function stripComments(s) {
224 let out = '', i = 0, n = s.length;
225 while (i < n) {
226 const c = s[i], d = s[i + 1];
227 if (c === '/' && d === '/') { while (i < n && s[i] !== '\n') i++; continue; }
228 if (c === '/' && d === '*') { i += 2; while (i < n && !(s[i] === '*' && s[i + 1] === '/')) i++; i += 2; continue; }
229 if (c === '"' || c === "'" || c === '`') {
230 out += c; i++;
231 while (i < n && s[i] !== c) { if (s[i] === '\\') { out += s[i]; i++; } out += s[i]; i++; }
232 out += s[i]; i++; continue;
233 }
234 out += c; i++;
235 }
236 return out;
237}
238const code = stripComments(source);
239
240// What the file says the faces are.
241const facesDecl = /var CENTRE_FACES = \[([^\]]*)\];/.exec(code);
242const documented = facesDecl
243 ? [...facesDecl[1].matchAll(/'([^']*)'/g)].map(m => m[1])
244 : [];
245check('the faces are declared in one place the checks can read',
246 documented.length > 0, documented.join(', ') || '(CENTRE_FACES not found)');
247
248// What anything actually enters. Every call must pass a literal, or none of this is sound:
249// a computed face would mean the set cannot be read from the source at all.
250const calls = [...code.matchAll(/(?<!function )showCentre\(([^)]*)\)/g)]
251 .map(m => m[1].trim())
252 .filter(a => a !== 'mode');
253const nonLiteral = calls.filter(a => !/^'[^']*'$/.test(a));
254check('4d. EVERY `showCentre` CALL NAMES A FACE OUTRIGHT, so the set can be read at all',
255 nonLiteral.length === 0, nonLiteral.join(' | ') || `${calls.length} calls, all literal`);
256const entered = [...new Set(calls.filter(a => /^'[^']*'$/.test(a)).map(a => a.slice(1, -1)))];
257
258// What anything compares against.
259const compared = [...new Set([...code.matchAll(/centreMode\s*[=!]==\s*'([^']*)'/g)].map(m => m[1]))];
260check('the checks found real comparisons to judge', compared.length > 0, compared.join(', '));
261
262// 4a. Nothing compares against a face the list does not name.
263const undocumented = compared.filter(v => documented.indexOf(v) < 0);
264check('4a. NOTHING COMPARES `centreMode` AGAINST A FACE THE LIST DOES NOT NAME',
265 undocumented.length === 0,
266 undocumented.length ? 'stray: ' + undocumented.join(', ') : compared.join(', '));
267
268// 4b. Nothing compares against a face nothing enters. THIS is the dead branch, and it is the
269// one that hid here for months: a guard that can never be true, guarding a real feature.
270const unreachable = compared.filter(v => entered.indexOf(v) < 0);
271check('4b. NOTHING COMPARES AGAINST A FACE NOTHING EVER ENTERS (the dead branch)',
272 unreachable.length === 0,
273 unreachable.length ? 'dead: ' + unreachable.join(', ') : 'entered: ' + entered.join(', '));
274
275// 4c. Nothing is documented that cannot be entered.
276const promised = documented.filter(v => entered.indexOf(v) < 0);
277check('4c. EVERY DOCUMENTED FACE IS ONE SOMETHING ENTERS',
278 promised.length === 0,
279 promised.length ? 'never entered: ' + promised.join(', ') : documented.join(', '));
280
281// 4d. `centreMode` is written in exactly two places: its declaration, and `showCentre`, which
282// refuses a face the list does not name. A third writer makes every reading above unsound.
283const writes = [...code.matchAll(/centreMode\s*=\s*([^=][^;]*);/g)].map(m => m[1].trim());
284check('4d. `centreMode` IS WRITTEN ONLY BY ITS DECLARATION AND BY `showCentre`',
285 writes.length === 2 && writes[0] === "'chat'" && writes[1] === 'mode',
286 writes.join(' | '));
287check('and `showCentre` refuses a face the list does not name',
288 /CENTRE_FACES\.indexOf\(mode\) < 0/.test(code), 'the guard is in showCentre');
289
290// 4e. EVERY USE OF THE NAME IS ONE OF THREE SHAPES. Without this, 4a and 4b are read straight
291// past by `var face = 'stage'; if (centreMode === face)` and by `switch (centreMode)`: the
292// comparison simply stops being visible, and the checks above go green on a SMALLER set than
293// before while saying nothing about what left it — which is the original defect exactly,
294// reintroduced under the check written to catch it. Attacked and confirmed. So the file is
295// allowed only the declaration, `showCentre`'s own write, and comparison against a literal.
296const residue = code
297 .replace(/var centreMode = '[^']*';/g, '')
298 .replace(/centreMode\s*=\s*mode;/g, '')
299 .replace(/centreMode\s*[=!]==\s*'[^']*'/g, '');
300const stray = [...residue.matchAll(/.{0,34}\bcentreMode\b.{0,34}/g)]
301 .map(m => m[0].replace(/\s+/g, ' ').trim());
302check('4e. `centreMode` IS ONLY DECLARED, SET BY `showCentre`, OR COMPARED TO A LITERAL FACE',
303 stray.length === 0, stray.join(' ¦ ') || 'no other use of the name');
304
305// ── The browser half ────────────────────────────────────────────────────
306const APP = process.env.DAIMOND_APP || 'http://localhost:8777';
307console.log(`\ndriving ${APP}${process.env.DAIMOND_APP ? '' : ' (NO WORLD SET — this is world 0)'}\n`);
308
309const s = await open({
310 name: 'daimonface', defaults: false,
311 route: BREAK ? async (page) => {
312 await page.route('**/' + SRC, r => r.fulfill({
313 status: 200, contentType: 'application/javascript', body: source,
314 }));
315 } : null,
316});
317const { page } = s;
318
319/// What the Centre is actually drawing, read the way a reader sees it.
320const drawn = () => page.evaluate(() => {
321 const co = document.getElementById('chat-output');
322 const vis = e => !!e && !!(e.offsetWidth || e.offsetHeight || e.getClientRects().length);
323 const fold = document.getElementById('chat-fold-btn');
324 return {
325 users: co ? [...co.querySelectorAll('.chat-msg-user')].map(e => (e.textContent || '').trim()) : [],
326 empty: !!(co && co.querySelector('.chat-msg-empty')),
327 text: co ? (co.textContent || '').replace(/\s+/g, ' ') : '',
328 fold: vis(fold),
329 };
330});
331
332/// Open a Diamond by name, on the face asked for.
333async function openDiamond(name, face) {
334 await page.evaluate((n) => {
335 const box = [...document.querySelectorAll('.diamond-box')]
336 .find(b => (b.textContent || '').indexOf(n) >= 0);
337 if (!box) throw new Error('no Diamond named ' + n);
338 box.click();
339 }, name);
340 await page.waitForTimeout(900);
341 await page.click('#dview-' + face, { force: true });
342 await page.waitForTimeout(900);
343}
344
345async function makeDiamond(name) {
346 await page.click('#new-diamond-btn', { force: true });
347 await page.waitForSelector('.dlg-input', { timeout: 10000 });
348 await page.fill('.dlg-input', name);
349 await page.click('.dlg-ok', { force: true });
350 await page.waitForTimeout(1800);
351}
352
353/// Press "Fresh daimon" on a Diamond's cog dialog and answer the confirm.
354///
355/// The confirm's OK is scoped to the dialog that is NOT the tile's own, because the tile
356/// dialog's Delete at the foot is also a `.dlg-ok` and sits earlier in the document. An
357/// unscoped `.dlg-ok` deletes the Diamond, which reads exactly like a thread that cleared
358/// itself — it cost this file an hour to notice.
359async function freshDaimon(name) {
360 await page.evaluate((n) => {
361 const box = [...document.querySelectorAll('.diamond-box')]
362 .find(b => (b.textContent || '').indexOf(n) >= 0);
363 box.querySelector('.tile-cog').click();
364 }, name);
365 await page.waitForTimeout(800);
366 const found = await page.evaluate(() => {
367 const b = [...document.querySelectorAll('.tile-dlg-clear')]
368 .find(e => /fresh daimon/i.test(e.textContent || ''));
369 if (!b) return false;
370 b.click();
371 return true;
372 });
373 if (!found) throw new Error('no "Fresh daimon" control in the cog dialog');
374 await page.waitForTimeout(700);
375 const confirmed = await page.evaluate(() => {
376 const b = document.querySelector('.dlg-card:not(.tile-dlg-card) .dlg-ok');
377 if (!b) return false;
378 b.click();
379 return true;
380 });
381 if (!confirmed) throw new Error('the Fresh daimon confirm never appeared');
382 await page.waitForTimeout(1400);
383 // Out of the cog dialog, so what is asserted below is what a reader would be looking at.
384 await page.evaluate(() => {
385 const x = document.querySelector('.tile-dlg-card .tile-dlg-done');
386 if (x) x.click();
387 });
388 await page.waitForTimeout(600);
389}
390
391try {
392 await makeDiamond('Alpha');
393 await makeDiamond('Beta');
394
395 // ── 2. Fold belongs to the conversation a hand fold can reach.
396 //
397 // Two of these say "not here" and one says "here". A file with only the refusals would be
398 // green against a Fold button that had been deleted, and a file with only the assertion
399 // would be green against one drawn on every face at once — so the positive and the two
400 // negatives are all three of them load-bearing.
401 //
402 // EVERY READING IS A BOUNDING RECTANGLE (`drawn`'s `vis`), never a computed `display`.
403 // `display: none` does not cascade: a child of a hidden parent computes its own `display`
404 // perfectly happily and reports itself visible.
405 await openDiamond('Alpha', 'chat');
406 await steerDiamond(s, 'hello alpha');
407 await page.waitForTimeout(4000);
408 // RE-DRAWN FROM THE RECORD before anything is read off it. Taken straight after the turn,
409 // this snapshot would be the LIVE stream's work, and the control check below would then go
410 // red under `mute` — which is check 3b's break, not this one's. A control that fails for
411 // another check's reason stops being a control.
412 await openDiamond('Alpha', 'chat');
413 const onChat = await drawn();
414 check('2a. FOLD IS OFF THE DAIMON FACE, the one conversation a hand fold cannot reach',
415 onChat.fold === false, 'visible:' + onChat.fold);
416 // The control beside it: the thread really has something in it, so 2a is a REFUSAL and not
417 // a button hidden for want of a conversation. It also makes check 1's "empty" below a
418 // change and not the state it started in.
419 check('the daimon really said something, so 2a is a refusal and not an empty thread',
420 onChat.users.length === 1 && /hello alpha/.test(onChat.users[0] || ''),
421 JSON.stringify(onChat.users));
422
423 await page.click('#dview-crystal', { force: true });
424 await page.waitForTimeout(900);
425 const onCrystal = await drawn();
426 check('2b. AND OFF THE CRYSTAL FACE, which shows no conversation at all',
427 onCrystal.fold === false, 'visible:' + onCrystal.fold);
428
429 // ── 2c/2d. And ON an ordinary chat, once there is something in it.
430 //
431 // The chat is made first and measured EMPTY, because "shown on a chat" and "shown on a
432 // chat with something in it" are two different claims and only the second one is the
433 // app's. A fold offered on an empty thread is the same dead control this check is about,
434 // one surface along.
435 await newChat(s);
436 await page.waitForTimeout(600);
437 const fresh = await drawn();
438 check('2d. AND OFF A CHAT NOBODY HAS SAID ANYTHING IN, which has nothing to fold',
439 fresh.fold === false, 'visible:' + fresh.fold);
440 await page.fill('#chat-input', 'hello chat');
441 await page.click('#chat-send', { force: true });
442 // The user's turn is on the record before the reply is, and that is what makes the
443 // control live: `runTurn` pushes and persists first, then syncs the composer.
444 await page.waitForTimeout(2500);
445 const onOrdinary = await drawn();
446 check('2c. FOLD IS ON AN ORDINARY CHAT WITH SOMETHING IN IT, which is the fold that works',
447 onOrdinary.fold === true, 'visible:' + onOrdinary.fold);
448 check('and that chat really has something in it, so 2c is not a control drawn over nothing',
449 onOrdinary.users.length >= 1 && /hello chat/.test(onOrdinary.text),
450 JSON.stringify(onOrdinary.users));
451 // Let the turn finish before the Diamond work below, so a reply still streaming cannot
452 // land in a thread check 1 or check 3 is about to read.
453 await page.waitForSelector('.chat-spinner', { state: 'detached', timeout: 30000 }).catch(() => {});
454 await page.waitForTimeout(500);
455 await openDiamond('Alpha', 'chat');
456 await page.waitForTimeout(600);
457
458 // ── 1. "Fresh daimon" clears WHAT IS DRAWN, not merely what is stored.
459 await freshDaimon('Alpha');
460 const after = await drawn();
461 check('1a. FRESH DAIMON EMPTIES THE THREAD ON SCREEN',
462 after.users.length === 0 && !/hello alpha/.test(after.text),
463 JSON.stringify(after.users).slice(0, 80));
464 check('and the emptied thread says so rather than going blank',
465 after.empty === true, after.text.slice(0, 60));
466 // ── 1b. AND IT STAYS CLEARED. The screen and the store are two different claims, and this
467 // is the one that was false: `persistChats` UNIONS a transcript with the stored copy,
468 // so emptying the array and saving put every message back. Nothing on screen could
469 // see it, because the screen had already been repainted from the emptied array.
470 const stored = () => page.evaluate(() => new Promise((res) => {
471 const r = indexedDB.open('daimond-chats', 1);
472 r.onsuccess = () => {
473 const db = r.result;
474 let t; try { t = db.transaction('chats', 'readonly'); } catch (e) { return res(null); }
475 const a = t.objectStore('chats').getAll();
476 a.onsuccess = () => {
477 const d = (a.result || []).filter(c => c.diamondId);
478 res({
479 msgs: d.reduce((n, c) => Math.max(n, (c.messages || []).length), 0),
480 sess: d.reduce((n, c) => Math.max(n, ((c.session || {}).msgs || []).length), 0),
481 });
482 };
483 a.onerror = () => res(null);
484 };
485 r.onerror = () => res(null);
486 }));
487 const st = await stored();
488 check('1b. THE TRANSCRIPT IS GONE FROM THE STORE TOO, not only from the screen',
489 !!st && st.msgs === 0, 'longest stored daimon transcript: ' + ((st || {}).msgs));
490 // The half the dialog's own comment calls the one that matters: `session.msgs` is what goes
491 // to the model on the next turn, and it is invisible from the screen either way.
492 check('1c. AND SO IS THE CONVERSATION THE MODEL HOLDS, which the next turn would carry',
493 !!st && st.sess === 0, 'stored session length: ' + ((st || {}).sess));
494
495 // Read the way the owner read it: come back tomorrow and see whether it is still gone.
496 await page.reload({ waitUntil: 'domcontentloaded' });
497 await signInAs(s, 'daimonface');
498 await page.waitForTimeout(1500);
499 await openDiamond('Alpha', 'chat');
500 const reloaded = await drawn();
501 check('1d. AND IT IS STILL GONE AFTER A RELOAD, which is what "start fresh" means',
502 reloaded.users.length === 0 && !/hello alpha/.test(reloaded.text),
503 JSON.stringify(reloaded.users).slice(0, 80));
504
505 // ── 1e. WHAT FALLS BETWEEN CHECK 1 AND CHECK 3, which neither can see on its own.
506 //
507 // Check 1 clears with nothing running. Check 3 runs a turn with nothing cleared. Between
508 // them sits the clear taken WHILE a turn is in flight, and it was real: the reply and the
509 // model's whole conversation are written back into the record when the turn ends, by
510 // reference, so the clear was undone a few seconds later and the reader was left with half
511 // an answer to a question that was no longer there. Two checks, each right, one gap.
512 await page.fill('#chat-input', '@long 40');
513 await page.click('#chat-send', { force: true });
514 await page.waitForTimeout(1500);
515 const refused = await page.evaluate(() => {
516 const box = [...document.querySelectorAll('.diamond-box')]
517 .find(b => (b.textContent || '').indexOf('Alpha') >= 0);
518 box.querySelector('.tile-cog').click();
519 return true;
520 }) && await page.waitForTimeout(700).then(async () => {
521 const pressed = await page.evaluate(() => {
522 const b = [...document.querySelectorAll('.tile-dlg-clear')]
523 .find(e => /fresh daimon/i.test(e.textContent || ''));
524 if (!b) return 'no control';
525 b.click();
526 return 'pressed';
527 });
528 await page.waitForTimeout(700);
529 // A NOTICE and not a confirm: nothing is discarded, and the way to discard is named.
530 return await page.evaluate((p) => {
531 const dlg = document.querySelector('.dlg-card:not(.tile-dlg-card)');
532 return { pressed: p, dialog: dlg ? (dlg.textContent || '').replace(/\s+/g, ' ') : '' };
533 }, pressed);
534 });
535 check('1e. A CLEAR IS REFUSED MID-TURN rather than undone by the turn a moment later',
536 /still working/i.test(refused.dialog),
537 refused.pressed + ': ' + (refused.dialog || '(no dialog)').slice(0, 70));
538 // Out of both dialogs, and let the turn finish, so what follows starts from a known place.
539 await page.evaluate(() => {
540 const n = document.querySelector('.dlg-card:not(.tile-dlg-card) .dlg-ok');
541 if (n) n.click();
542 });
543 await page.waitForTimeout(400);
544 await page.evaluate(() => {
545 const x = document.querySelector('.tile-dlg-card .tile-dlg-done');
546 if (x) x.click();
547 });
548 // The dots are the app's own answer to "is a turn still running", so they are what is
549 // waited on rather than a number of seconds somebody guessed.
550 await page.waitForSelector('.chat-spinner', { state: 'detached', timeout: 30000 });
551 await page.waitForTimeout(800);
552 // Cleared again, now that it is allowed — so check 3 below starts from an empty thread and
553 // its `chunk-` evidence is this turn's and not the one just run.
554 await freshDaimon('Alpha');
555 await openDiamond('Alpha', 'chat');
556 const cleared2 = await drawn();
557 check('and the clear works once the turn is over, which is what the refusal promised',
558 cleared2.users.length === 0 && !/chunk-/.test(cleared2.text),
559 JSON.stringify(cleared2.users) + ' chunks:' + /chunk-/.test(cleared2.text));
560
561 // ── 3. A reply streams into ITS OWN thread, and into no other.
562 await page.fill('#chat-input', '@long 40');
563 await page.click('#chat-send', { force: true });
564 await page.waitForTimeout(1600);
565 const mid = await drawn();
566 check('3b. A REPLY STREAMS INTO ITS OWN THREAD WHILE IT ARRIVES',
567 /chunk-/.test(mid.text), mid.text.slice(-60));
568
569 // Away, mid-turn, to another Diamond's conversation — the gather-round shape, reached the
570 // way a reader reaches it.
571 await openDiamond('Beta', 'chat');
572 await page.waitForTimeout(2500);
573 const beta = await drawn();
574 check('3a. AND INTO NO OTHER THREAD, when the reader has moved on mid-turn',
575 !/chunk-/.test(beta.text) && beta.users.length === 0,
576 "Alpha's words in Beta's thread: " + (/chunk-/.test(beta.text) ? 'YES' : 'no')
577 + ', user turns drawn: ' + beta.users.length);
578
579 // And it was not lost by being undrawn: back on Alpha, the whole reply is there.
580 await page.waitForTimeout(4000);
581 await openDiamond('Alpha', 'chat');
582 const back = await drawn();
583 check('and the reply was kept, so "drawn nowhere" never meant "thrown away"',
584 /chunk-40/.test(back.text), back.text.slice(-70));
585
586} catch (e) {
587 check('the run completed', false, String((e && e.message) || e));
588} finally {
589 await s.close?.().catch(() => {});
590}
591
592console.log(`\n${ok.length} passed, ${bad.length} failed`);
593if (BREAK && MUST_STAY_GREEN.has(BREAK)) {
594 console.log(bad.length
595 ? `\n'${BREAK}' IS NOT A BREAK: it restores the conditions the helper replaced and must `
596 + 'change nothing. Something failed, so the helper is not the refactor it claims to be.'
597 : `\n'${BREAK}' changed nothing, which is what it is for: the helper says the same thing `
598 + 'the raw conditions said.');
599 process.exit(bad.length ? 1 : 0);
600}
601if (BREAK) {
602 console.log(bad.length
603 ? `\nbreak '${BREAK}' produced failures, as it must.`
604 : `\nBREAK '${BREAK}' CHANGED NOTHING — the check it targets is not proving anything.`);
605}
606process.exit(bad.length ? 1 : 0);