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. |
| 22 | import fs from 'node:fs'; |
| 23 | import path from 'node:path'; |
| 24 | import { fileURLToPath } from 'node:url'; |
| 25 | import { 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. |
| 31 | const APPDIR = path.join(path.dirname(fileURLToPath(import.meta.url)), '..'); |
| 32 | const BROKEN = process.argv.includes('--broken'); |
| 33 | |
| 34 | // The whole of the fix, in one line either way. |
| 35 | const FIXED_LINE = '\t\tsyncComposer();\n\t\tif (onCrystal) showCrystalSpinner();'; |
| 36 | const BROKEN_LINE = '\t\tif (onScreen()) syncComposer();\n\t\tif (onCrystal) showCrystalSpinner();'; |
| 37 | |
| 38 | let failures = 0; |
| 39 | const 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. |
| 45 | const 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? |
| 59 | const generating = (p) => p.evaluate(() => |
| 60 | !!(window.DaimondCore && window.DaimondCore.busy && window.DaimondCore.busy())); |
| 61 | |
| 62 | async 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 | |
| 76 | const 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 | |
| 85 | const s = await open({ name: 'stopprobe', profile: scratch('pw', 'stopprobe-' + process.pid), route }); |
| 86 | const { page: p } = s; |
| 87 | try { |
| 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 | } |
| 155 | console.log((BROKEN ? 'BROKEN' : 'FIXED') + ': ' + (failures ? failures + ' failure(s)' : 'all checks passed')); |
| 156 | process.exit(failures ? 1 : 0); |