oxedyne/daimond/dev/verify_daimonface.mjs
31.6 KiB, 1 run
created by r2519314175:345, 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_daimonface.mjs — the Centre knows which face it is showing, and says so once. |
| 2 | // |
| 3 | // WHAT THIS IS FOR. The Centre has three faces and one variable that names the one it is |
| 4 | // showing: `centreMode`, written only by `showCentre`, holding one of `CENTRE_FACES` — |
| 5 | // `'chat'` (an ordinary chat), `'focus'` (a Diamond's crystal) and `'daimon'` (a Diamond's |
| 6 | // own conversation, drawn in the chat's thread). Three guards ask which face is up before |
| 7 | // they draw, and all three are about the same thing: IS THIS CONVERSATION THE ONE ON SCREEN. |
| 8 | // |
| 9 | // * "Fresh daimon" empties a daimon's conversation, and has to repaint the thread. The |
| 10 | // dialog it is pressed in overlays the app, so what is behind it is stale until |
| 11 | // something repaints it — and closing a dialog repaints nothing. It also has to make the |
| 12 | // emptying STICK, which is a separate claim and was the false one: the transcript merge |
| 13 | // is a deliberate UNION, so an emptied array saved straight back was refilled from the |
| 14 | // store, and the conversation returned on the next reload with the screen none the wiser. |
| 15 | // * Fold belongs to the conversation a hand fold can REACH, and to no other. It was |
| 16 | // drawn on the daimon's face and nowhere else, which is precisely backwards: a |
| 17 | // Diamond engine's session holds no messages, so `fold_now` there compacts nothing |
| 18 | // and bills for the round; an ordinary chat is the one that can be folded by hand. |
| 19 | // * A daimon's reply streams into the thread AS IT ARRIVES, and only when that thread is |
| 20 | // the one on screen. A turn can run from a gather round with its Diamond nowhere near |
| 21 | // the screen, and drawing then puts one conversation's words in another's transcript. |
| 22 | // |
| 23 | // WHY THIS FILE EXISTS, which is not the same as what it checks. `centreMode` was declared |
| 24 | // with the comment `'chat' | 'focus'` and had been holding `'daimon'` since a Diamond grew |
| 25 | // two faces — `selectDiamond` calls `showCentre('daimon')`. Read from the declaration alone, |
| 26 | // the three guards above are three dead branches, each a real feature that has supposedly |
| 27 | // never run, and the obvious repair is to make all three live at once. They were already |
| 28 | // live. The COMMENT was the defect, and it was very nearly paid for three times over. |
| 29 | // |
| 30 | // So the check that matters most here is the fourth, and it is worth more than the other |
| 31 | // three: THE DOCUMENTED FACES, THE FACES ANYTHING ENTERS, AND THE FACES ANYTHING COMPARES |
| 32 | // AGAINST MUST BE THE SAME SET. A comparison against a face nothing enters is a dead branch; |
| 33 | // a face documented but never entered is a promise nothing keeps; a write to `centreMode` |
| 34 | // from outside `showCentre` puts the app in a face the list never sanctioned. None of the |
| 35 | // three can be seen by looking at any one line. |
| 36 | // |
| 37 | // EACH CHECK IS PROVED AGAINST BROKEN CODE FIRST. |
| 38 | // |
| 39 | // node dev/verify_daimonface.mjs --break no-redraw # 1a fails: Fresh daimon stops repainting |
| 40 | // node dev/verify_daimonface.mjs --break resurrect # 1b+1d fail: the union puts it all back |
| 41 | // node dev/verify_daimonface.mjs --break sessionless # 1c fails: the model keeps the conversation |
| 42 | // node dev/verify_daimonface.mjs --break clear-midturn # 1e fails: a clear a turn then undoes |
| 43 | // node dev/verify_daimonface.mjs --break fold-drift # 2 fails: Fold goes back to the daimon |
| 44 | // node dev/verify_daimonface.mjs --break crosstalk # 3a fails: a reply lands in another thread |
| 45 | // node dev/verify_daimonface.mjs --break mute # 3b fails: nothing streams into its own |
| 46 | // node dev/verify_daimonface.mjs --break dead-compare # 4a+4b fail: a face nothing enters |
| 47 | // node dev/verify_daimonface.mjs --break ghost-face # 4c fails: a face documented and unreachable |
| 48 | // node dev/verify_daimonface.mjs --break side-assign # 4d+4e fail: `centreMode` written elsewhere |
| 49 | // node dev/verify_daimonface.mjs --break indirect-compare # 4e fails: the comparison hides |
| 50 | // node dev/verify_daimonface.mjs --break unhelped # NOTHING fails, and that is the point |
| 51 | // node dev/verify_daimonface.mjs # and then, clean |
| 52 | // |
| 53 | // WHAT THE FOURTH CHECK STILL CANNOT SEE, attacked and confirmed rather than guessed at: |
| 54 | // |
| 55 | // * A face entered ONLY from code that never runs — `if (false) showCentre('stage');` — |
| 56 | // counts as entered, so 4b and 4c are satisfied by a face nothing reaches. Reading the |
| 57 | // source cannot tell a live call from a dead one, and this file does not pretend to. |
| 58 | // * The comment stripper knows strings but not REGEX LITERALS, so a regex containing a lone |
| 59 | // quote (`/['"]/`) desynchronises it and can swallow the code after it. Nothing in the |
| 60 | // file does that today and 4e's residue scan would notice the loss of a whole comparison, |
| 61 | // but a regex is where this breaks first, and that is where to look. |
| 62 | // * It is a scan of one file. `centreMode` is a closure variable and no other file can see |
| 63 | // it, which is the only reason that is enough. |
| 64 | // |
| 65 | // `crosstalk` and `mute` are two breaks and not one because the guard has TWO ways to be |
| 66 | // wrong and a check for either alone passes while the other is broken: `mute` makes it always |
| 67 | // false, so nothing is ever drawn live and no thread is ever polluted; `crosstalk` makes it |
| 68 | // forget WHICH thread, so every thread on screen is drawn into and the right one is drawn |
| 69 | // into too. A single check would have been green under one of them. |
| 70 | // |
| 71 | // `unhelped` is the odd one and is not a break at all: it restores the raw compound conditions |
| 72 | // the one helper replaced. It MUST leave every check green, which is how this file says that |
| 73 | // collapsing the copies into `daimonOnScreen` was a refactor and not a change of behaviour. Red |
| 74 | // there is a failure of the refactor, and the run says so. It covers TWO sites and not three: |
| 75 | // `syncFoldBtn` no longer asks which face is up at all, since a daimon's record is `current` on |
| 76 | // both of a Diamond's faces and `diamondId` therefore answers for both. |
| 77 | // |
| 78 | // THE HARNESS. Use a world, or the run silently drives world 0 on :8777 and says nothing: |
| 79 | // |
| 80 | // eval "$(bash dev/world.sh 3 --env)" && node dev/verify_daimonface.mjs |
| 81 | import fs from 'node:fs'; |
| 82 | import path from 'node:path'; |
| 83 | import { fileURLToPath } from 'node:url'; |
| 84 | import { open, signInAs, steerDiamond, newChat } from './harness.mjs'; |
| 85 | |
| 86 | const HERE = path.dirname(fileURLToPath(import.meta.url)); |
| 87 | const WWW = path.join(HERE, '..', 'www'); |
| 88 | const SRC = 'js/daimond.js'; |
| 89 | |
| 90 | const BREAK = (() => { |
| 91 | const i = process.argv.indexOf('--break'); |
| 92 | return i > 0 ? String(process.argv[i + 1] || '') : ''; |
| 93 | })(); |
| 94 | |
| 95 | // Every break names its sites. A break may name several, and every one of them has to land: |
| 96 | // an anchor that no longer matches is a break that quietly stopped applying, and a green run |
| 97 | // under it proves nothing at all. |
| 98 | const BREAKS = { |
| 99 | // The repaint after "Fresh daimon" simply never runs. The record still clears — which is |
| 100 | // the point of asserting on what is DRAWN rather than on what is stored. |
| 101 | 'no-redraw': { |
| 102 | find: " if (daimonOnScreen(rec)) {\n await selectDiamond(f, 'chat');", |
| 103 | with: " if (false) {\n await selectDiamond(f, 'chat');", |
| 104 | }, |
| 105 | // THE OLD CONDITION, restored: Fold drawn on the daimon's face and on no other. It is a |
| 106 | // real comparison against a real face, so every static check stays green and only the |
| 107 | // control checks move — which is the point, because for months this was the shipped |
| 108 | // behaviour and nothing in this file objected. It must redden 2a (the control is back on |
| 109 | // the face that cannot use it) and 2c (it is gone from the one that can). |
| 110 | 'fold-drift': { |
| 111 | find: " b.style.display = (current && !current.diamondId && chatSaid(current)) ? '' : 'none';", |
| 112 | with: " b.style.display = (daimonOnScreen(current) && current.diamondId) ? '' : 'none';", |
| 113 | }, |
| 114 | // The guard forgets WHICH conversation and remembers only that some conversation is up. |
| 115 | crosstalk: { |
| 116 | find: " var onScreen = function () { return daimonOnScreen(rec); };", |
| 117 | with: " var onScreen = function () { return !!(current && centreMode === 'daimon'); };", |
| 118 | }, |
| 119 | // The tombstones come off, so the union puts the conversation straight back. The SCREEN |
| 120 | // still clears, which is what made this so hard to see: every check that watches the |
| 121 | // thread stays green and only the store, and a reload, know anything happened. |
| 122 | resurrect: { |
| 123 | find: " msgTombstone((rec.messages || []).map(function (m) { return m.mid; }));\n", |
| 124 | with: "", |
| 125 | }, |
| 126 | // The model's own conversation goes back to `null` — "this tab does not know" — which |
| 127 | // `persistChats` fills in from the store. Invisible on screen: the thread is empty and the |
| 128 | // next turn quietly carries the whole discarded conversation to the model, and is billed |
| 129 | // for it. |
| 130 | sessionless: { |
| 131 | find: " rec.session = { v: 1, msgs: [], upto: '', uptoTs: 0 };", |
| 132 | with: " rec.session = null;", |
| 133 | }, |
| 134 | // The refusal comes off, so a clear can be taken mid-turn and undone by the turn. |
| 135 | 'clear-midturn': { |
| 136 | find: " if (rec._generating) {\n await noticeDialog(t('tile.daimon_reset_busy_title'), t('tile.daimon_reset_busy'));\n return;\n }", |
| 137 | with: " if (false) { return; }", |
| 138 | }, |
| 139 | // The guard is always false — the state the whole file was mistakenly believed to be in. |
| 140 | mute: { |
| 141 | find: " var onScreen = function () { return daimonOnScreen(rec); };", |
| 142 | with: " var onScreen = function () { return false; };", |
| 143 | }, |
| 144 | // A comparison against a face nothing ever enters: the dead branch, in its natural form — |
| 145 | // somebody writing the DIAMOND view's word ('crystal') where the CENTRE's word ('focus') |
| 146 | // belongs. Deliberately placed at a site none of the three behaviour checks measures, so |
| 147 | // it proves the static check and not one of them. |
| 148 | 'dead-compare': { |
| 149 | find: " toast(centreMode === 'focus'", |
| 150 | with: " toast(centreMode === 'crystal'", |
| 151 | }, |
| 152 | // A face in the list that nothing enters: a promise the app does not keep. |
| 153 | 'ghost-face': { |
| 154 | find: " var CENTRE_FACES = ['chat', 'focus', 'daimon'];", |
| 155 | with: " var CENTRE_FACES = ['chat', 'focus', 'daimon', 'stage'];", |
| 156 | }, |
| 157 | // A second writer for `centreMode`, which makes every reading above unsound: the set of |
| 158 | // faces the app can be in is no longer the set `showCentre` sanctions. Behaviour here is |
| 159 | // unchanged, which is exactly why nothing but the static check can see it. |
| 160 | 'side-assign': { |
| 161 | find: " showCentre('daimon');\n renderHistory(rec.messages);", |
| 162 | with: " centreMode = 'daimon';\n showCentre('daimon');\n renderHistory(rec.messages);", |
| 163 | }, |
| 164 | // The comparison goes through a variable, so the scan for literal comparisons stops seeing |
| 165 | // it. Nothing about the app changes except that one face is now compared against a name -- |
| 166 | // and 4a and 4b go green on a set one face SMALLER without a word about the one that left. |
| 167 | // This is the original defect wearing a disguise, and 4e is what strips it. |
| 168 | 'indirect-compare': { |
| 169 | find: " return !!(rec && current && current.id === rec.id && centreMode === 'daimon');", |
| 170 | with: " var face = 'daimon';\n return !!(rec && current && current.id === rec.id && centreMode === face);", |
| 171 | }, |
| 172 | // Not a break: the three raw conditions the helper replaced, restored. Everything must |
| 173 | // stay green. |
| 174 | unhelped: [ |
| 175 | { |
| 176 | find: " if (daimonOnScreen(rec)) {", |
| 177 | with: " if (currentDiamond && currentDiamond.id === id && centreMode === 'daimon') {", |
| 178 | }, |
| 179 | { |
| 180 | find: " var onScreen = function () { return daimonOnScreen(rec); };", |
| 181 | with: " var onScreen = function () {\n return !!(current && rec && current.id === rec.id && centreMode === 'daimon');\n };", |
| 182 | }, |
| 183 | ], |
| 184 | }; |
| 185 | // The one that must stay green. Named here rather than tested by string, so adding another |
| 186 | // equivalence break is one line and not a scattered special case. |
| 187 | const MUST_STAY_GREEN = new Set(['unhelped']); |
| 188 | |
| 189 | const ok = [], bad = []; |
| 190 | const check = (name, pass, detail) => { |
| 191 | (pass ? ok : bad).push(name + (detail ? ' — ' + detail : '')); |
| 192 | console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : '')); |
| 193 | }; |
| 194 | |
| 195 | // ── The source under test, broken or not ──────────────────────────────── |
| 196 | // |
| 197 | // The STATIC checks below read this string and not the file on disk, so a break that is a |
| 198 | // static regression really does turn its check red. |
| 199 | let source = fs.readFileSync(path.join(WWW, SRC), 'utf8'); |
| 200 | if (BREAK) { |
| 201 | const spec = BREAKS[BREAK]; |
| 202 | if (!spec) { console.error(`no such break: ${BREAK}`); process.exit(2); } |
| 203 | for (const site of (Array.isArray(spec) ? spec : [spec])) { |
| 204 | const n = source.split(site.find).length - 1; |
| 205 | if (n !== 1) { |
| 206 | console.error(`break '${BREAK}': the anchor appears ${n} times in ${SRC}, so nothing ` |
| 207 | + 'was broken and the run below would prove nothing. The file has moved on; ' |
| 208 | + 'move the anchor with it.\n ' + site.find.split('\n')[0].trim()); |
| 209 | process.exit(2); |
| 210 | } |
| 211 | source = source.replace(site.find, site.with); |
| 212 | } |
| 213 | } |
| 214 | |
| 215 | // ── 4. The regression check: one set of faces, agreed on from three directions ── |
| 216 | // |
| 217 | // COMMENTS ARE STRIPPED FIRST, and they have to be: the declaration of `CENTRE_FACES` carries |
| 218 | // a paragraph about the day this went wrong, and that paragraph quotes `centreMode === |
| 219 | // 'daimon'`. Scanned raw, the check reads its own explanation as code. This is a line-and-block |
| 220 | // stripper that respects quotes; it does not understand regex literals, which is stated rather |
| 221 | // than hidden — `centreMode` never appears in one, and if it ever does, this comment is the |
| 222 | // place the next reader will look. |
| 223 | function stripComments(s) { |
| 224 | let out = '', i = 0, n = s.length; |
| 225 | while (i < n) { |
| 226 | const c = s[i], d = s[i + 1]; |
| 227 | if (c === '/' && d === '/') { while (i < n && s[i] !== '\n') i++; continue; } |
| 228 | if (c === '/' && d === '*') { i += 2; while (i < n && !(s[i] === '*' && s[i + 1] === '/')) i++; i += 2; continue; } |
| 229 | if (c === '"' || c === "'" || c === '`') { |
| 230 | out += c; i++; |
| 231 | while (i < n && s[i] !== c) { if (s[i] === '\\') { out += s[i]; i++; } out += s[i]; i++; } |
| 232 | out += s[i]; i++; continue; |
| 233 | } |
| 234 | out += c; i++; |
| 235 | } |
| 236 | return out; |
| 237 | } |
| 238 | const code = stripComments(source); |
| 239 | |
| 240 | // What the file says the faces are. |
| 241 | const facesDecl = /var CENTRE_FACES = \[([^\]]*)\];/.exec(code); |
| 242 | const documented = facesDecl |
| 243 | ? [...facesDecl[1].matchAll(/'([^']*)'/g)].map(m => m[1]) |
| 244 | : []; |
| 245 | check('the faces are declared in one place the checks can read', |
| 246 | documented.length > 0, documented.join(', ') || '(CENTRE_FACES not found)'); |
| 247 | |
| 248 | // What anything actually enters. Every call must pass a literal, or none of this is sound: |
| 249 | // a computed face would mean the set cannot be read from the source at all. |
| 250 | const calls = [...code.matchAll(/(?<!function )showCentre\(([^)]*)\)/g)] |
| 251 | .map(m => m[1].trim()) |
| 252 | .filter(a => a !== 'mode'); |
| 253 | const nonLiteral = calls.filter(a => !/^'[^']*'$/.test(a)); |
| 254 | check('4d. EVERY `showCentre` CALL NAMES A FACE OUTRIGHT, so the set can be read at all', |
| 255 | nonLiteral.length === 0, nonLiteral.join(' | ') || `${calls.length} calls, all literal`); |
| 256 | const entered = [...new Set(calls.filter(a => /^'[^']*'$/.test(a)).map(a => a.slice(1, -1)))]; |
| 257 | |
| 258 | // What anything compares against. |
| 259 | const compared = [...new Set([...code.matchAll(/centreMode\s*[=!]==\s*'([^']*)'/g)].map(m => m[1]))]; |
| 260 | check('the checks found real comparisons to judge', compared.length > 0, compared.join(', ')); |
| 261 | |
| 262 | // 4a. Nothing compares against a face the list does not name. |
| 263 | const undocumented = compared.filter(v => documented.indexOf(v) < 0); |
| 264 | check('4a. NOTHING COMPARES `centreMode` AGAINST A FACE THE LIST DOES NOT NAME', |
| 265 | undocumented.length === 0, |
| 266 | undocumented.length ? 'stray: ' + undocumented.join(', ') : compared.join(', ')); |
| 267 | |
| 268 | // 4b. Nothing compares against a face nothing enters. THIS is the dead branch, and it is the |
| 269 | // one that hid here for months: a guard that can never be true, guarding a real feature. |
| 270 | const unreachable = compared.filter(v => entered.indexOf(v) < 0); |
| 271 | check('4b. NOTHING COMPARES AGAINST A FACE NOTHING EVER ENTERS (the dead branch)', |
| 272 | unreachable.length === 0, |
| 273 | unreachable.length ? 'dead: ' + unreachable.join(', ') : 'entered: ' + entered.join(', ')); |
| 274 | |
| 275 | // 4c. Nothing is documented that cannot be entered. |
| 276 | const promised = documented.filter(v => entered.indexOf(v) < 0); |
| 277 | check('4c. EVERY DOCUMENTED FACE IS ONE SOMETHING ENTERS', |
| 278 | promised.length === 0, |
| 279 | promised.length ? 'never entered: ' + promised.join(', ') : documented.join(', ')); |
| 280 | |
| 281 | // 4d. `centreMode` is written in exactly two places: its declaration, and `showCentre`, which |
| 282 | // refuses a face the list does not name. A third writer makes every reading above unsound. |
| 283 | const writes = [...code.matchAll(/centreMode\s*=\s*([^=][^;]*);/g)].map(m => m[1].trim()); |
| 284 | check('4d. `centreMode` IS WRITTEN ONLY BY ITS DECLARATION AND BY `showCentre`', |
| 285 | writes.length === 2 && writes[0] === "'chat'" && writes[1] === 'mode', |
| 286 | writes.join(' | ')); |
| 287 | check('and `showCentre` refuses a face the list does not name', |
| 288 | /CENTRE_FACES\.indexOf\(mode\) < 0/.test(code), 'the guard is in showCentre'); |
| 289 | |
| 290 | // 4e. EVERY USE OF THE NAME IS ONE OF THREE SHAPES. Without this, 4a and 4b are read straight |
| 291 | // past by `var face = 'stage'; if (centreMode === face)` and by `switch (centreMode)`: the |
| 292 | // comparison simply stops being visible, and the checks above go green on a SMALLER set than |
| 293 | // before while saying nothing about what left it — which is the original defect exactly, |
| 294 | // reintroduced under the check written to catch it. Attacked and confirmed. So the file is |
| 295 | // allowed only the declaration, `showCentre`'s own write, and comparison against a literal. |
| 296 | const residue = code |
| 297 | .replace(/var centreMode = '[^']*';/g, '') |
| 298 | .replace(/centreMode\s*=\s*mode;/g, '') |
| 299 | .replace(/centreMode\s*[=!]==\s*'[^']*'/g, ''); |
| 300 | const stray = [...residue.matchAll(/.{0,34}\bcentreMode\b.{0,34}/g)] |
| 301 | .map(m => m[0].replace(/\s+/g, ' ').trim()); |
| 302 | check('4e. `centreMode` IS ONLY DECLARED, SET BY `showCentre`, OR COMPARED TO A LITERAL FACE', |
| 303 | stray.length === 0, stray.join(' ¦ ') || 'no other use of the name'); |
| 304 | |
| 305 | // ── The browser half ──────────────────────────────────────────────────── |
| 306 | const APP = process.env.DAIMOND_APP || 'http://localhost:8777'; |
| 307 | console.log(`\ndriving ${APP}${process.env.DAIMOND_APP ? '' : ' (NO WORLD SET — this is world 0)'}\n`); |
| 308 | |
| 309 | const s = await open({ |
| 310 | name: 'daimonface', defaults: false, |
| 311 | route: BREAK ? async (page) => { |
| 312 | await page.route('**/' + SRC, r => r.fulfill({ |
| 313 | status: 200, contentType: 'application/javascript', body: source, |
| 314 | })); |
| 315 | } : null, |
| 316 | }); |
| 317 | const { page } = s; |
| 318 | |
| 319 | /// What the Centre is actually drawing, read the way a reader sees it. |
| 320 | const drawn = () => page.evaluate(() => { |
| 321 | const co = document.getElementById('chat-output'); |
| 322 | const vis = e => !!e && !!(e.offsetWidth || e.offsetHeight || e.getClientRects().length); |
| 323 | const fold = document.getElementById('chat-fold-btn'); |
| 324 | return { |
| 325 | users: co ? [...co.querySelectorAll('.chat-msg-user')].map(e => (e.textContent || '').trim()) : [], |
| 326 | empty: !!(co && co.querySelector('.chat-msg-empty')), |
| 327 | text: co ? (co.textContent || '').replace(/\s+/g, ' ') : '', |
| 328 | fold: vis(fold), |
| 329 | }; |
| 330 | }); |
| 331 | |
| 332 | /// Open a Diamond by name, on the face asked for. |
| 333 | async function openDiamond(name, face) { |
| 334 | await page.evaluate((n) => { |
| 335 | const box = [...document.querySelectorAll('.diamond-box')] |
| 336 | .find(b => (b.textContent || '').indexOf(n) >= 0); |
| 337 | if (!box) throw new Error('no Diamond named ' + n); |
| 338 | box.click(); |
| 339 | }, name); |
| 340 | await page.waitForTimeout(900); |
| 341 | await page.click('#dview-' + face, { force: true }); |
| 342 | await page.waitForTimeout(900); |
| 343 | } |
| 344 | |
| 345 | async function makeDiamond(name) { |
| 346 | await page.click('#new-diamond-btn', { force: true }); |
| 347 | await page.waitForSelector('.dlg-input', { timeout: 10000 }); |
| 348 | await page.fill('.dlg-input', name); |
| 349 | await page.click('.dlg-ok', { force: true }); |
| 350 | await page.waitForTimeout(1800); |
| 351 | } |
| 352 | |
| 353 | /// Press "Fresh daimon" on a Diamond's cog dialog and answer the confirm. |
| 354 | /// |
| 355 | /// The confirm's OK is scoped to the dialog that is NOT the tile's own, because the tile |
| 356 | /// dialog's Delete at the foot is also a `.dlg-ok` and sits earlier in the document. An |
| 357 | /// unscoped `.dlg-ok` deletes the Diamond, which reads exactly like a thread that cleared |
| 358 | /// itself — it cost this file an hour to notice. |
| 359 | async function freshDaimon(name) { |
| 360 | await page.evaluate((n) => { |
| 361 | const box = [...document.querySelectorAll('.diamond-box')] |
| 362 | .find(b => (b.textContent || '').indexOf(n) >= 0); |
| 363 | box.querySelector('.tile-cog').click(); |
| 364 | }, name); |
| 365 | await page.waitForTimeout(800); |
| 366 | const found = await page.evaluate(() => { |
| 367 | const b = [...document.querySelectorAll('.tile-dlg-clear')] |
| 368 | .find(e => /fresh daimon/i.test(e.textContent || '')); |
| 369 | if (!b) return false; |
| 370 | b.click(); |
| 371 | return true; |
| 372 | }); |
| 373 | if (!found) throw new Error('no "Fresh daimon" control in the cog dialog'); |
| 374 | await page.waitForTimeout(700); |
| 375 | const confirmed = await page.evaluate(() => { |
| 376 | const b = document.querySelector('.dlg-card:not(.tile-dlg-card) .dlg-ok'); |
| 377 | if (!b) return false; |
| 378 | b.click(); |
| 379 | return true; |
| 380 | }); |
| 381 | if (!confirmed) throw new Error('the Fresh daimon confirm never appeared'); |
| 382 | await page.waitForTimeout(1400); |
| 383 | // Out of the cog dialog, so what is asserted below is what a reader would be looking at. |
| 384 | await page.evaluate(() => { |
| 385 | const x = document.querySelector('.tile-dlg-card .tile-dlg-done'); |
| 386 | if (x) x.click(); |
| 387 | }); |
| 388 | await page.waitForTimeout(600); |
| 389 | } |
| 390 | |
| 391 | try { |
| 392 | await makeDiamond('Alpha'); |
| 393 | await makeDiamond('Beta'); |
| 394 | |
| 395 | // ── 2. Fold belongs to the conversation a hand fold can reach. |
| 396 | // |
| 397 | // Two of these say "not here" and one says "here". A file with only the refusals would be |
| 398 | // green against a Fold button that had been deleted, and a file with only the assertion |
| 399 | // would be green against one drawn on every face at once — so the positive and the two |
| 400 | // negatives are all three of them load-bearing. |
| 401 | // |
| 402 | // EVERY READING IS A BOUNDING RECTANGLE (`drawn`'s `vis`), never a computed `display`. |
| 403 | // `display: none` does not cascade: a child of a hidden parent computes its own `display` |
| 404 | // perfectly happily and reports itself visible. |
| 405 | await openDiamond('Alpha', 'chat'); |
| 406 | await steerDiamond(s, 'hello alpha'); |
| 407 | await page.waitForTimeout(4000); |
| 408 | // RE-DRAWN FROM THE RECORD before anything is read off it. Taken straight after the turn, |
| 409 | // this snapshot would be the LIVE stream's work, and the control check below would then go |
| 410 | // red under `mute` — which is check 3b's break, not this one's. A control that fails for |
| 411 | // another check's reason stops being a control. |
| 412 | await openDiamond('Alpha', 'chat'); |
| 413 | const onChat = await drawn(); |
| 414 | check('2a. FOLD IS OFF THE DAIMON FACE, the one conversation a hand fold cannot reach', |
| 415 | onChat.fold === false, 'visible:' + onChat.fold); |
| 416 | // The control beside it: the thread really has something in it, so 2a is a REFUSAL and not |
| 417 | // a button hidden for want of a conversation. It also makes check 1's "empty" below a |
| 418 | // change and not the state it started in. |
| 419 | check('the daimon really said something, so 2a is a refusal and not an empty thread', |
| 420 | onChat.users.length === 1 && /hello alpha/.test(onChat.users[0] || ''), |
| 421 | JSON.stringify(onChat.users)); |
| 422 | |
| 423 | await page.click('#dview-crystal', { force: true }); |
| 424 | await page.waitForTimeout(900); |
| 425 | const onCrystal = await drawn(); |
| 426 | check('2b. AND OFF THE CRYSTAL FACE, which shows no conversation at all', |
| 427 | onCrystal.fold === false, 'visible:' + onCrystal.fold); |
| 428 | |
| 429 | // ── 2c/2d. And ON an ordinary chat, once there is something in it. |
| 430 | // |
| 431 | // The chat is made first and measured EMPTY, because "shown on a chat" and "shown on a |
| 432 | // chat with something in it" are two different claims and only the second one is the |
| 433 | // app's. A fold offered on an empty thread is the same dead control this check is about, |
| 434 | // one surface along. |
| 435 | await newChat(s); |
| 436 | await page.waitForTimeout(600); |
| 437 | const fresh = await drawn(); |
| 438 | check('2d. AND OFF A CHAT NOBODY HAS SAID ANYTHING IN, which has nothing to fold', |
| 439 | fresh.fold === false, 'visible:' + fresh.fold); |
| 440 | await page.fill('#chat-input', 'hello chat'); |
| 441 | await page.click('#chat-send', { force: true }); |
| 442 | // The user's turn is on the record before the reply is, and that is what makes the |
| 443 | // control live: `runTurn` pushes and persists first, then syncs the composer. |
| 444 | await page.waitForTimeout(2500); |
| 445 | const onOrdinary = await drawn(); |
| 446 | check('2c. FOLD IS ON AN ORDINARY CHAT WITH SOMETHING IN IT, which is the fold that works', |
| 447 | onOrdinary.fold === true, 'visible:' + onOrdinary.fold); |
| 448 | check('and that chat really has something in it, so 2c is not a control drawn over nothing', |
| 449 | onOrdinary.users.length >= 1 && /hello chat/.test(onOrdinary.text), |
| 450 | JSON.stringify(onOrdinary.users)); |
| 451 | // Let the turn finish before the Diamond work below, so a reply still streaming cannot |
| 452 | // land in a thread check 1 or check 3 is about to read. |
| 453 | await page.waitForSelector('.chat-spinner', { state: 'detached', timeout: 30000 }).catch(() => {}); |
| 454 | await page.waitForTimeout(500); |
| 455 | await openDiamond('Alpha', 'chat'); |
| 456 | await page.waitForTimeout(600); |
| 457 | |
| 458 | // ── 1. "Fresh daimon" clears WHAT IS DRAWN, not merely what is stored. |
| 459 | await freshDaimon('Alpha'); |
| 460 | const after = await drawn(); |
| 461 | check('1a. FRESH DAIMON EMPTIES THE THREAD ON SCREEN', |
| 462 | after.users.length === 0 && !/hello alpha/.test(after.text), |
| 463 | JSON.stringify(after.users).slice(0, 80)); |
| 464 | check('and the emptied thread says so rather than going blank', |
| 465 | after.empty === true, after.text.slice(0, 60)); |
| 466 | // ── 1b. AND IT STAYS CLEARED. The screen and the store are two different claims, and this |
| 467 | // is the one that was false: `persistChats` UNIONS a transcript with the stored copy, |
| 468 | // so emptying the array and saving put every message back. Nothing on screen could |
| 469 | // see it, because the screen had already been repainted from the emptied array. |
| 470 | const stored = () => page.evaluate(() => new Promise((res) => { |
| 471 | const r = indexedDB.open('daimond-chats', 1); |
| 472 | r.onsuccess = () => { |
| 473 | const db = r.result; |
| 474 | let t; try { t = db.transaction('chats', 'readonly'); } catch (e) { return res(null); } |
| 475 | const a = t.objectStore('chats').getAll(); |
| 476 | a.onsuccess = () => { |
| 477 | const d = (a.result || []).filter(c => c.diamondId); |
| 478 | res({ |
| 479 | msgs: d.reduce((n, c) => Math.max(n, (c.messages || []).length), 0), |
| 480 | sess: d.reduce((n, c) => Math.max(n, ((c.session || {}).msgs || []).length), 0), |
| 481 | }); |
| 482 | }; |
| 483 | a.onerror = () => res(null); |
| 484 | }; |
| 485 | r.onerror = () => res(null); |
| 486 | })); |
| 487 | const st = await stored(); |
| 488 | check('1b. THE TRANSCRIPT IS GONE FROM THE STORE TOO, not only from the screen', |
| 489 | !!st && st.msgs === 0, 'longest stored daimon transcript: ' + ((st || {}).msgs)); |
| 490 | // The half the dialog's own comment calls the one that matters: `session.msgs` is what goes |
| 491 | // to the model on the next turn, and it is invisible from the screen either way. |
| 492 | check('1c. AND SO IS THE CONVERSATION THE MODEL HOLDS, which the next turn would carry', |
| 493 | !!st && st.sess === 0, 'stored session length: ' + ((st || {}).sess)); |
| 494 | |
| 495 | // Read the way the owner read it: come back tomorrow and see whether it is still gone. |
| 496 | await page.reload({ waitUntil: 'domcontentloaded' }); |
| 497 | await signInAs(s, 'daimonface'); |
| 498 | await page.waitForTimeout(1500); |
| 499 | await openDiamond('Alpha', 'chat'); |
| 500 | const reloaded = await drawn(); |
| 501 | check('1d. AND IT IS STILL GONE AFTER A RELOAD, which is what "start fresh" means', |
| 502 | reloaded.users.length === 0 && !/hello alpha/.test(reloaded.text), |
| 503 | JSON.stringify(reloaded.users).slice(0, 80)); |
| 504 | |
| 505 | // ── 1e. WHAT FALLS BETWEEN CHECK 1 AND CHECK 3, which neither can see on its own. |
| 506 | // |
| 507 | // Check 1 clears with nothing running. Check 3 runs a turn with nothing cleared. Between |
| 508 | // them sits the clear taken WHILE a turn is in flight, and it was real: the reply and the |
| 509 | // model's whole conversation are written back into the record when the turn ends, by |
| 510 | // reference, so the clear was undone a few seconds later and the reader was left with half |
| 511 | // an answer to a question that was no longer there. Two checks, each right, one gap. |
| 512 | await page.fill('#chat-input', '@long 40'); |
| 513 | await page.click('#chat-send', { force: true }); |
| 514 | await page.waitForTimeout(1500); |
| 515 | const refused = await page.evaluate(() => { |
| 516 | const box = [...document.querySelectorAll('.diamond-box')] |
| 517 | .find(b => (b.textContent || '').indexOf('Alpha') >= 0); |
| 518 | box.querySelector('.tile-cog').click(); |
| 519 | return true; |
| 520 | }) && await page.waitForTimeout(700).then(async () => { |
| 521 | const pressed = await page.evaluate(() => { |
| 522 | const b = [...document.querySelectorAll('.tile-dlg-clear')] |
| 523 | .find(e => /fresh daimon/i.test(e.textContent || '')); |
| 524 | if (!b) return 'no control'; |
| 525 | b.click(); |
| 526 | return 'pressed'; |
| 527 | }); |
| 528 | await page.waitForTimeout(700); |
| 529 | // A NOTICE and not a confirm: nothing is discarded, and the way to discard is named. |
| 530 | return await page.evaluate((p) => { |
| 531 | const dlg = document.querySelector('.dlg-card:not(.tile-dlg-card)'); |
| 532 | return { pressed: p, dialog: dlg ? (dlg.textContent || '').replace(/\s+/g, ' ') : '' }; |
| 533 | }, pressed); |
| 534 | }); |
| 535 | check('1e. A CLEAR IS REFUSED MID-TURN rather than undone by the turn a moment later', |
| 536 | /still working/i.test(refused.dialog), |
| 537 | refused.pressed + ': ' + (refused.dialog || '(no dialog)').slice(0, 70)); |
| 538 | // Out of both dialogs, and let the turn finish, so what follows starts from a known place. |
| 539 | await page.evaluate(() => { |
| 540 | const n = document.querySelector('.dlg-card:not(.tile-dlg-card) .dlg-ok'); |
| 541 | if (n) n.click(); |
| 542 | }); |
| 543 | await page.waitForTimeout(400); |
| 544 | await page.evaluate(() => { |
| 545 | const x = document.querySelector('.tile-dlg-card .tile-dlg-done'); |
| 546 | if (x) x.click(); |
| 547 | }); |
| 548 | // The dots are the app's own answer to "is a turn still running", so they are what is |
| 549 | // waited on rather than a number of seconds somebody guessed. |
| 550 | await page.waitForSelector('.chat-spinner', { state: 'detached', timeout: 30000 }); |
| 551 | await page.waitForTimeout(800); |
| 552 | // Cleared again, now that it is allowed — so check 3 below starts from an empty thread and |
| 553 | // its `chunk-` evidence is this turn's and not the one just run. |
| 554 | await freshDaimon('Alpha'); |
| 555 | await openDiamond('Alpha', 'chat'); |
| 556 | const cleared2 = await drawn(); |
| 557 | check('and the clear works once the turn is over, which is what the refusal promised', |
| 558 | cleared2.users.length === 0 && !/chunk-/.test(cleared2.text), |
| 559 | JSON.stringify(cleared2.users) + ' chunks:' + /chunk-/.test(cleared2.text)); |
| 560 | |
| 561 | // ── 3. A reply streams into ITS OWN thread, and into no other. |
| 562 | await page.fill('#chat-input', '@long 40'); |
| 563 | await page.click('#chat-send', { force: true }); |
| 564 | await page.waitForTimeout(1600); |
| 565 | const mid = await drawn(); |
| 566 | check('3b. A REPLY STREAMS INTO ITS OWN THREAD WHILE IT ARRIVES', |
| 567 | /chunk-/.test(mid.text), mid.text.slice(-60)); |
| 568 | |
| 569 | // Away, mid-turn, to another Diamond's conversation — the gather-round shape, reached the |
| 570 | // way a reader reaches it. |
| 571 | await openDiamond('Beta', 'chat'); |
| 572 | await page.waitForTimeout(2500); |
| 573 | const beta = await drawn(); |
| 574 | check('3a. AND INTO NO OTHER THREAD, when the reader has moved on mid-turn', |
| 575 | !/chunk-/.test(beta.text) && beta.users.length === 0, |
| 576 | "Alpha's words in Beta's thread: " + (/chunk-/.test(beta.text) ? 'YES' : 'no') |
| 577 | + ', user turns drawn: ' + beta.users.length); |
| 578 | |
| 579 | // And it was not lost by being undrawn: back on Alpha, the whole reply is there. |
| 580 | await page.waitForTimeout(4000); |
| 581 | await openDiamond('Alpha', 'chat'); |
| 582 | const back = await drawn(); |
| 583 | check('and the reply was kept, so "drawn nowhere" never meant "thrown away"', |
| 584 | /chunk-40/.test(back.text), back.text.slice(-70)); |
| 585 | |
| 586 | } catch (e) { |
| 587 | check('the run completed', false, String((e && e.message) || e)); |
| 588 | } finally { |
| 589 | await s.close?.().catch(() => {}); |
| 590 | } |
| 591 | |
| 592 | console.log(`\n${ok.length} passed, ${bad.length} failed`); |
| 593 | if (BREAK && MUST_STAY_GREEN.has(BREAK)) { |
| 594 | console.log(bad.length |
| 595 | ? `\n'${BREAK}' IS NOT A BREAK: it restores the conditions the helper replaced and must ` |
| 596 | + 'change nothing. Something failed, so the helper is not the refactor it claims to be.' |
| 597 | : `\n'${BREAK}' changed nothing, which is what it is for: the helper says the same thing ` |
| 598 | + 'the raw conditions said.'); |
| 599 | process.exit(bad.length ? 1 : 0); |
| 600 | } |
| 601 | if (BREAK) { |
| 602 | console.log(bad.length |
| 603 | ? `\nbreak '${BREAK}' produced failures, as it must.` |
| 604 | : `\nBREAK '${BREAK}' CHANGED NOTHING — the check it targets is not proving anything.`); |
| 605 | } |
| 606 | process.exit(bad.length ? 1 : 0); |