Oregami
Repositories/oxedyne/daimond

oxedyne/daimond/dev/verify_queue.mjs

14.9 KiB, 1 run

created by r2519314175:617, 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_queue.mjs — you may type while the answer is still arriving.
2//
3// The composer used to be disabled for the whole turn, so the next thing you
4// wanted to say had to be held in your head until the model finished. It is held
5// by the app now: queued, shown, and sent as its OWN turn the moment the turn
6// lock is free. A turn cannot be joined mid-flight -- the wasm side holds the
7// session for its whole length -- so this is queue-and-send-after, not injection.
8//
9// The check that matters most is the third one. A turn is the unit a fold picks
10// and the unit the numbering counts, so a queued bubble that wore `.chat-msg-user`
11// would be a turn that does not exist: every ticked turn would map one message
12// out, and a fold would quietly send the wrong text to the reducer. So the queue
13// is asserted to leave `userDivs()` and every `data-turn` exactly as they were.
14//
15// node dev/verify_queue.mjs
16//
17// Needs dev/serve.mjs (DAIMOND_PORT, default 8777) and dev/mockllm.mjs
18// (DAIMOND_MOCK_PORT, default 9099). No gateway.
19
20import { open, newChat, shot, errors } from './harness.mjs';
21
22const ok = [], bad = [];
23const check = (name, pass, detail) => {
24 (pass ? ok : bad).push(name + (detail ? ' — ' + detail : ''));
25 console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : ''));
26};
27const sleep = ms => new Promise(r => setTimeout(r, ms));
28
29const s = await open({ name: 'queue' });
30const p = s.page;
31
32// Record toasts as they appear: they remove themselves after ~4s.
33await p.evaluate(() => {
34 window.__toasts = [];
35 new MutationObserver(muts => {
36 for (const m of muts) for (const n of m.addedNodes) {
37 if (n.nodeType === 1 && n.classList && n.classList.contains('daimond-toast')) {
38 window.__toasts.push({ text: n.textContent, err: n.classList.contains('err') });
39 }
40 }
41 }).observe(document.body, { childList: true });
42});
43const toasts = () => p.evaluate(() => window.__toasts.slice());
44
45/// The shape of the thread: the real turns, and the queue beside them.
46const shape = () => p.evaluate(() => {
47 const out = document.getElementById('chat-output');
48 const users = [...out.querySelectorAll('.chat-msg-user')];
49 const q = [...out.querySelectorAll('.chat-msg-queued')];
50 const box = document.getElementById('chat-queued');
51 return {
52 users: users.length,
53 turns: users.map(u => u.dataset.turn).join(','),
54 userText: users.map(u => u.querySelector('.chat-msg-content').textContent).join(' | '),
55 queued: q.map(e => e.querySelector('.chat-msg-content').textContent),
56 queuedAreUsers: q.filter(e => e.classList.contains('chat-msg-user')).length,
57 boxIsLast: !!box && box === out.lastElementChild,
58 inputDisabled: document.getElementById('chat-input').disabled,
59 composer: document.getElementById('chat-input').value,
60 stopMode: /stop/i.test((document.getElementById('chat-send').getAttribute('title') || '')
61 + (document.getElementById('chat-send').className || '')),
62 busy: !!(window.DaimondCore && window.DaimondCore.busy()),
63 };
64});
65/// Wait until the send button offers Send again — the app's own idea of "done".
66async function idle(timeout = 60000) {
67 const t0 = Date.now();
68 while (Date.now() - t0 < timeout) {
69 if (!(await shape()).stopMode) return true;
70 await sleep(250);
71 }
72 return false;
73}
74/// Type into the composer and press Enter, which is how a message is sent — and,
75/// mid-turn, how it is queued.
76async function type(text) {
77 await p.fill('#chat-input', text);
78 await p.press('#chat-input', 'Enter');
79 await sleep(400);
80}
81/// Click something if it is there. A verifier has to be able to RUN against the
82/// code before the fix, where these controls do not exist at all — a hard click
83/// would abort the pass with a Playwright timeout instead of failing the check
84/// that is meant to catch it.
85async function clickIf(sel) {
86 const el = await p.$(sel);
87 if (!el) return false;
88 await el.click({ force: true });
89 await sleep(400);
90 return true;
91}
92
93await newChat(s);
94
95// ── 1. The composer stays live while a turn runs ─────────────────────
96// `@long` streams 120 chunks at 120ms, so there is a good half-minute of turn
97// to type into.
98await p.fill('#chat-input', '@long 120');
99await p.click('#chat-send', { force: true });
100await sleep(1500);
101const running = await shape();
102check('the composer is not disabled while the answer arrives',
103 running.inputDisabled === false, `disabled=${running.inputDisabled}`);
104check('and Send still means Stop while it runs (the signal everything else reads)',
105 running.stopMode === true);
106const ph = await p.$eval('#chat-input', e => e.placeholder);
107check('the box invites the next message rather than looking dead',
108 /next/i.test(ph), ph);
109
110// ── 2/3. Queueing adds a bubble and NOT a turn ───────────────────────
111const before = await shape();
112await type('QUEUED-ONE');
113const after = await shape();
114check('a message typed mid-turn appears as a queued bubble',
115 after.queued.length === 1 && after.queued[0] === 'QUEUED-ONE', JSON.stringify(after.queued));
116check('the bubble is not a user message: no new .chat-msg-user',
117 after.users === before.users && after.queuedAreUsers === 0,
118 `users ${before.users} → ${after.users}, queued-wearing-user=${after.queuedAreUsers}`);
119check('and every existing turn number is untouched',
120 after.turns === before.turns, `${before.turns} → ${after.turns}`);
121check('the composer is cleared, as it is on an ordinary send',
122 after.composer === '', JSON.stringify(after.composer));
123check('the queue sits at the bottom of the thread, under what has happened',
124 after.boxIsLast === true);
125check('a queue on its own counts as work in flight (the updater must not reload it away)',
126 after.busy === true);
127const qhead = await p.$eval('.chat-queued-head', e => e.textContent).catch(() => '');
128check('and the queue says when what is in it will be sent',
129 /finish/i.test(qhead), qhead || '(no head line)');
130await shot(s, 'queue-waiting');
131
132// ── 4. A second one queues behind the first, in order ────────────────
133await type('QUEUED-TWO');
134const two = await shape();
135check('a second message queues behind the first, in order',
136 JSON.stringify(two.queued) === JSON.stringify(['QUEUED-ONE', 'QUEUED-TWO']), JSON.stringify(two.queued));
137
138// ── 5. Both are sent, as two separate turns, in order ────────────────
139await idle(90000);
140await sleep(1200);
141await idle(90000); // the second queued turn
142await sleep(1500);
143const drained = await shape();
144check('every queued message became a real turn of its own',
145 drained.users === before.users + 2, `${before.users} → ${drained.users} user turns`);
146check('in the order they were typed',
147 /QUEUED-ONE[\s\S]*QUEUED-TWO/.test(drained.userText), drained.userText.slice(0, 160));
148check('and the turns are numbered without a gap, so a fold still maps them',
149 drained.turns === Array.from({ length: drained.users }, (_, i) => i + 1).join(','), drained.turns);
150check('nothing is left queued once it has been sent',
151 drained.queued.length === 0 && drained.busy === false, JSON.stringify(drained.queued));
152await shot(s, 'queue-drained');
153
154// ── 6. The × drops one without sending it ────────────────────────────
155await p.fill('#chat-input', '@long 60');
156await p.click('#chat-send', { force: true });
157await sleep(1200);
158await type('CANCEL-ME');
159await clickIf('.chat-msg-queued .queue-x');
160const cancelled = await shape();
161check('the × takes a queued message back out',
162 cancelled.queued.length === 0, JSON.stringify(cancelled.queued));
163await idle(90000);
164await sleep(1000);
165const afterCancel = await shape();
166check('and a cancelled message is never sent',
167 !/CANCEL-ME/.test(afterCancel.userText), afterCancel.userText.slice(-80));
168
169// ── 7. Clicking a queued bubble puts it back in the composer to edit ─
170await p.fill('#chat-input', '@long 60');
171await p.click('#chat-send', { force: true });
172await sleep(1200);
173await type('EDIT-ME');
174await clickIf('.chat-msg-queued .chat-msg-content');
175const edited = await shape();
176check('clicking a queued message puts it back in the box to be changed',
177 edited.composer === 'EDIT-ME' && edited.queued.length === 0,
178 `composer=${JSON.stringify(edited.composer)} queued=${JSON.stringify(edited.queued)}`);
179// That turn is still streaming, and the next check needs the button to mean Send
180// when it presses it: clicking Stop-by-accident is how this test lied the first
181// time it was written.
182await p.fill('#chat-input', '');
183await idle(90000);
184await sleep(1000);
185
186// ── 8. Stop hands the queue back rather than sending it ──────────────
187const beforeStop = await shape();
188await p.fill('#chat-input', '@long 120');
189await p.click('#chat-send', { force: true });
190await sleep(1500);
191await type('AFTER-STOP');
192await p.click('#chat-send', { force: true }); // in stop-mode: kills the turn
193await sleep(2500);
194const stopped = await shape();
195check('Stop returns what was queued to the composer, unsent',
196 stopped.composer === 'AFTER-STOP' && stopped.queued.length === 0,
197 `composer=${JSON.stringify(stopped.composer)} queued=${JSON.stringify(stopped.queued)}`);
198check('and says so, rather than leaving the text to be wondered about',
199 (await toasts()).some(x => x.err && /unsent|back in the box/i.test(x.text)),
200 JSON.stringify((await toasts()).slice(-2)));
201await sleep(3000);
202const afterStop = await shape();
203check('nothing queued behind a stopped turn is sent',
204 afterStop.users === beforeStop.users + 1, `${beforeStop.users} → ${afterStop.users} (the stopped turn only)`);
205await shot(s, 'queue-stopped');
206
207// ── 9. A queue on a chat you have left is visible, and drains on return ──
208// The queue is drawn in the thread of the chat on screen, and only there, so
209// one left on a background chat used to be invisible AND inert: nothing said it
210// was waiting, and going back to the chat did not send it. Both halves are the
211// same promise -- what you typed will be sent -- and both are kept here.
212const tiles = () => p.evaluate(() => [...document.querySelectorAll('#session-list .session-box')]
213 .map(b => ({
214 id: b.dataset.id,
215 name: ((b.querySelector('.tile-when') || {}).textContent || '').trim(),
216 badge: (b.querySelector('.queue-badge') || {}).textContent || '',
217 })));
218/// Start a second chat: the + makes a PENDING tile, and Start makes it a chat.
219async function addChat() {
220 await p.click('#new-session-btn', { force: true });
221 await sleep(400);
222 const start = p.locator('.tile-start').first();
223 if (await start.count()) await start.click({ force: true });
224 await sleep(500);
225}
226/// Click a chat's tile in the rail, the way a user changes conversation.
227async function openTile(id) {
228 await p.evaluate((cid) => {
229 const box = [...document.querySelectorAll('#session-list .session-box')]
230 .find(b => b.dataset.id === cid);
231 if (box) box.click();
232 }, id);
233 await sleep(600);
234}
235
236const beforeB = (await tiles()).map(t => t.id);
237await addChat();
238const allTiles = await tiles();
239const chatB = allTiles.map(t => t.id).find(id => beforeB.indexOf(id) === -1) || '';
240const chatA = beforeB[0] || '';
241check('a second chat can be opened beside the first',
242 !!chatA && !!chatB && chatA !== chatB, `A=${chatA} B=${chatB}`);
243
244// Queue behind a turn running on A, then leave for B.
245await openTile(chatA);
246await p.fill('#chat-input', '@long 30');
247await p.click('#chat-send', { force: true });
248await sleep(1200);
249await type('QUEUED-AWAY');
250const queuedOnA = (await shape()).queued;
251check('the message queues on the chat it was typed in',
252 JSON.stringify(queuedOnA) === JSON.stringify(['QUEUED-AWAY']), JSON.stringify(queuedOnA));
253await openTile(chatB);
254const away = await tiles();
255const badgeA = (away.find(t => t.id === chatA) || {}).badge;
256check('the tile of the chat left behind says something is waiting on it',
257 !!badgeA, `badge=${JSON.stringify(badgeA)}`);
258check('and the chat being looked at is not badged for someone else’s queue',
259 !(away.find(t => t.id === chatB) || {}).badge,
260 JSON.stringify((away.find(t => t.id === chatB) || {}).badge));
261// Let the turn that was in flight on A finish while the user is elsewhere.
262await sleep(6000);
263const stillAway = await tiles();
264check('the tile still says so once that turn has finished — nothing is sent behind your back',
265 !!(stillAway.find(t => t.id === chatA) || {}).badge,
266 JSON.stringify((stillAway.find(t => t.id === chatA) || {}).badge));
267
268// Go back: it sends, as its own turn, and the badge goes with it.
269const bBefore = (await shape()).users;
270await openTile(chatA);
271await idle(90000);
272await sleep(1500);
273const returned = await shape();
274const cleared = await tiles();
275check('going back to the chat sends what was queued on it, as a real turn',
276 /QUEUED-AWAY/.test(returned.userText), returned.userText.slice(-120));
277check('as a turn, not a bubble: the queue is empty and the numbering has no gap',
278 returned.queued.length === 0
279 && returned.turns === Array.from({ length: returned.users }, (_, i) => i + 1).join(','),
280 `queued=${JSON.stringify(returned.queued)} turns=${returned.turns}`);
281check('and the badge clears once it has gone',
282 !(cleared.find(t => t.id === chatA) || {}).badge,
283 JSON.stringify((cleared.find(t => t.id === chatA) || {}).badge));
284check('the other chat is untouched by any of it', bBefore >= 0 && !!chatB);
285await shot(s, 'queue-background');
286
287// ── 10. A queue behind a STOPPED turn is handed back, not sent ────────
288// Stop means stop even when the user is not watching: the chat was in the
289// background when its turn was killed, so the hand-back waits with it and
290// happens on return.
291await openTile(chatA);
292await p.fill('#chat-input', '@long 60');
293await p.click('#chat-send', { force: true });
294await sleep(1200);
295await type('QUEUED-STOPPED');
296// Stop and leave in the SAME tick, so the turn ends while the chat is in the
297// background: an await between the two would let the drain run while it is still
298// on screen, which is the case section 8 already covers.
299await p.evaluate((bid) => {
300 document.getElementById('chat-send').click(); // in stop-mode: kills the turn
301 const box = [...document.querySelectorAll('#session-list .session-box')]
302 .find(b => b.dataset.id === bid);
303 if (box) box.click();
304}, chatB);
305await sleep(2500);
306await openTile(chatA);
307await sleep(1200);
308const handed = await shape();
309check('a queue left behind a stopped turn comes back to the composer, unsent',
310 handed.composer === 'QUEUED-STOPPED' && handed.queued.length === 0,
311 `composer=${JSON.stringify(handed.composer)} queued=${JSON.stringify(handed.queued)}`);
312check('and it was never sent as a turn', !/QUEUED-STOPPED/.test(handed.userText),
313 handed.userText.slice(-80));
314await p.fill('#chat-input', '');
315
316const errs = errors(s).filter(e => !/favicon|404|401|402|502|Bad Gateway|net::ERR/.test(e));
317console.log('\nconsole errors:', errs.slice(0, 5));
318check('nothing throws while all this happens', errs.length === 0, errs[0] || '');
319
320await s.close();
321console.log(`\n${ok.length} passed, ${bad.length} failed`);
322if (bad.length) console.log('FAILED:\n ' + bad.join('\n '));
323process.exit(bad.length ? 1 : 0);