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 | |
| 20 | import { open, newChat, shot, errors } from './harness.mjs'; |
| 21 | |
| 22 | const ok = [], bad = []; |
| 23 | const check = (name, pass, detail) => { |
| 24 | (pass ? ok : bad).push(name + (detail ? ' — ' + detail : '')); |
| 25 | console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : '')); |
| 26 | }; |
| 27 | const sleep = ms => new Promise(r => setTimeout(r, ms)); |
| 28 | |
| 29 | const s = await open({ name: 'queue' }); |
| 30 | const p = s.page; |
| 31 | |
| 32 | // Record toasts as they appear: they remove themselves after ~4s. |
| 33 | await 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 | }); |
| 43 | const toasts = () => p.evaluate(() => window.__toasts.slice()); |
| 44 | |
| 45 | /// The shape of the thread: the real turns, and the queue beside them. |
| 46 | const 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". |
| 66 | async 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. |
| 76 | async 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. |
| 85 | async 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 | |
| 93 | await 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. |
| 98 | await p.fill('#chat-input', '@long 120'); |
| 99 | await p.click('#chat-send', { force: true }); |
| 100 | await sleep(1500); |
| 101 | const running = await shape(); |
| 102 | check('the composer is not disabled while the answer arrives', |
| 103 | running.inputDisabled === false, `disabled=${running.inputDisabled}`); |
| 104 | check('and Send still means Stop while it runs (the signal everything else reads)', |
| 105 | running.stopMode === true); |
| 106 | const ph = await p.$eval('#chat-input', e => e.placeholder); |
| 107 | check('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 ─────────────────────── |
| 111 | const before = await shape(); |
| 112 | await type('QUEUED-ONE'); |
| 113 | const after = await shape(); |
| 114 | check('a message typed mid-turn appears as a queued bubble', |
| 115 | after.queued.length === 1 && after.queued[0] === 'QUEUED-ONE', JSON.stringify(after.queued)); |
| 116 | check('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}`); |
| 119 | check('and every existing turn number is untouched', |
| 120 | after.turns === before.turns, `${before.turns} → ${after.turns}`); |
| 121 | check('the composer is cleared, as it is on an ordinary send', |
| 122 | after.composer === '', JSON.stringify(after.composer)); |
| 123 | check('the queue sits at the bottom of the thread, under what has happened', |
| 124 | after.boxIsLast === true); |
| 125 | check('a queue on its own counts as work in flight (the updater must not reload it away)', |
| 126 | after.busy === true); |
| 127 | const qhead = await p.$eval('.chat-queued-head', e => e.textContent).catch(() => ''); |
| 128 | check('and the queue says when what is in it will be sent', |
| 129 | /finish/i.test(qhead), qhead || '(no head line)'); |
| 130 | await shot(s, 'queue-waiting'); |
| 131 | |
| 132 | // ── 4. A second one queues behind the first, in order ──────────────── |
| 133 | await type('QUEUED-TWO'); |
| 134 | const two = await shape(); |
| 135 | check('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 ──────────────── |
| 139 | await idle(90000); |
| 140 | await sleep(1200); |
| 141 | await idle(90000); // the second queued turn |
| 142 | await sleep(1500); |
| 143 | const drained = await shape(); |
| 144 | check('every queued message became a real turn of its own', |
| 145 | drained.users === before.users + 2, `${before.users} → ${drained.users} user turns`); |
| 146 | check('in the order they were typed', |
| 147 | /QUEUED-ONE[\s\S]*QUEUED-TWO/.test(drained.userText), drained.userText.slice(0, 160)); |
| 148 | check('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); |
| 150 | check('nothing is left queued once it has been sent', |
| 151 | drained.queued.length === 0 && drained.busy === false, JSON.stringify(drained.queued)); |
| 152 | await shot(s, 'queue-drained'); |
| 153 | |
| 154 | // ── 6. The × drops one without sending it ──────────────────────────── |
| 155 | await p.fill('#chat-input', '@long 60'); |
| 156 | await p.click('#chat-send', { force: true }); |
| 157 | await sleep(1200); |
| 158 | await type('CANCEL-ME'); |
| 159 | await clickIf('.chat-msg-queued .queue-x'); |
| 160 | const cancelled = await shape(); |
| 161 | check('the × takes a queued message back out', |
| 162 | cancelled.queued.length === 0, JSON.stringify(cancelled.queued)); |
| 163 | await idle(90000); |
| 164 | await sleep(1000); |
| 165 | const afterCancel = await shape(); |
| 166 | check('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 ─ |
| 170 | await p.fill('#chat-input', '@long 60'); |
| 171 | await p.click('#chat-send', { force: true }); |
| 172 | await sleep(1200); |
| 173 | await type('EDIT-ME'); |
| 174 | await clickIf('.chat-msg-queued .chat-msg-content'); |
| 175 | const edited = await shape(); |
| 176 | check('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. |
| 182 | await p.fill('#chat-input', ''); |
| 183 | await idle(90000); |
| 184 | await sleep(1000); |
| 185 | |
| 186 | // ── 8. Stop hands the queue back rather than sending it ────────────── |
| 187 | const beforeStop = await shape(); |
| 188 | await p.fill('#chat-input', '@long 120'); |
| 189 | await p.click('#chat-send', { force: true }); |
| 190 | await sleep(1500); |
| 191 | await type('AFTER-STOP'); |
| 192 | await p.click('#chat-send', { force: true }); // in stop-mode: kills the turn |
| 193 | await sleep(2500); |
| 194 | const stopped = await shape(); |
| 195 | check('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)}`); |
| 198 | check('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))); |
| 201 | await sleep(3000); |
| 202 | const afterStop = await shape(); |
| 203 | check('nothing queued behind a stopped turn is sent', |
| 204 | afterStop.users === beforeStop.users + 1, `${beforeStop.users} → ${afterStop.users} (the stopped turn only)`); |
| 205 | await 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. |
| 212 | const 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. |
| 219 | async 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. |
| 227 | async 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 | |
| 236 | const beforeB = (await tiles()).map(t => t.id); |
| 237 | await addChat(); |
| 238 | const allTiles = await tiles(); |
| 239 | const chatB = allTiles.map(t => t.id).find(id => beforeB.indexOf(id) === -1) || ''; |
| 240 | const chatA = beforeB[0] || ''; |
| 241 | check('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. |
| 245 | await openTile(chatA); |
| 246 | await p.fill('#chat-input', '@long 30'); |
| 247 | await p.click('#chat-send', { force: true }); |
| 248 | await sleep(1200); |
| 249 | await type('QUEUED-AWAY'); |
| 250 | const queuedOnA = (await shape()).queued; |
| 251 | check('the message queues on the chat it was typed in', |
| 252 | JSON.stringify(queuedOnA) === JSON.stringify(['QUEUED-AWAY']), JSON.stringify(queuedOnA)); |
| 253 | await openTile(chatB); |
| 254 | const away = await tiles(); |
| 255 | const badgeA = (away.find(t => t.id === chatA) || {}).badge; |
| 256 | check('the tile of the chat left behind says something is waiting on it', |
| 257 | !!badgeA, `badge=${JSON.stringify(badgeA)}`); |
| 258 | check('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. |
| 262 | await sleep(6000); |
| 263 | const stillAway = await tiles(); |
| 264 | check('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. |
| 269 | const bBefore = (await shape()).users; |
| 270 | await openTile(chatA); |
| 271 | await idle(90000); |
| 272 | await sleep(1500); |
| 273 | const returned = await shape(); |
| 274 | const cleared = await tiles(); |
| 275 | check('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)); |
| 277 | check('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}`); |
| 281 | check('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)); |
| 284 | check('the other chat is untouched by any of it', bBefore >= 0 && !!chatB); |
| 285 | await 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. |
| 291 | await openTile(chatA); |
| 292 | await p.fill('#chat-input', '@long 60'); |
| 293 | await p.click('#chat-send', { force: true }); |
| 294 | await sleep(1200); |
| 295 | await 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. |
| 299 | await 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); |
| 305 | await sleep(2500); |
| 306 | await openTile(chatA); |
| 307 | await sleep(1200); |
| 308 | const handed = await shape(); |
| 309 | check('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)}`); |
| 312 | check('and it was never sent as a turn', !/QUEUED-STOPPED/.test(handed.userText), |
| 313 | handed.userText.slice(-80)); |
| 314 | await p.fill('#chat-input', ''); |
| 315 | |
| 316 | const errs = errors(s).filter(e => !/favicon|404|401|402|502|Bad Gateway|net::ERR/.test(e)); |
| 317 | console.log('\nconsole errors:', errs.slice(0, 5)); |
| 318 | check('nothing throws while all this happens', errs.length === 0, errs[0] || ''); |
| 319 | |
| 320 | await s.close(); |
| 321 | console.log(`\n${ok.length} passed, ${bad.length} failed`); |
| 322 | if (bad.length) console.log('FAILED:\n ' + bad.join('\n ')); |
| 323 | process.exit(bad.length ? 1 : 0); |