Oregami
Repositories/oxedyne/daimond

oxedyne/daimond/dev/verify_stopcontrol.mjs

6.8 KiB, 1 run

created by r2519314175:701, 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_stopcontrol.mjs — can a daimon turn be stopped from the face a Diamond opens on?
2//
3// The user's report: "i could not stop the llm, the arrow send button doesn't
4// change to a stop button! I had to refresh".
5//
6// A Diamond opens on its CRYSTAL face, and both faces share one composer. The
7// turn machinery repaints that composer through `syncComposer`, which was called
8// at the start of a steer behind `if (onScreen())` -- and `onScreen()` is true
9// only on the CHAT face. So the button stayed an arrow for the whole turn.
10//
11// What is asserted is MEANING, not a glyph: the control is in stop mode, and
12// pressing it actually ends the turn -- proved by the turn finishing far sooner
13// than the mock would have taken to answer.
14//
15// node dev/verify_stopcontrol.mjs # the working tree
16// node dev/verify_stopcontrol.mjs --broken # the same page with the guard put back
17//
18// `--broken` is a hand check and exits 1 by design; `run_all.sh` runs it without the flag.
19//
20// `--broken` rewrites the ONE line in the served daimond.js, so the two runs
21// differ by that line and nothing else.
22import fs from 'node:fs';
23import path from 'node:path';
24import { fileURLToPath } from 'node:url';
25import { open, connectMock, scratch } from './harness.mjs';
26
27// Derived, never written down. `gate.sh` runs the suite inside a `git
28// worktree` at a different path, and an absolute path here would read the
29// MAIN tree's `daimond.js` while driving the worktree's app -- a verifier
30// measuring one tree and reporting on another.
31const APPDIR = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
32const BROKEN = process.argv.includes('--broken');
33
34// The whole of the fix, in one line either way.
35const FIXED_LINE = '\t\tsyncComposer();\n\t\tif (onCrystal) showCrystalSpinner();';
36const BROKEN_LINE = '\t\tif (onScreen()) syncComposer();\n\t\tif (onCrystal) showCrystalSpinner();';
37
38let failures = 0;
39const check = (cond, msg, detail) => {
40 console.log((cond ? ' ok ' : ' FAIL ') + msg + (detail != null ? ' — ' + detail : ''));
41 if (!cond) failures++;
42};
43
44/// What the one composer button is saying right now, read from the DOM.
45const sendState = (p) => p.evaluate(() => {
46 const b = document.getElementById('chat-send');
47 if (!b) return { there: false };
48 return {
49 there: true,
50 shown: b.getClientRects().length > 0,
51 stop: b.classList.contains('stop'),
52 glyph: (b.textContent || '').trim(),
53 title: b.title || '',
54 enabled: !b.disabled,
55 };
56});
57
58/// Is the Diamond's own record mid-turn, as the rest of the app sees it?
59const generating = (p) => p.evaluate(() =>
60 !!(window.DaimondCore && window.DaimondCore.busy && window.DaimondCore.busy()));
61
62async function newDiamond(p, name) {
63 await p.evaluate(() => document.getElementById('new-diamond-btn').click());
64 await p.waitForSelector('.dlg-card', { timeout: 8000 });
65 await p.evaluate((nm) => {
66 const card = [...document.querySelectorAll('.dlg-card')]
67 .filter(c => c.getClientRects().length).pop();
68 const inp = card.querySelector('input.dlg-input');
69 inp.value = nm;
70 inp.dispatchEvent(new Event('input', { bubbles: true }));
71 card.querySelector('.dlg-ok').click();
72 }, name);
73 await p.waitForTimeout(1500);
74}
75
76const route = BROKEN ? async (page) => {
77 const src = fs.readFileSync(APPDIR + '/www/js/daimond.js', 'utf8');
78 if (!src.includes(FIXED_LINE)) throw new Error('the fixed line is not in the file; --broken cannot rewrite it');
79 const hurt = src.replace(FIXED_LINE, BROKEN_LINE);
80 await page.route('**/js/daimond.js', (r) => r.fulfill({
81 status: 200, contentType: 'application/javascript; charset=utf-8', body: hurt,
82 }));
83} : null;
84
85const s = await open({ name: 'stopprobe', profile: scratch('pw', 'stopprobe-' + process.pid), route });
86const { page: p } = s;
87try {
88 await connectMock(s);
89 await newDiamond(p, 'Stop probe');
90
91 // The face a Diamond opens on. Named, not assumed.
92 const face = await p.evaluate(() => ({
93 crystal: !!document.getElementById('panel-ai').classList.contains('crystal-face'),
94 composer: (() => { const b = document.querySelector('.chat-input-bar');
95 return !!(b && b.getClientRects().length); })(),
96 }));
97 check(face.crystal, 'a new Diamond opens on the crystal face', JSON.stringify(face));
98 check(face.composer, 'and the composer is under it', JSON.stringify(face));
99
100 const before = await sendState(p);
101 check(before.there && before.shown && !before.stop, 'at rest the button is Send',
102 JSON.stringify(before));
103
104 // A turn long enough to watch. 300 chunks at 120ms is ~36s of streaming, so
105 // anything that ends inside a few seconds ended because it was STOPPED.
106 await p.fill('#chat-input', '@long 300');
107 await p.click('#chat-send');
108 await p.waitForTimeout(1500);
109
110 const mid = await sendState(p);
111 const gen = await generating(p);
112 check(gen, 'the turn is running', 'DaimondCore.busy() = ' + gen);
113 check(mid.stop, 'MID-TURN THE BUTTON IS STOP', JSON.stringify(mid));
114 check(mid.glyph === '■', 'and it shows the stop mark', JSON.stringify(mid));
115 check(mid.enabled && mid.shown, 'and it can be pressed', JSON.stringify(mid));
116
117 // THE CONTROL FOR THE CHECK BELOW. Without this, a turn that ended by itself
118 // would read as a turn the press stopped. Left alone for six seconds, this one
119 // is still going: the mock has 300 chunks to dribble at 120ms.
120 await p.waitForTimeout(4500);
121 check(await generating(p), 'left alone, the turn is STILL running six seconds in',
122 'so anything that ends it after this was the press');
123
124 // The property that matters: pressing it ENDS THE TURN. Nothing here reads a
125 // class to decide that -- the turn is over when the app says it is not busy.
126 //
127 // This passes in the BROKEN build too, and that is the point rather than a
128 // flaw: `sendMode()` already returns 'stop', so the button was ALWAYS wired to
129 // stop the turn -- it merely said "Send" while it did. Nobody presses an arrow
130 // to stop something, which is why the paint is the whole defect.
131 const t0 = Date.now();
132 await p.click('#chat-send');
133 let ended = false;
134 for (let i = 0; i < 60; i++) {
135 await p.waitForTimeout(250);
136 if (!(await generating(p))) { ended = true; break; }
137 }
138 const took = Date.now() - t0;
139 check(ended && took < 8000, 'PRESSING IT STOPS THE TURN', 'ended after ' + took + 'ms');
140
141 await p.waitForTimeout(600);
142 const after = await sendState(p);
143 check(after.there && !after.stop && after.glyph === '➤',
144 'and the button goes back to Send', JSON.stringify(after));
145
146 await p.screenshot({ path: scratch('stopprobe-' + (BROKEN ? 'broken' : 'fixed') + '.png') });
147} catch (e) {
148 console.log(' FAIL threw — ' + (e && e.message));
149 failures++;
150} finally {
151 const errs = s.errs.filter(e => !/favicon|manifest/i.test(e));
152 if (errs.length) console.log(' console errors: ' + errs.slice(0, 5).join(' | '));
153 await s.close();
154}
155console.log((BROKEN ? 'BROKEN' : 'FIXED') + ': ' + (failures ? failures + ' failure(s)' : 'all checks passed'));
156process.exit(failures ? 1 : 0);