Oregami
Repositories/oxedyne/daimond

oxedyne/daimond/dev/verify_waiting.mjs

29.2 KiB, 1 run

created by r2519314175:797, 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_waiting.mjs — the waiting indicator means "this turn is still running".
2//
3// The complaint, against the live app: the dots cleared when the FIRST thing came
4// back, not when the turn ended. A turn is one request per tool-call round, so a
5// long run of tool calls is minutes of work, and with the steps hidden nothing on
6// screen changes for any of it. What the user saw was the indicator vanish and
7// then a still page — indistinguishable from a session that had died. An earlier
8// round fixed the short gap, between pressing send and the first token; this is
9// the long one, and the long one is the one that reads as death.
10//
11// So the property is not "a spinner appears". It is:
12//
13// * WHILE the app says a turn is running, the indicator is up. Sampled
14// throughout, so a single moment of absence anywhere in the turn fails —
15// which is what the old code did at the first tool call.
16// * It stays at the FOOT of the thread. An indicator that sinks above each new
17// tool block says "running file_read" halfway up a thread that has moved on.
18// * It SAYS something, and what it says follows the tool that is actually
19// running. Dots that have not changed in four minutes are barely better than
20// no dots at all.
21// * It holds with the steps hidden, which is the case the complaint was about.
22// * Every way out of a turn takes it down. A stuck indicator is worse than one
23// that clears early, so the exits are enumerated and each one is driven:
24// the answer, an error, the Stop button, a provider refusal, a reload in the
25// middle, and walking away to another chat and back.
26// * It belongs to ONE chat. A turn running behind the chat on screen must put
27// nothing on that chat — not its dots, and not its words. The two are the
28// same rule read at two distances, and the second is the one that bites: a
29// daimon turn used to answer the question "am I the thing on screen?" once,
30// before it started, so walking away mid-turn left it streaming into
31// whichever thread had arrived.
32//
33// Two halves, and the first needs nothing running.
34//
35// SOURCE reads www/js/daimond.js and asserts that no event arm takes the
36// indicator down — only the end of a turn does. This is the half that
37// covers the exits a mock provider cannot produce (a turn that ends in
38// silence, a refused key that is reminted and retried): both arrive as
39// an `error` EVENT in the middle of a turn that then carries on, so the
40// rule "an error event is not an exit" is the thing to hold.
41// BROWSER drives real turns through the mock and samples the page while they
42// run.
43//
44// node dev/verify_waiting.mjs # both halves
45// node dev/verify_waiting.mjs --source # the source half alone, no server
46//
47// Needs dev/serve.mjs and dev/mockllm.mjs for the browser half. No gateway.
48import fs from 'node:fs';
49import path from 'node:path';
50import { fileURLToPath } from 'node:url';
51import { open, newChat, shot, errors, signInAs, steerDiamond } from './harness.mjs';
52
53const HERE = path.dirname(fileURLToPath(import.meta.url));
54const APPJS = path.join(HERE, '..', 'www', 'js', 'daimond.js');
55
56const ok = [], bad = [];
57const check = (name, pass, detail) => {
58 (pass ? ok : bad).push(name + (detail ? ' — ' + detail : ''));
59 console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : ''));
60 return pass;
61};
62
63// ── The source half ────────────────────────────────────────────────
64//
65// Brace-matched, not line-ranged: these functions move every week and a range
66// would quietly start reading somewhere else rather than failing.
67
68/// The body of the first `{…}` block that opens after `marker`.
69const blockAfter = (src, marker) => {
70 const at = src.indexOf(marker);
71 if (at === -1) return null;
72 let i = src.indexOf('{', at);
73 if (i === -1) return null;
74 let depth = 0;
75 for (let j = i; j < src.length; j++) {
76 if (src[j] === '{') depth++;
77 else if (src[j] === '}') { depth--; if (depth === 0) return src.slice(i, j + 1); }
78 }
79 return null;
80};
81
82/// The `onEvent` sink declared inside `fnBody` — the arms that run DURING a turn.
83const sinkOf = (fnBody) => fnBody && blockAfter(fnBody, 'var onEvent = function');
84
85const src = fs.readFileSync(APPJS, 'utf8');
86const runTurn = blockAfter(src, 'async function runTurn(');
87const doSteer = blockAfter(src, 'async function doSteer(');
88const sync = blockAfter(src, 'function syncComposer(');
89
90check('runTurn, doSteer and syncComposer are all found in the source',
91 !!(runTurn && doSteer && sync));
92
93// Every way of saying "take the indicator down" that is reachable from inside a
94// turn — the call itself, and any little helper the turn declares that makes it.
95//
96// Naming `hideSpinner` alone was not enough, and the proof is that it passed
97// against the very code this exists to catch: the daimon's sink cleared the dots
98// through a two-line `replyStarted()` of its own, so a check looking for the one
99// name saw nothing wrong and reported green on a broken page. A check that passes
100// for the wrong reason is worse than no check.
101const clearersIn = (fnBody, sink) => {
102 const names = ['hideSpinner'];
103 const decl = /(?:function\s+(\w+)\s*\(|var\s+(\w+)\s*=\s*function\s*\()/g;
104 let m, added = true;
105 const helpers = [];
106 while ((m = decl.exec(fnBody || ''))) {
107 const nm = m[1] || m[2];
108 const body = blockAfter(fnBody.slice(m.index), nm);
109 // The sink itself is the thing under test, not a way of reaching it.
110 if (nm && body && body !== sink) helpers.push({ nm, body });
111 }
112 // A helper may clear through another helper, so settle it.
113 while (added) {
114 added = false;
115 for (const h of helpers) {
116 if (names.indexOf(h.nm) !== -1) continue;
117 if (names.some(n => new RegExp('\\b' + n + '\\s*\\(').test(h.body))) {
118 names.push(h.nm); added = true;
119 }
120 }
121 }
122 return names;
123};
124
125/// Places in `where` that take the indicator down, by any of `names`.
126const clearsAt = (where, names) => names
127 .map(n => ((where || '').match(new RegExp('\\b' + n + '\\s*\\(', 'g')) || []).length)
128 .reduce((a, b) => a + b, 0);
129
130const chatSink = sinkOf(runTurn);
131const steerSink = sinkOf(doSteer);
132check('a chat turn has an event sink to inspect', !!chatSink);
133check('a daimon turn has an event sink to inspect', !!steerSink);
134
135const chatClearers = clearersIn(runTurn, chatSink);
136const steerClearers = clearersIn(doSteer, steerSink);
137check('nothing that arrives DURING a chat turn takes the indicator down',
138 clearsAt(chatSink, chatClearers) === 0,
139 clearsAt(chatSink, chatClearers)
140 ? clearsAt(chatSink, chatClearers) + ' place(s) in the sink still clear it, via ' + chatClearers.join('/') : '');
141check('nor during a daimon turn, which behaves identically to a chat',
142 clearsAt(steerSink, steerClearers) === 0,
143 clearsAt(steerSink, steerClearers)
144 ? clearsAt(steerSink, steerClearers) + ' place(s) in the sink still clear it, via ' + steerClearers.join('/') : '');
145
146// An error EVENT is not the end of a turn: a refused key is reminted and the same
147// turn run again, and the guard that raises "the model ended its turn without
148// saying anything" leaves the turn to finish underneath it. Both would clear the
149// indicator on a page that still had work to do.
150const errArm = chatSink && blockAfter(chatSink.slice(chatSink.indexOf("ev.type === 'error'")), ')');
151check('an error reported mid-turn is not treated as the end of the turn',
152 !!errArm && clearsAt(errArm, chatClearers) === 0);
153
154// And the one place it does come down: where the turn ends, however it ended.
155const ends = runTurn && blockAfter(runTurn.slice(runTurn.lastIndexOf('} finally {')), 'finally');
156check('the end of a turn is where the indicator comes down',
157 !!ends && /syncComposer\s*\(|hideSpinner\s*\(/.test(ends));
158
159// `syncComposer` is what decides whether the chat ON SCREEN is working, so it has
160// to be able to raise the indicator as well as lower it — otherwise coming back
161// to a chat that is still running finds a thread that was rebuilt without one.
162check('the composer sync can raise the indicator as well as take it down',
163 !!sync && /showSpinner\s*\(/.test(sync) && /hideSpinner\s*\(/.test(sync));
164
165if (process.argv.includes('--source')) {
166 console.log(`\n${ok.length} passed, ${bad.length} failed`);
167 if (bad.length) console.log('FAILED:\n ' + bad.join('\n '));
168 process.exit(bad.length ? 1 : 0);
169}
170
171// ── The browser half ───────────────────────────────────────────────
172
173/// Watch the thread while a turn runs.
174///
175/// Sampled rather than event-driven, because what is being asserted is that
176/// there is NO moment of absence — and a moment nobody looked at is exactly where
177/// the old behaviour lived. `busy` is the composer's own Stop state, which is the
178/// only signal the app itself trusts for "a turn is running"; nothing is typed
179/// into the box during these turns, so Stop is what it shows.
180const WATCH = () => {
181 window.__wait = { samples: [], timer: null, said: [], obs: null };
182 const tick = () => {
183 const send = document.getElementById('chat-send');
184 const out = document.getElementById('chat-output');
185 const el = out ? out.querySelector('.chat-spinner') : null;
186 const kids = out ? [...out.children].filter(e => e.id !== 'chat-queued') : [];
187 const say = el ? el.querySelector('.chat-spinner-say') : null;
188 window.__wait.samples.push({
189 busy: !!send && send.classList.contains('stop'),
190 up: !!el,
191 last: !!el && kids.length > 0 && kids[kids.length - 1] === el,
192 say: say ? (say.textContent || '') : '',
193 });
194 };
195 window.__wait.timer = setInterval(tick, 25);
196
197 // EVERY caption, not every twenty-five milliseconds' worth. Sampling is the
198 // right instrument for "there was no moment of absence", because a moment
199 // nobody looked at is exactly where the old behaviour lived -- but it is the
200 // wrong one for "it named the tools in the order they ran". Against the mock
201 // a tool returns in about a millisecond, so the line naming it is overwritten
202 // long before the next tick, and the check went red against code that was
203 // doing the right thing. In front of a real provider the same caption stands
204 // for as long as the tool takes, which is the case it exists for.
205 const seen = window.__wait.said;
206 const note = () => {
207 const el = document.querySelector('#chat-output .chat-spinner .chat-spinner-say');
208 const t = el ? (el.textContent || '').trim() : '';
209 if (t && seen[seen.length - 1] !== t) seen.push(t);
210 };
211 const out = document.getElementById('chat-output');
212 if (out) {
213 window.__wait.obs = new MutationObserver(note);
214 window.__wait.obs.observe(out, { subtree: true, childList: true, characterData: true });
215 }
216 tick();
217 note();
218};
219
220const watch = (p) => p.evaluate(WATCH);
221const reap = (p) => p.evaluate(() => {
222 clearInterval(window.__wait.timer);
223 if (window.__wait.obs) window.__wait.obs.disconnect();
224 const s = window.__wait.samples;
225 return {
226 total: s.length,
227 working: s.filter(x => x.busy).length,
228 // The whole point: a moment when the app said it was working and the
229 // thread showed nothing.
230 dark: s.filter(x => x.busy && !x.up).length,
231 // A moment when the indicator had sunk above something else.
232 adrift: s.filter(x => x.busy && x.up && !x.last).length,
233 // What it said, in the order it first said it -- from the observer, so a
234 // caption that stood for less than one sampling interval still counts.
235 saidIn: window.__wait.said.slice(),
236 mute: s.filter(x => x.busy && x.up && !x.say.trim()).length,
237 };
238});
239
240/// Send without waiting: the turn is what is being watched, not its answer.
241const send = async (p, text) => {
242 await p.fill('#chat-input', text);
243 await p.click('#chat-send', { force: true });
244};
245
246/// Wait for the composer to offer Send again — the app's own "the turn is over".
247///
248/// `took` is how long that was, and it is reported rather than discarded: the
249/// provider-failure exits below are the two turns whose length is a POLICY, and a
250/// silent timeout is how a widened retry budget came to read as a stuck
251/// indicator. See the `@err` loop.
252const settle = async (p, timeout = 40000) => {
253 const t0 = Date.now();
254 while (Date.now() - t0 < timeout) {
255 const busy = await p.evaluate(() => {
256 const b = document.getElementById('chat-send');
257 return !!b && (b.classList.contains('stop') || b.disabled);
258 });
259 if (!busy) return { ended: true, took: Date.now() - t0 };
260 await p.waitForTimeout(100);
261 }
262 return { ended: false, took: Date.now() - t0 };
263};
264
265/// Make a NEW chat from the rail, and say which one it turned out to be.
266///
267/// NOT `harness.newChat`, whose job is "get me into a chat" and which therefore
268/// returns the instant a chat composer is already on screen. Called from inside
269/// a chat it does nothing at all, and that is how the fresh-chat check below
270/// came to fail against code that was right: it sampled the chat it meant to
271/// leave — which of course had an indicator up, being the one that was working —
272/// and reported it as the other chat's state leaking onto a fresh one that was
273/// never made. The + and Start are what a person presses, so press those.
274const freshChat = async (p) => {
275 await p.click('#new-session-btn', { force: true });
276 await p.waitForTimeout(400);
277 const start = p.locator('.tile-start').first();
278 if (await start.count()) await start.click({ force: true });
279 await p.waitForTimeout(400);
280 return p.evaluate(() => {
281 const a = document.querySelector('.session-box.active');
282 return a ? a.dataset.id : '';
283 });
284};
285
286/// Open a chat through its own tile label, the way a person opens one.
287const openChat = async (p, id) => {
288 await p.evaluate((i) => {
289 const box = document.querySelector('.session-box[data-id="' + i + '"]');
290 const lab = box && box.querySelector('.tile-label');
291 if (lab) lab.click();
292 }, id);
293 await p.waitForTimeout(800);
294};
295
296/// Everything the thread on screen is currently saying.
297const threadText = (p) => p.evaluate(() => {
298 const out = document.getElementById('chat-output');
299 return out ? (out.innerText || '') : '';
300});
301
302/// Is anything left claiming to be running?
303const resting = (p) => p.evaluate(() => {
304 const out = document.getElementById('chat-output');
305 return {
306 spinner: !!(out && out.querySelector('.chat-spinner')),
307 send: !!document.querySelector('#chat-send:not(.stop)'),
308 };
309});
310
311const s = await open({ name: 'waiting' });
312const p = s.page;
313await newChat(s);
314
315// WHERE THE CHAIN'S FIRST TOOL WRITES, and it has to be somewhere it may.
316//
317// This said `waiting.txt` — a workspace-ROOT path — until 2026-08-14. Since the
318// chat fence landed on 2026-08-12 a chat is confined to `chats/<id>/work`
319// (`scopeChatTo`, www/js/daimond.js) and `Tool::guard` (src/tools.rs:5490) refuses a
320// root path before the write ever happens. A refusal is instant, and instant is the
321// problem here: the caption "Running file_write, step 1…" was replaced by the next
322// one inside a single microtask checkpoint, so the observer below never saw it and
323// two checks about WHAT THE INDICATOR SAID went red about a fence.
324const SCRATCH = await p.evaluate(() => {
325 const f = window.DaimondAttach.focus();
326 return f && f.id ? window.DaimondAttach.chatScratch(f.id) : '';
327});
328check('the chat has a scratch folder, so its tools have real work to do', !!SCRATCH, SCRATCH);
329
330// ── A turn of several tool calls, with the steps SHOWN ──────────────
331//
332// `@chain` is two rounds of one tool call each and then a text reply — the shape
333// of a real agentic turn, and the shape the old code went dark for.
334
335await watch(p);
336await send(p, `@chain file_write {"path":"${SCRATCH}/waiting.txt","content":"hello"}`);
337check('the multi-call turn ran to the end', (await settle(p)).ended);
338const shown = await reap(p);
339
340check('the app was seen working, so there was something to watch',
341 shown.working > 0, shown.working + ' of ' + shown.total + ' samples');
342check('the indicator never went dark while the turn was running',
343 shown.dark === 0, shown.dark ? shown.dark + ' sample(s) with work in flight and nothing on screen' : '');
344check('and it stayed at the foot of the thread, under what the turn had produced',
345 shown.adrift === 0, shown.adrift ? shown.adrift + ' sample(s) with the indicator adrift above later output' : '');
346check('it never sat there saying nothing',
347 shown.mute === 0, shown.mute ? shown.mute + ' silent sample(s)' : '');
348// The caption follows the work: `@chain` calls the named tool, then file_list.
349check('it named the tool that was actually running, and then the next one',
350 shown.saidIn.some(w => /file_write/.test(w)) && shown.saidIn.some(w => /file_list/.test(w)),
351 shown.saidIn.join(' | '));
352// BOTH HAVE TO BE THERE, and this is why. Written as a bare
353// `findIndex(file_write) < findIndex(file_list)`, an ABSENT first caption scores -1
354// and -1 is less than any index, so the check passed BECAUSE its subject was
355// missing — green, beside a sibling going red about the very same absence. An
356// ordering assertion over a set that may not hold either member has to say so.
357const iWrote = shown.saidIn.findIndex(w => /file_write/.test(w));
358const iList = shown.saidIn.findIndex(w => /file_list/.test(w));
359check('the tool it named first is the tool that ran first',
360 iWrote >= 0 && iList >= 0 && iWrote < iList,
361 `file_write at ${iWrote}, file_list at ${iList} — ` + shown.saidIn.join(' | '));
362check('and the line changed as the turn went on, rather than standing still',
363 shown.saidIn.length > 1, shown.saidIn.join(' | '));
364// Nothing about how long the user has been waiting: the app may notice, and must
365// never remark on it.
366check('it says nothing about how long any of it has taken',
367 !shown.saidIn.some(w => /\b(still|yet|long|wait|minute|second|patien)/i.test(w)),
368 shown.saidIn.join(' | '));
369
370const done1 = await resting(p);
371check('the answer ends the turn, and the indicator goes with it',
372 !done1.spinner && done1.send);
373
374await shot(s, 'waiting-steps-shown');
375
376// ── The same turn with the steps HIDDEN ─────────────────────────────
377//
378// The reported case. With the tool blocks hidden the thread genuinely does not
379// change for the whole of a tool-call run, so the indicator is the only thing
380// distinguishing work from a hang.
381
382await p.evaluate(() => document.getElementById('steps-toggle-btn').click());
383await p.waitForTimeout(200);
384
385await watch(p);
386await send(p, `@chain file_write {"path":"${SCRATCH}/waiting2.txt","content":"again"}`);
387check('the hidden-steps turn ran to the end', (await settle(p)).ended);
388const hidden = await reap(p);
389
390// Prove the case is really the hidden one before asserting anything about it.
391const reallyHidden = await p.evaluate(() => {
392 const out = document.getElementById('chat-output');
393 const blocks = [...out.querySelectorAll('.tool-block')];
394 return blocks.length > 0 && blocks.every(b => getComputedStyle(b).display === 'none');
395});
396check('the tool steps really are hidden, so this is the case that was reported',
397 reallyHidden);
398check('the indicator never went dark with the steps hidden either',
399 hidden.dark === 0, hidden.dark ? hidden.dark + ' dark sample(s)' : '');
400check('and it still said which tool was running, where nothing else could',
401 hidden.saidIn.some(w => /file_write/.test(w)), hidden.saidIn.join(' | '));
402
403await shot(s, 'waiting-steps-hidden');
404await p.evaluate(() => document.getElementById('steps-toggle-btn').click());
405await p.waitForTimeout(200);
406
407// ── Exit: the user presses Stop ─────────────────────────────────────
408
409await watch(p);
410await send(p, '@long 80');
411await p.waitForTimeout(1200);
412const wasUp = await p.evaluate(() =>
413 !!document.querySelector('#chat-output .chat-spinner'));
414check('a long answer is still being watched over while it streams', wasUp);
415await p.evaluate(() => document.getElementById('chat-send').click()); // Stop
416check('the stopped turn ended', (await settle(p)).ended);
417await reap(p);
418const stopped = await resting(p);
419check('stopping a turn takes the indicator down with it',
420 !stopped.spinner && stopped.send);
421
422// ── Exit: the provider fails ────────────────────────────────────────
423
424// A 500 IS RETRIED AND A 403 IS NOT, so the two are not the same turn with a
425// different number in it, and giving them one budget was what made this file red.
426//
427// `RetryPolicy::default()` (src/llm.rs) treats a 5xx as transient: eight attempts
428// over up to 120 s of backoff, widened from four over 20 s on 2026-08-19 so that a
429// laptop waking in another building keeps its turn. Measured in world 7 on
430// 2026-08-21 the retry notices land at 1.1, 1.7, 2.8, 4.8, 12.1, 20.9 and 40.8 s
431// and the turn ends at 59-69 s -- past the 40 s this waited, so it reported a
432// stuck indicator on a turn that was still correctly retrying. The indicator was
433// doing exactly what this file exists to require.
434//
435// So each code carries its own bound, and the bound on the 403 is the assertion:
436// a refusal the client must not retry has to end PROMPTLY. Should 403 ever be
437// classified retryable, the 403 arm goes over 15 s and this says so — which the
438// single shared budget could not.
439for (const { code, budget, why } of [
440 { code: 500, budget: 180000, why: 'retried: eight attempts, up to 120s of backoff' },
441 { code: 403, budget: 15000, why: 'not retried: the provider has answered' },
442]) {
443 await watch(p);
444 await send(p, '@err ' + code);
445 const end = await settle(p, budget);
446 check(`the ${code} turn ended`, end.ended,
447 `${(end.took / 1000).toFixed(1)}s of ${budget / 1000}s — ${why}`);
448 await reap(p);
449 const r = await resting(p);
450 check(`a ${code} from the provider takes the indicator down`,
451 !r.spinner && r.send);
452}
453
454// ── Exit: walk away mid-turn, and come back ─────────────────────────
455//
456// The half nobody sees. The thread is thrown away and rebuilt when a chat is
457// opened, so a turn still running elsewhere used to leave the indicator detached:
458// coming back found a chat that looked finished and was not, and — because the
459// node was still held — the NEXT turn could not raise one at all.
460
461// Whose tile to come back to. Taken before the turn starts, so the click below
462// reaches THIS chat and not whichever tile happens to be first.
463const working = await p.evaluate(() => {
464 const a = document.querySelector('.session-box.active');
465 return a ? a.dataset.id : '';
466});
467check('the chat about to be left is identifiable in the rail', !!working, working);
468
469await send(p, '@long 160');
470await p.waitForTimeout(1200);
471const fresh = await freshChat(p); // a second chat, on top of the running one
472await p.waitForTimeout(600);
473// Prove the case before asserting anything about it, as the hidden-steps case
474// above does. A fresh-chat check that is standing on the chat it meant to leave
475// is not testing a fresh chat, and it will say so either way.
476check('the new chat really is another chat, not the one still working',
477 !!fresh && fresh !== working, fresh + ' vs ' + working);
478check('and it opened on a thread of its own, with nothing in it',
479 await p.evaluate(() => {
480 const out = document.getElementById('chat-output');
481 return !!out && !out.querySelector('.chat-msg');
482 }));
483const away = await resting(p);
484check('a fresh chat shows no indicator of its own while another chat works',
485 !away.spinner);
486// The other half of the same state: the button is the composer's word for "this
487// chat is working", and a fresh chat must not inherit that either.
488check('nor does it inherit the other chat\'s Stop button', away.send);
489
490// Back to the one that is still going, through its own label, the way a person
491// opens a chat.
492await openChat(p, working);
493const back = await p.evaluate(() => {
494 const out = document.getElementById('chat-output');
495 const el = out ? out.querySelector('.chat-spinner') : null;
496 const b = document.getElementById('chat-send');
497 const say = el ? el.querySelector('.chat-spinner-say') : null;
498 return {
499 busy: !!b && b.classList.contains('stop'),
500 up: !!el,
501 say: say ? (say.textContent || '').trim() : '',
502 };
503});
504// `busy` is the app's own word for "this chat is still working", so the check is
505// conditional on it rather than on the clock: a stream that happened to finish
506// during the switch is not a failure, and asserting on a timing race would be.
507check('the chat left running is still running when we come back to it',
508 back.busy, back.busy ? '' : '(it finished during the switch — nothing was proved here)');
509check('coming back to a chat that is still working finds its indicator again',
510 !back.busy || back.up, back.busy ? 'indicator ' + (back.up ? 'up' : 'MISSING') : '');
511check('and it is still saying what that turn is doing',
512 !back.busy || !!back.say, back.say || '(nothing)');
513await settle(p);
514
515// ── A daimon's turn, and the thread it must not draw into ───────────
516//
517// The same rule one surface along, and the one where it bites hardest. A
518// Diamond's daimon is an ordinary chat record drawn by the ordinary renderer
519// into the one thread, so "am I the thing on screen?" is what keeps its turn
520// off everybody else's transcript — and it was answered once, before the turn
521// started, and then trusted for the length of it. Walk off the Diamond
522// mid-turn and the answer went with you: the daimon kept streaming, and an
523// ordinary chat that had long since finished its own work collected another
524// conversation's words as they arrived.
525//
526// The dots were never the visible part of this. They are decided by
527// `syncComposer` from whichever chat is current, so they were right the whole
528// time — which is exactly why it needs a check of its own rather than being
529// assumed to fall out of the indicator ones.
530
531await p.click('#new-diamond-btn', { force: true });
532await p.waitForSelector('.dlg-input', { timeout: 10000 });
533await p.fill('.dlg-input', 'Waiting');
534await p.click('.dlg-ok', { force: true });
535await p.waitForTimeout(900);
536const dia = await p.evaluate(() => {
537 const a = document.querySelector('#diamond-list .diamond-box.active')
538 || document.querySelector('#diamond-list .diamond-box');
539 return a ? a.dataset.id : '';
540});
541check('the Diamond about to be left is identifiable in the rail', !!dia, dia);
542
543await steerDiamond(s, '@long 200');
544await p.waitForTimeout(1200);
545check('the daimon turn is watched over on the Diamond it belongs to',
546 await p.evaluate(() => !!document.querySelector('#chat-output .chat-spinner')));
547
548// Walk off onto the ordinary chat made earlier, which has nothing of its own to
549// say and no turn of its own running.
550await openChat(p, fresh);
551const quietBefore = await threadText(p);
552await p.waitForTimeout(2500);
553const quietAfter = await threadText(p);
554
555// Back to the Diamond, both to stop the turn and to establish that there WAS
556// one running for all of that — the same conditional idiom as the switch above,
557// because a daimon that finished early would make the quiet meaningless.
558await p.evaluate((id) => {
559 const box = document.querySelector('#diamond-list .diamond-box[data-id="' + id + '"]');
560 if (box) box.click();
561}, dia);
562await p.waitForTimeout(800);
563const face = await p.$('#dview-chat');
564if (face) { await face.click({ force: true }); await p.waitForTimeout(400); }
565const daimonBusy = await p.evaluate(() => {
566 const b = document.getElementById('chat-send');
567 return !!b && b.classList.contains('stop');
568});
569check('the daimon turn was still running for the whole of that absence',
570 daimonBusy, daimonBusy ? '' : '(it finished while we were away — nothing was proved here)');
571check('a daimon turn writes nothing into the chat you walked away to',
572 !daimonBusy || quietAfter === quietBefore,
573 quietAfter === quietBefore ? '' : 'the thread gained: '
574 + JSON.stringify(quietAfter.slice(quietBefore.length, quietBefore.length + 60)));
575
576if (daimonBusy) await p.evaluate(() => document.getElementById('chat-send').click()); // Stop
577await settle(p);
578check('and stopping it leaves no indicator on the Diamond either',
579 (await resting(p)).spinner === false);
580await openChat(p, working); // back to a chat, for the reload below
581
582// ── Exit: the page reloads in the middle ────────────────────────────
583
584await send(p, '@long 200');
585await p.waitForTimeout(1200);
586await p.reload({ waitUntil: 'domcontentloaded' });
587await signInAs(s, 'waiting');
588await p.waitForTimeout(1200);
589const afterReload = await p.evaluate(() => {
590 const out = document.getElementById('chat-output');
591 const b = document.getElementById('chat-send');
592 return {
593 spinner: !!(out && out.querySelector('.chat-spinner')),
594 stop: !!b && b.classList.contains('stop'),
595 };
596});
597check('a reload in the middle of a turn does not come back to a stuck indicator',
598 !afterReload.spinner && !afterReload.stop);
599
600// ── Nothing anywhere is left claiming to run ────────────────────────
601
602const anyLeft = await p.evaluate(() =>
603 !!document.querySelector('.chat-spinner, .crystal-spinner'));
604check('with every turn finished, no indicator is left anywhere on the page',
605 !anyLeft);
606
607await shot(s, 'waiting-rest');
608
609const errs = errors(s).filter(e => !/favicon|404|401|402|403|500|502|Bad Gateway|net::ERR|mock: as requested/.test(e));
610check('and none of it throws', errs.length === 0, errs[0] || '');
611
612await s.close();
613console.log(`\n${ok.length} passed, ${bad.length} failed`);
614if (bad.length) console.log('FAILED:\n ' + bad.join('\n '));
615process.exit(bad.length ? 1 : 0);