oxedyne/daimond/dev/verify_diamondmodels.mjs
40.1 KiB, 45 runs
created by r2519314175:361, 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_diamondmodels.mjs — phase D: the models a Diamond runs on, and the fold. |
| 2 | // |
| 3 | // Five properties, each one a thing notes2 asks for and none of which could be |
| 4 | // seen from the app before this phase: |
| 5 | // |
| 6 | // 1. A Diamond SHOWS the model it thinks with. It has been stored since |
| 7 | // Diamonds had models and drawn nowhere, so two Diamonds deliberately put |
| 8 | // on different models were indistinguishable on the rail. |
| 9 | // 2. Changing the daimon's model is a deliberate two-step: the pulldown reveals |
| 10 | // a "Change" button ONLY while the pick differs, and pressing it takes effect, |
| 11 | // is written into the crystal's own version history — the discontinuity notes2 |
| 12 | // calls "a fold and a new daimon" — and LEAVES THE CONVERSATION WHERE IT IS. |
| 13 | // There is no confirm, and the absence is the property: the modal that stood |
| 14 | // here warned that the thread would not carry over, which was true for the |
| 15 | // twenty-two minutes before a daimon had a thread at all. So the two halves are |
| 16 | // checked together, because either one alone can be satisfied by the wrong |
| 17 | // thing: a confirm nobody answers also leaves the session intact, and a silent |
| 18 | // handler that empties it also asks nothing. |
| 19 | // 3. The secondary is a map keyed by modality. A task naming an image is |
| 20 | // dispatched on the vision model; a task naming none is not. Measured from |
| 21 | // the RUN, which is what carries the model and the key, rather than from |
| 22 | // the pulldown that was set. |
| 23 | // 4. The context meter says where the fold happens. The number is `FOLD_AT` |
| 24 | // in compact.rs and has never been anywhere a user could see it. |
| 25 | // 5. The fold can be asked for, and reports honestly when there is nothing to |
| 26 | // fold. That a hand fold does not SHRINK the window is proved in Rust, where |
| 27 | // the window lives — `learn_from_refusal` moves it down and never up, so a |
| 28 | // hand fold routed through the refusal arm would cost a quarter of the |
| 29 | // context every three presses, and no screenshot of a meter can tell a |
| 30 | // window that shrank from a conversation that grew. |
| 31 | // |
| 32 | // node dev/verify_diamondmodels.mjs |
| 33 | // node dev/verify_diamondmodels.mjs --break novision # dispatch ignores modality |
| 34 | // node dev/verify_diamondmodels.mjs --break nomark # the meter loses the fold mark |
| 35 | // node dev/verify_diamondmodels.mjs --break wipes # the model change empties the session |
| 36 | // node dev/verify_diamondmodels.mjs --break asks # the confirm comes back |
| 37 | // node dev/verify_diamondmodels.mjs --break appguard # the fold asks for an engine again |
| 38 | // node dev/verify_diamondmodels.mjs --break refuses # the fold refuses before it asks |
| 39 | // |
| 40 | // `wipes` and `asks` are SOURCE breaks: the handler they target is a closure inside |
| 41 | // daimond.js and cannot be reached from the page, so the file is patched in memory |
| 42 | // and served through `page.route` — the same trick verify_daimonface.mjs uses. Each |
| 43 | // one asserts its anchor lands exactly once; an anchor that has drifted is a break |
| 44 | // that quietly stopped applying, and a green run under it would prove nothing. |
| 45 | // |
| 46 | // Needs dev/serve.mjs and dev/mockllm.mjs (dev/world.sh N --up gives both). |
| 47 | import fs from 'node:fs'; |
| 48 | import path from 'node:path'; |
| 49 | import os from 'node:os'; |
| 50 | import { fileURLToPath } from 'node:url'; |
| 51 | import { open, newChat, connectMock, scratch, shot, signInAs, steerDiamond, storedChats } from './harness.mjs'; |
| 52 | |
| 53 | // The mock offers three models, and exactly one of them is in the price TABLE with |
| 54 | // a context window. That is the one this run connects under, because `mock/fast` |
| 55 | // has no published window: the meter is then not drawn at all, and the checks on |
| 56 | // the fold mark would pass vacuously against a meter that is not there. |
| 57 | const MODEL = 'accounts/fireworks/models/glm-5p2'; |
| 58 | |
| 59 | const OUT = path.join(os.homedir(), '.cache/daimond/diamondmodels-shots'); |
| 60 | fs.mkdirSync(OUT, { recursive: true }); |
| 61 | |
| 62 | // `--break x` or `--break=x`. The index form has to be guarded: `indexOf` of an |
| 63 | // absent flag is -1, and -1 + 1 is 0, which is the path to node. |
| 64 | const BI = process.argv.indexOf('--break'); |
| 65 | const BEQ = process.argv.find(a => a.startsWith('--break=')); |
| 66 | const BREAK = BEQ ? BEQ.split('=')[1] : (BI >= 0 ? (process.argv[BI + 1] || '') : ''); |
| 67 | |
| 68 | // ── The two source breaks ──────────────────────────────────────────── |
| 69 | // |
| 70 | // Both live in the daimon's model-change handler, which is a closure: nothing the |
| 71 | // page can reach from outside gets at it, so the only honest way to break it is to |
| 72 | // serve a different file. |
| 73 | const HERE = path.dirname(fileURLToPath(import.meta.url)); |
| 74 | const SRC = 'js/daimond.js'; |
| 75 | const SRC_BREAKS = { |
| 76 | // THE LOSS THE OLD CONFIRM WARNED ABOUT, made real: the conversation discarded as |
| 77 | // the model moves. Both halves of it, because the two are stored separately and a |
| 78 | // break that took only one would leave the other check green on a conversation |
| 79 | // half gone -- the model's own copy, which is what the next turn carries, and the |
| 80 | // transcript the reader is looking at. |
| 81 | wipes: { |
| 82 | find: "\t\t\tresetDiamondApps();\n\t\t\tvar logged = true;", |
| 83 | with: "\t\t\tresetDiamondApps();\n" |
| 84 | + "\t\t\tvar _lost = (chats || []).find(function (c) { return c.diamondId === opts.id; });\n" |
| 85 | + "\t\t\tif (_lost) {\n" |
| 86 | + "\t\t\t\t_lost.messages = [];\n" |
| 87 | + "\t\t\t\t_lost.session = { v: 1, msgs: [], upto: '', uptoTs: 0 };\n" |
| 88 | + "\t\t\t\tpersistChats();\n" |
| 89 | + "\t\t\t\tif (current && current.id === _lost.id) renderHistory(_lost.messages);\n" |
| 90 | + "\t\t\t}\n" |
| 91 | + "\t\t\tvar logged = true;", |
| 92 | }, |
| 93 | // THE OLD FOLD GUARD, restored: `chat.app`, which asks whether an engine was built |
| 94 | // during THIS page load and not whether anything was ever said. After a reload it |
| 95 | // is null for every chat there is, so a thread of any length is refused. |
| 96 | appguard: { |
| 97 | find: "\t\tif (!chatSaid(chat)) { toast(t('tile.fold_unavailable'), true); return; }", |
| 98 | with: "\t\tvar _a = chat.app;\n" |
| 99 | + "\t\tif (!_a || typeof _a.fold_now !== 'function') {\n" |
| 100 | + "\t\t\ttoast(t('tile.fold_unavailable'), true); return;\n" |
| 101 | + "\t\t}", |
| 102 | }, |
| 103 | // THE FOLD REFUSING BEFORE IT ASKS, which is the shape that trashed a chat. |
| 104 | // `appguard` above produces it only after a reload; this one produces it on the |
| 105 | // first press, where section 5a stands. It is here so the SCOPING in 5a and 5b is |
| 106 | // held by something: unscoped, this break does not merely fail, it deletes the |
| 107 | // conversation the rest of the section is about and then reports on what is left. |
| 108 | refuses: { |
| 109 | find: "\t\tif (!chatSaid(chat)) { toast(t('tile.fold_unavailable'), true); return; }", |
| 110 | with: "\t\tif (true) { toast(t('tile.fold_unavailable'), true); return; }", |
| 111 | }, |
| 112 | // The confirm back, in its plainest form. Nothing answers it, so the model does |
| 113 | // not move either — which is the point: a confirm is a thing that stands there. |
| 114 | // It now lives on the Change button's click, where the switch was moved. |
| 115 | asks: { |
| 116 | find: "\t\t\tsetDiamondModel(opts.id, { provider: p.provider, model: p.model });", |
| 117 | with: "\t\t\tvar _ok = await confirmDialog('Change the model?', 'Change',\n" |
| 118 | + "\t\t\t\t{ title: 'Change the daimon', danger: false });\n" |
| 119 | + "\t\t\tif (!_ok) return;\n" |
| 120 | + "\t\t\tsetDiamondModel(opts.id, { provider: p.provider, model: p.model });", |
| 121 | }, |
| 122 | }; |
| 123 | let patched = null; |
| 124 | if (SRC_BREAKS[BREAK]) { |
| 125 | const spec = SRC_BREAKS[BREAK]; |
| 126 | patched = fs.readFileSync(path.join(HERE, '..', 'www', SRC), 'utf8'); |
| 127 | const n = patched.split(spec.find).length - 1; |
| 128 | if (n !== 1) { |
| 129 | console.error(`break '${BREAK}': the anchor appears ${n} times in ${SRC}, so nothing was ` |
| 130 | + 'broken and the run below would prove nothing. The file has moved on; move the ' |
| 131 | + 'anchor with it.\n ' + spec.find.split('\n')[0].trim()); |
| 132 | process.exit(2); |
| 133 | } |
| 134 | patched = patched.replace(spec.find, spec.with); |
| 135 | } |
| 136 | |
| 137 | let failures = 0; |
| 138 | const check = (cond, msg, detail) => { |
| 139 | console.log((cond ? ' ok ' : ' FAIL ') + msg + (detail != null ? ' — ' + detail : '')); |
| 140 | if (!cond) failures++; |
| 141 | }; |
| 142 | |
| 143 | /// The topmost visible dialog card. |
| 144 | /// |
| 145 | /// NOT the first: the tile dialog's own card also carries `.dlg-card`, and its |
| 146 | /// Done button also carries `.dlg-cancel` — so `find(first visible)` reaches past |
| 147 | /// a confirm that is drawn over it and presses Done on the dialog underneath, |
| 148 | /// which closes the wrong thing and leaves the confirm standing. |
| 149 | /// Make a Diamond through the dialog a person uses. |
| 150 | async function create(p, name) { |
| 151 | await p.evaluate(() => document.getElementById('new-diamond-btn').click()); |
| 152 | await p.waitForSelector('.dlg-card', { timeout: 8000 }); |
| 153 | await p.evaluate((nm) => { |
| 154 | const card = [...document.querySelectorAll('.dlg-card')] |
| 155 | .filter(c => c.getClientRects().length).pop(); |
| 156 | const inp = card.querySelector('input.dlg-input'); |
| 157 | inp.value = nm; |
| 158 | inp.dispatchEvent(new Event('input', { bubbles: true })); |
| 159 | card.querySelector('.dlg-ok').click(); |
| 160 | }, name); |
| 161 | await p.waitForTimeout(900); |
| 162 | } |
| 163 | |
| 164 | /// Open a Diamond tile's cog dialog by the Diamond's name. |
| 165 | async function openCog(p, name) { |
| 166 | const found = await p.evaluate((nm) => { |
| 167 | const box = [...document.querySelectorAll('#diamond-list .diamond-box')] |
| 168 | .find(b => ((b.querySelector('.session-box-name') || {}).textContent || '').trim() === nm); |
| 169 | if (!box) return false; |
| 170 | const cog = box.querySelector('.tile-cog'); |
| 171 | if (!cog) return false; |
| 172 | cog.click(); |
| 173 | return true; |
| 174 | }, name); |
| 175 | if (!found) return false; |
| 176 | await p.waitForSelector('.tile-dlg-card', { timeout: 8000 }); |
| 177 | return true; |
| 178 | } |
| 179 | |
| 180 | /// Close whatever dialogs are standing. The tile dialog's way out is the closer |
| 181 | /// cross, which still carries `tile-dlg-done` — that class means "the control |
| 182 | /// that finishes with this dialog" and never meant the word Done, so this path |
| 183 | /// is unchanged by the cross replacing the button. |
| 184 | const closeDialogs = (p) => p.evaluate(() => { |
| 185 | document.querySelectorAll('.tile-dlg .tile-dlg-done, .dlg-card .dlg-cancel') |
| 186 | .forEach(b => { try { b.click(); } catch {} }); |
| 187 | }); |
| 188 | |
| 189 | /// How much every tile draws. Simple and Max are global (notes3), so this is one |
| 190 | /// call and not a visit to each tile's dialog: the per-tile level control is |
| 191 | /// gone. Set live rather than by writing `localStorage` and reloading — the |
| 192 | /// reload was costing the wasm a fresh boot on every check, and what is under |
| 193 | /// test here is the rail as the user sees it change. |
| 194 | async function setView(p, level) { |
| 195 | await p.evaluate((lv) => window.DaimondView.set(lv), level); |
| 196 | await p.waitForTimeout(250); |
| 197 | } |
| 198 | |
| 199 | /// Open one tile's dialog from its cog. False when that tile has no cog, which |
| 200 | /// is a failure worth naming rather than a selector timeout. |
| 201 | async function openTile(p, box) { |
| 202 | const opened = await p.evaluate((sel) => { |
| 203 | const b = document.querySelector(sel); |
| 204 | const cog = b && b.querySelector('.tile-cog'); |
| 205 | if (!cog) return false; |
| 206 | cog.click(); |
| 207 | return true; |
| 208 | }, box); |
| 209 | if (!opened) return false; |
| 210 | await p.waitForSelector('.tile-dlg-card', { timeout: 8000 }); |
| 211 | return true; |
| 212 | } |
| 213 | |
| 214 | const s = await open({ |
| 215 | name: 'diamondmodels', profile: scratch('pw', 'models-' + process.pid), |
| 216 | route: patched ? async (page) => { |
| 217 | await page.route('**/' + SRC, r => r.fulfill({ |
| 218 | status: 200, contentType: 'application/javascript', body: patched, |
| 219 | })); |
| 220 | } : null, |
| 221 | }); |
| 222 | const { page: p } = s; |
| 223 | try { |
| 224 | await connectMock(s, { model: MODEL }); |
| 225 | |
| 226 | if (BREAK) console.log(` .. running with --break ${BREAK}`); |
| 227 | if (BREAK === 'novision') { |
| 228 | // Exactly the pre-phase-D dispatch: one model for the whole fan-out, |
| 229 | // modality ignored. Applied to the live rule, so what is disabled is the |
| 230 | // thing under test rather than a flag beside it. |
| 231 | await p.evaluate(() => { |
| 232 | const W = window.DaimondWorkers; |
| 233 | W.routeFor = function (task, text) { |
| 234 | return { provider: text.provider || '', model: text.model || '', sees: false }; |
| 235 | }; |
| 236 | }); |
| 237 | } |
| 238 | if (BREAK === 'nomark') { |
| 239 | await p.addStyleTag({ content: '.tile-ctx-fold { display: none !important; }' }); |
| 240 | } |
| 241 | // ══ 1. A Diamond shows its model ══════════════════════════════════ |
| 242 | await create(p, 'Dee'); |
| 243 | const dId = await p.evaluate(() => |
| 244 | ([...document.querySelectorAll('#diamond-list .diamond-box')] |
| 245 | .find(b => ((b.querySelector('.session-box-name') || {}).textContent || '').trim() === 'Dee') || {}).dataset?.id || ''); |
| 246 | check(!!dId, 'the Diamond was made', dId); |
| 247 | const dSel = `#diamond-list .diamond-box[data-id="${dId}"]`; |
| 248 | check(await openTile(p, dSel), 'the tile dialog opens from the cog'); |
| 249 | await closeDialogs(p); |
| 250 | await setView(p, 'max'); |
| 251 | |
| 252 | const chip = await p.evaluate((sel) => { |
| 253 | const c = document.querySelector(sel + ' .tile-model-chip.diamond-model'); |
| 254 | if (!c) return null; |
| 255 | return { text: (c.textContent || '').trim(), title: c.title || '', |
| 256 | shown: c.getClientRects().length > 0 }; |
| 257 | }, dSel); |
| 258 | check(chip !== null, 'the Diamond tile carries a model chip'); |
| 259 | check(!!(chip && chip.shown && chip.text), 'the chip is drawn and says something', |
| 260 | chip && chip.text); |
| 261 | check(!!(chip && chip.title.includes(MODEL)), |
| 262 | 'the chip names the full model, not only the short form', chip && chip.title); |
| 263 | |
| 264 | // ── And Simple hides it, because a model is detail. ── |
| 265 | await setView(p, 'simple'); |
| 266 | const hidden = await p.evaluate((sel) => { |
| 267 | const c = document.querySelector(sel + ' .tile-model-chip.diamond-model'); |
| 268 | return !c || c.getClientRects().length === 0; |
| 269 | }, dSel); |
| 270 | check(hidden, 'Simple hides the model chip with the other model controls'); |
| 271 | await setView(p, 'max'); |
| 272 | await p.waitForTimeout(300); |
| 273 | |
| 274 | // ══ 2. The model changes on Change, and the thread comes with it ═══ |
| 275 | // |
| 276 | // GIVE THE DAIMON A CONVERSATION FIRST. Without one, "the session is the same |
| 277 | // length afterwards" is 0 === 0 — a check with no subject, which passes against |
| 278 | // anything, including a handler that empties the session on its way past. |
| 279 | await p.evaluate((sel) => { document.querySelector(sel).click(); }, dSel); |
| 280 | await p.waitForTimeout(900); |
| 281 | await steerDiamond(s, 'remember this sentence'); |
| 282 | await p.waitForSelector('.chat-spinner', { state: 'detached', timeout: 40000 }).catch(() => {}); |
| 283 | await p.waitForTimeout(1200); |
| 284 | /// How many messages the MODEL's own copy of this daimon's conversation holds. |
| 285 | /// |
| 286 | /// Read from IndexedDB rather than from the screen: `session.msgs` is what the next |
| 287 | /// turn is built from and carries the provider's tool-call ids, and it is invisible |
| 288 | /// from the transcript either way. The transcript is the other half of the same |
| 289 | /// claim and is checked beside it. |
| 290 | const sessLen = async () => { |
| 291 | const all = await storedChats(s); |
| 292 | const rec = (all || []).find(c => c && c.diamondId === dId); |
| 293 | return rec ? (((rec.session || {}).msgs) || []).length : -1; |
| 294 | }; |
| 295 | const sessBefore = await sessLen(); |
| 296 | check(sessBefore > 0, 'the daimon has a conversation, so what follows has a subject', |
| 297 | 'session msgs: ' + sessBefore); |
| 298 | |
| 299 | check(await openCog(p, 'Dee'), 'the cog opens the tile dialog'); |
| 300 | // The label is a real `<label>` now, bound to its pulldown by `for`/`id`. |
| 301 | // It used to wear `.tile-model-chip`, which is the mono pill a model NAME is |
| 302 | // drawn in on the tile — so three form labels looked like three values, and |
| 303 | // the word did not focus the control it named. |
| 304 | const rows = await p.evaluate(() => { |
| 305 | const card = document.querySelector('.tile-dlg-card'); |
| 306 | return [...card.querySelectorAll('.tile-dlg-model')].map(r => { |
| 307 | const lab = r.querySelector('label.tile-dlg-label'); |
| 308 | const sel = r.querySelector('select'); |
| 309 | return { |
| 310 | label: lab ? (lab.textContent || '').trim() : '', |
| 311 | // Which of the three settings this row IS, taken from the control's |
| 312 | // own id rather than from the row's position: a row list read by |
| 313 | // index says nothing about which setting moved when one is added. |
| 314 | which: sel ? (/(daimon|workers|vision)/.exec(sel.id) || [''])[0] : '', |
| 315 | bound: !!(lab && sel && lab.htmlFor && lab.htmlFor === sel.id), |
| 316 | options: [...((sel || { options: [] }).options)].map(o => o.value), |
| 317 | }; |
| 318 | }); |
| 319 | }); |
| 320 | const rowFor = (w) => rows.find(r => r.which === w); |
| 321 | check(['daimon', 'workers', 'vision'].every(w => !!rowFor(w)), |
| 322 | 'the dialog carries a row for each of the three settings: the daimon, the workers, and the workers’ eyes', |
| 323 | rows.map(r => r.which || '(unnamed)').join(', ')); |
| 324 | check(rows.length > 0 && rows.every(r => r.label && r.bound), |
| 325 | 'each is a real label bound to its own pulldown, so the word focuses the control it names', |
| 326 | JSON.stringify(rows.map(r => [r.label, r.bound]))); |
| 327 | check(!!rowFor('workers') && !!rowFor('vision') |
| 328 | && rowFor('workers').options.includes('') && rowFor('vision').options.includes(''), |
| 329 | 'the two worker rows offer "same as the text model" as a real choice, which is what absent MEANS here'); |
| 330 | |
| 331 | // Each of the three reads below reaches the DAIMON's pulldown by the setting |
| 332 | // it carries -- `/daimon/` in the control's own id -- rather than by taking |
| 333 | // the first row on screen. The daimon is the only one of the three written |
| 334 | // into the crystal's history, so a check that found a row by position would |
| 335 | // end up asserting that against a setting which deliberately has none. |
| 336 | |
| 337 | // A second model to move to. The mock serves whatever it is asked for, so any |
| 338 | // second name in the pulldown will do. |
| 339 | const other = await p.evaluate(() => { |
| 340 | const sel = [...document.querySelectorAll('.tile-dlg-card .tile-dlg-model select')] |
| 341 | .find(s => /daimon/.test(s.id)); |
| 342 | if (!sel) return ''; |
| 343 | const opts = [...sel.options].map(o => o.value).filter(Boolean); |
| 344 | return opts.find(v => v !== sel.value) || ''; |
| 345 | }); |
| 346 | if (!other) { |
| 347 | console.log(' .. only one model is configured; the change checks are skipped'); |
| 348 | } else { |
| 349 | const before = await p.evaluate((id) => |
| 350 | (JSON.parse(localStorage.getItem('daimond-diamond-models') || '{}')[id] || {}).model || '', dId); |
| 351 | const move = (v) => p.evaluate((val) => { |
| 352 | const sel = [...document.querySelectorAll('.tile-dlg-card .tile-dlg-model select')] |
| 353 | .find(s => /daimon/.test(s.id)); |
| 354 | sel.value = val; |
| 355 | sel.dispatchEvent(new Event('change', { bubbles: true })); |
| 356 | }, v); |
| 357 | /// Did a dialog appear OVER the tile dialog, within `ms`? |
| 358 | /// |
| 359 | /// Measured as a bounding rectangle -- `getClientRects().length` -- and never as |
| 360 | /// a computed `display`. `display: none` does not cascade: a child of a hidden |
| 361 | /// parent computes its own `display` quite happily and reports itself drawn, and |
| 362 | /// a check written that way is green against a dialog nobody can see and green |
| 363 | /// against one everybody can. |
| 364 | /// |
| 365 | /// The tile dialog's own card is excluded by class. It is a `.dlg-card` too, and |
| 366 | /// it is standing throughout, so an unscoped read answers "yes" every time and |
| 367 | /// the check never had a chance of being false. |
| 368 | const confirmWithin = async (ms) => { |
| 369 | try { |
| 370 | await p.waitForFunction(() => |
| 371 | [...document.querySelectorAll('.dlg-card')] |
| 372 | .filter(c => c.getClientRects().length && !c.classList.contains('tile-dlg-card')) |
| 373 | .length > 0, null, { timeout: ms }); |
| 374 | return true; |
| 375 | } catch { return false; } |
| 376 | }; |
| 377 | |
| 378 | // ── The pulldown reveals Change; pressing it moves the daimon. ── |
| 379 | // |
| 380 | // The switch is two steps now, on purpose: a daimon's model is persistent, so |
| 381 | // it is not moved by a pulldown brushed past. The Change button beside it shows |
| 382 | // ONLY while the pick differs from the model in force, and pressing it is what |
| 383 | // switches -- reusing the conversation, never a fresh daimon. |
| 384 | /// Is the daimon row's Change button drawn (a real bounding box), and what does |
| 385 | /// it read? Measured as `getClientRects()`, never a computed `display`, for the |
| 386 | /// reason `confirmWithin` states about itself. |
| 387 | const changeState = () => p.evaluate(() => { |
| 388 | const row = [...document.querySelectorAll('.tile-dlg-card .tile-dlg-model')] |
| 389 | .find(r => { const s = r.querySelector('select'); return s && /daimon/.test(s.id); }); |
| 390 | const b = row ? row.querySelector('.tile-dlg-apply') : null; |
| 391 | return { shown: !!(b && b.getClientRects().length), text: b ? (b.textContent || '').trim() : '' }; |
| 392 | }); |
| 393 | const pressChange = () => p.evaluate(() => { |
| 394 | const row = [...document.querySelectorAll('.tile-dlg-card .tile-dlg-model')] |
| 395 | .find(r => { const s = r.querySelector('select'); return s && /daimon/.test(s.id); }); |
| 396 | const b = row ? row.querySelector('.tile-dlg-apply') : null; |
| 397 | if (b) b.click(); |
| 398 | }); |
| 399 | const hiddenAtRest = await changeState(); |
| 400 | check(!hiddenAtRest.shown, |
| 401 | 'the Change button is hidden until a different model is picked', |
| 402 | 'shown=' + hiddenAtRest.shown); |
| 403 | await move(other); |
| 404 | await p.waitForTimeout(150); |
| 405 | const shownOnDiff = await changeState(); |
| 406 | check(shownOnDiff.shown && /change/i.test(shownOnDiff.text), |
| 407 | 'picking a different model reveals the button, reading "Change"', |
| 408 | JSON.stringify(shownOnDiff)); |
| 409 | await pressChange(); |
| 410 | // THE RECEIPT IS CAUGHT AS IT APPEARS. It used to be read at the bottom of |
| 411 | // this block, and that read was a coin toss. |
| 412 | // |
| 413 | // `toast()` fades its box at 3600ms and REMOVES it at 4200ms |
| 414 | // (daimond.js:29857). The read below it stood behind `confirmWithin(3000)`, |
| 415 | // which burns its whole window on the healthy path where no confirm ever |
| 416 | // comes, then a 1200ms settle, then a localStorage read, an IndexedDB round |
| 417 | // trip and a transcript read. So the earliest it could sample was 4200ms |
| 418 | // plus three round trips: the removal boundary itself, missed or hit on a |
| 419 | // few milliseconds of jitter. It came up "(nothing said)" once in ten runs |
| 420 | // on 2026-08-21 — red for the right reason, in the one shape that cannot be |
| 421 | // attributed to anything, and a gate of 253 checks that reports a different |
| 422 | // count each time is a gate nobody can read. |
| 423 | // |
| 424 | // The fade is not the problem and a shorter wait would not have fixed it: |
| 425 | // an opacity-0 box still answers `getClientRects()`, so the window is sharp |
| 426 | // rather than soft. What is wanted is not an earlier guess at when to look |
| 427 | // but a watch that fires the moment the toast exists, which is this. |
| 428 | const receiptEarly = await p.waitForFunction(() => { |
| 429 | const said = [...document.querySelectorAll('.toast, .toast-msg, [class*="toast"]')] |
| 430 | .filter(n => n.getClientRects().length) |
| 431 | .map(n => (n.textContent || '').trim()).filter(Boolean).join(' | '); |
| 432 | return (/tile\.model_changed/.test(said) || /moved from .* to /i.test(said)) ? said : false; |
| 433 | }, null, { timeout: 4000 }).then(h => h.jsonValue()).catch(() => ''); |
| 434 | const asked = await confirmWithin(3000); |
| 435 | check(!asked, 'NOTHING IS ASKED: a change this cheap to undo takes a receipt, not a modal', |
| 436 | asked ? 'a confirm was drawn over the tile dialog' : 'no dialog appeared'); |
| 437 | await p.waitForTimeout(1200); |
| 438 | const afterYes = await p.evaluate((id) => |
| 439 | (JSON.parse(localStorage.getItem('daimond-diamond-models') || '{}')[id] || {}).model || '', dId); |
| 440 | check(afterYes === other, 'and the daimon really is on the new model', |
| 441 | `${before} → ${afterYes}`); |
| 442 | |
| 443 | // ── AND THE CONVERSATION IS STILL THERE. ── |
| 444 | // |
| 445 | // The claim the deleted modal made was that it would not be. Both halves are |
| 446 | // read: the model's own copy, which is what the next turn carries and is |
| 447 | // invisible from the screen, and the transcript, which is what the reader sees. |
| 448 | const sessAfter = await sessLen(); |
| 449 | check(sessAfter === sessBefore, |
| 450 | 'THE MODEL\'S OWN CONVERSATION IS UNTOUCHED by the change of model', |
| 451 | `${sessBefore} → ${sessAfter} messages`); |
| 452 | const stillDrawn = await p.evaluate(() => { |
| 453 | const co = document.getElementById('chat-output'); |
| 454 | return co ? (co.textContent || '') : ''; |
| 455 | }); |
| 456 | check(/remember this sentence/.test(stillDrawn), |
| 457 | 'and so is the transcript the reader is looking at', |
| 458 | stillDrawn.replace(/\s+/g, ' ').slice(0, 70)); |
| 459 | |
| 460 | // ── The receipt. ── |
| 461 | // |
| 462 | // `tile.model_changed` is added by the locale files, so until those land `t()` |
| 463 | // hands back the key itself and the toast reads `tile.model_changed`. Either |
| 464 | // form is accepted; what is asserted is that the app SAID something when it |
| 465 | // moved, rather than moving in silence. |
| 466 | // The watch above is what answers this; the late read stays as the fallback, |
| 467 | // so a build whose toast lingers still reports what it said rather than an |
| 468 | // empty string, and so a failure names whatever IS on screen instead of |
| 469 | // nothing at all. |
| 470 | const receiptLate = await p.evaluate(() => |
| 471 | [...document.querySelectorAll('.toast, .toast-msg, [class*="toast"]')] |
| 472 | .filter(n => n.getClientRects().length) |
| 473 | .map(n => (n.textContent || '').trim()).filter(Boolean).join(' | ')); |
| 474 | const receipt = receiptEarly || receiptLate; |
| 475 | check(/tile\.model_changed/.test(receipt) || /moved from .* to /i.test(receipt), |
| 476 | 'a receipt says what moved, in place of the question that used to be asked', |
| 477 | receipt || '(nothing said)'); |
| 478 | |
| 479 | // ── And it is written into the crystal's own history, which is what |
| 480 | // notes2 means by "it requires a fold and a new daimon": a change that |
| 481 | // only flipped a browser setting would leave the Diamond with no record |
| 482 | // that its daimon is not the one that wrote the crystal above it. ── |
| 483 | await closeDialogs(p); |
| 484 | await p.waitForTimeout(400); |
| 485 | await p.evaluate((sel) => { document.querySelector(sel).click(); }, dSel); |
| 486 | await p.waitForTimeout(900); |
| 487 | // Onto the CRYSTAL face: the steer above put this Diamond on its chat face and |
| 488 | // `setDiamondView` remembers, so selecting it again lands on the conversation and |
| 489 | // the history control -- which belongs to the crystal -- is simply not on screen. |
| 490 | await p.click('#dview-crystal', { force: true }); |
| 491 | await p.waitForTimeout(900); |
| 492 | const opened = await p.evaluate(() => { |
| 493 | const b = [...document.querySelectorAll('.crystal-act')] |
| 494 | .find(x => /history/i.test(x.textContent || '')); |
| 495 | if (!b) return false; |
| 496 | b.click(); |
| 497 | return true; |
| 498 | }); |
| 499 | check(opened, 'the crystal has a history to open'); |
| 500 | await p.waitForTimeout(900); |
| 501 | const hist = await p.evaluate(() => [...document.querySelectorAll('.hist-row')].map(r => ({ |
| 502 | kind: (r.querySelector('.hist-kind') || {}).textContent || '', |
| 503 | note: (r.querySelector('.hist-note') || {}).textContent || '', |
| 504 | }))); |
| 505 | const mrow = hist.find(h => h.kind.trim() === 'model'); |
| 506 | check(!!mrow, 'the model change is a version in the crystal\'s history', |
| 507 | hist.map(h => h.kind.trim()).join(', ') || '(no rows)'); |
| 508 | check(!!(mrow && /moved from .* to /i.test(mrow.note)), |
| 509 | 'and the row says which model it moved from and to', |
| 510 | mrow && mrow.note); |
| 511 | await shot(s, 'history'); |
| 512 | } |
| 513 | await closeDialogs(p); |
| 514 | |
| 515 | // ══ 3. The secondary is keyed by modality ═════════════════════════ |
| 516 | // Asked of `Workers.routeFor`, which is the function `dispatch` itself calls — |
| 517 | // not a copy of the rule written here. A rule about how money is spent that |
| 518 | // could only be observed by spending it would be checked by nobody. |
| 519 | const routed = await p.evaluate(() => { |
| 520 | const W = window.DaimondWorkers; |
| 521 | if (!W || !W.routeFor) return null; |
| 522 | const text = { provider: 'p', model: 'the-text-one' }; |
| 523 | const eyes = { provider: 'p', model: 'the-eyes-one' }; |
| 524 | const r = (task, supplied) => W.routeFor(task, text, eyes, !!supplied); |
| 525 | return { |
| 526 | plain: r('read src/main.rs and summarise it'), |
| 527 | image: r('look at shots/home.png and say what is wrong'), |
| 528 | jpeg: r('compare a.jpg with b.jpeg'), |
| 529 | gif: r('what happens in loop.gif'), |
| 530 | webp: r('open hero.webp'), |
| 531 | nearMiss: r('read notes.pngx, it is not an image'), |
| 532 | tiff: r('open scan.tiff'), |
| 533 | supplied: r('look at shots/home.png', true), |
| 534 | }; |
| 535 | }); |
| 536 | if (!routed) { |
| 537 | check(false, 'the dispatch routing rule can be asked about without dispatching'); |
| 538 | } else { |
| 539 | check(routed.plain.model === 'the-text-one', |
| 540 | 'a task naming no image runs on the text model', routed.plain.model); |
| 541 | check(routed.image.model === 'the-eyes-one', |
| 542 | 'a task naming a .png runs on the image model', routed.image.model); |
| 543 | check(routed.jpeg.model === 'the-eyes-one' && routed.gif.model === 'the-eyes-one' |
| 544 | && routed.webp.model === 'the-eyes-one', |
| 545 | '.jpg, .jpeg, .gif and .webp count as images — exactly what ImageMedia sniffs'); |
| 546 | check(routed.tiff.model === 'the-text-one', |
| 547 | 'a format this app cannot hand a model as an image is not one', routed.tiff.model); |
| 548 | check(routed.nearMiss.model === 'the-text-one', |
| 549 | 'an extension that merely starts with an image name is not one', routed.nearMiss.model); |
| 550 | check(routed.supplied.model === 'the-text-one' && routed.supplied.sees === false, |
| 551 | 'a model supplied for the whole fan-out is not second-guessed by modality', |
| 552 | routed.supplied.model); |
| 553 | |
| 554 | // ── And through the store: with no image model set, an image task falls |
| 555 | // back to the TEXT worker model rather than failing — §1.4's stated |
| 556 | // behaviour — and still records that it was an image task, so the |
| 557 | // fallback shows on the run instead of looking chosen. ── |
| 558 | const fell = await p.evaluate((id) => { |
| 559 | const all = JSON.parse(localStorage.getItem('daimond-diamond-models') || '{}'); |
| 560 | all[id] = Object.assign({}, all[id], { visionProvider: '', visionModel: '' }); |
| 561 | localStorage.setItem('daimond-diamond-models', JSON.stringify(all)); |
| 562 | const W = window.DaimondWorkers; |
| 563 | return { |
| 564 | img: W.routeForDiamond(id, 'look at shots/home.png'), |
| 565 | plain: W.routeForDiamond(id, 'read main.rs'), |
| 566 | }; |
| 567 | }, dId); |
| 568 | check(!!fell.img.model && fell.img.model === fell.plain.model, |
| 569 | 'with no image model set, an image task falls back to the text model', |
| 570 | fell.img.model); |
| 571 | check(fell.img.sees === true && fell.plain.sees === false, |
| 572 | 'and the run still records which of the two it was', String(fell.img.sees)); |
| 573 | } |
| 574 | |
| 575 | // ══ 4. The meter says where the fold happens ══════════════════════ |
| 576 | await newChat(s); |
| 577 | await p.fill('#chat-input', 'hello'); |
| 578 | await p.click('#chat-send'); |
| 579 | await p.waitForTimeout(2500); |
| 580 | // Max, or the meter is hidden by the view's CSS rather than absent. |
| 581 | const cId = await p.evaluate(() => |
| 582 | (document.querySelector('#session-list .chat-box') || {}).dataset?.id || ''); |
| 583 | await setView(p, 'max'); |
| 584 | await p.waitForTimeout(300); |
| 585 | |
| 586 | const meter = await p.evaluate(() => { |
| 587 | const bar = document.querySelector('#session-list .chat-box .tile-ctx-bar'); |
| 588 | if (!bar) return null; |
| 589 | const mark = bar.querySelector('.tile-ctx-fold'); |
| 590 | const ctx = bar.closest('.tile-ctx'); |
| 591 | return { |
| 592 | hasMark: !!mark, |
| 593 | left: mark ? mark.style.left : '', |
| 594 | drawn: mark ? mark.getClientRects().length > 0 : false, |
| 595 | title: ctx ? (ctx.title || '') : '', |
| 596 | }; |
| 597 | }); |
| 598 | if (meter === null) { |
| 599 | console.log(' .. this model publishes no window, so there is no meter to mark'); |
| 600 | } else { |
| 601 | check(meter.hasMark, 'the context bar carries a fold mark'); |
| 602 | check(meter.drawn, 'the fold mark is actually drawn', meter.left); |
| 603 | check(/^\d+%$/.test(meter.left) && parseInt(meter.left, 10) > 0 |
| 604 | && parseInt(meter.left, 10) < 100, |
| 605 | 'the mark sits inside the bar at the fold fraction', meter.left); |
| 606 | check(/\bFolds at\b|\d+%/.test(meter.title), |
| 607 | 'and the meter says so in words as well as in ink', meter.title); |
| 608 | } |
| 609 | await shot(s, 'meter'); |
| 610 | |
| 611 | // ══ 5. A fold the user can ask for, that reports honestly ═════════ |
| 612 | // |
| 613 | // That a hand fold does not SHRINK the window is proved in Rust, where the |
| 614 | // window lives (`test_a_hand_fold_leaves_the_window_where_it_was_00`). What |
| 615 | // only the browser can prove is that the control exists, is reachable, and |
| 616 | // tells the truth about a conversation too short to fold — the failure mode |
| 617 | // of every "do it now" button ever written. |
| 618 | const cogOpened = await p.evaluate((id) => { |
| 619 | const box = document.querySelector(`#session-list .chat-box[data-id="${id}"]`); |
| 620 | const cog = box && box.querySelector('.tile-cog'); |
| 621 | if (!cog) return false; |
| 622 | cog.click(); |
| 623 | return true; |
| 624 | }, cId); |
| 625 | check(cogOpened, 'the chat tile has a cog'); |
| 626 | await p.waitForSelector('.tile-dlg-card', { timeout: 8000 }); |
| 627 | const ctxSec = await p.evaluate(() => { |
| 628 | const card = document.querySelector('.tile-dlg-card'); |
| 629 | const heads = [...card.querySelectorAll('.tile-dlg-head')].map(h => h.textContent.trim()); |
| 630 | // `.tile-dlg-level` is a BOX, not a meaning: the colour reset in this same |
| 631 | // dialog wears it too. The fold is the one inside the Context section, so |
| 632 | // the reset is excluded by class and the words settle which of what is |
| 633 | // left is being pressed. |
| 634 | const btn = [...card.querySelectorAll('.tile-dlg-level:not(.tile-dlg-clear)')] |
| 635 | .find(b => /fold/i.test(b.textContent || '')); |
| 636 | const notes = [...card.querySelectorAll('.tile-dlg-note')].map(n => n.textContent.trim()); |
| 637 | return { heads, hasBtn: !!btn, notes }; |
| 638 | }); |
| 639 | check(ctxSec.heads.some(h => /context/i.test(h)), |
| 640 | 'the chat dialog has a Context section', ctxSec.heads.join(' | ')); |
| 641 | check(ctxSec.hasBtn, 'and a control that folds it now'); |
| 642 | check(ctxSec.notes.some(n => /folds at \d+%/i.test(n)), |
| 643 | 'which says where the fold happens, in words', |
| 644 | ctxSec.notes.find(n => /folds/i.test(n)) || ctxSec.notes.join(' | ')); |
| 645 | |
| 646 | // ── Press it on a one-turn conversation. There is no tail to cut below |
| 647 | // MIN_KEEP_MESSAGES, so the honest answer is that nothing moved. ── |
| 648 | await p.evaluate(() => { |
| 649 | const card = document.querySelector('.tile-dlg-card'); |
| 650 | [...card.querySelectorAll('.tile-dlg-level:not(.tile-dlg-clear)')] |
| 651 | .find(b => /fold/i.test(b.textContent || '')).click(); |
| 652 | }); |
| 653 | // THE CONFIRM, SCOPED PAST THE TILE DIALOG'S OWN CARD — and this is the |
| 654 | // second place in this file to need saying so. |
| 655 | // |
| 656 | // What stood here was `waitForSelector('.dlg-card .dlg-ok')` followed by a |
| 657 | // click on the last visible `.dlg-card`. The tile dialog is a `.dlg-card` |
| 658 | // too (`modal-card dlg-card tile-dlg-card`), it is standing at this point |
| 659 | // because the Fold button that was just pressed is inside it, and the only |
| 660 | // `.dlg-ok` it carries is DELETE at its foot — which `deleteChat` acts on |
| 661 | // with no second question. So the wait was answered by the dialog already on |
| 662 | // screen, and the click was a coin toss settled by document order. |
| 663 | // |
| 664 | // It came up tails on 2026-08-20/21. Under a break where the fold refuses |
| 665 | // before any confirm is drawn, this block pressed Delete and the run said |
| 666 | // "Moved 'the chat from just now' to the trash" — a check destroying the |
| 667 | // fixture it was in the middle of measuring, and then reporting on the |
| 668 | // wreckage. 5b was scoped that night; this one was left because it passed, |
| 669 | // and it passed only because a confirm happens to be drawn LAST in document |
| 670 | // order on the healthy path. That is not a reason, it is a coincidence. |
| 671 | // |
| 672 | // Scoped by `:not(.tile-dlg-card)` — the same guard 5b uses — and a missing |
| 673 | // confirm is now a NAMED failure rather than a timeout thrown out of the |
| 674 | // block: the fold is a destructive-enough act to be worth asking about, so |
| 675 | // its absence is a fact about the product and belongs in the list. |
| 676 | // |
| 677 | // Read the toast once BEFORE waiting the confirm out, for the reason 5b gives |
| 678 | // below: a refusal is instant and its toast is gone in a few seconds, so a run |
| 679 | // that waits the full timeout first reports the break as "(nothing said)" — |
| 680 | // red for the right reason and unreadable, which is how a red run gets blamed |
| 681 | // on the harness. |
| 682 | await p.waitForTimeout(1200); |
| 683 | const earlySaid = await p.evaluate(() => |
| 684 | [...document.querySelectorAll('.toast, .toast-msg, [class*="toast"]')] |
| 685 | .filter(n => n.getClientRects().length) |
| 686 | .map(n => (n.textContent || '').trim()).filter(Boolean).join(' | ')); |
| 687 | const askedFirst = await p.waitForFunction(() => |
| 688 | [...document.querySelectorAll('.dlg-card')] |
| 689 | .filter(c => c.getClientRects().length && !c.classList.contains('tile-dlg-card')) |
| 690 | .length > 0, null, { timeout: 8000 }).then(() => true).catch(() => false); |
| 691 | check(askedFirst, 'pressing Fold asks first, in a card of its own', |
| 692 | askedFirst ? 'a confirm was drawn over the tile dialog' : 'no confirm appeared, so nothing was pressed'); |
| 693 | if (askedFirst) { |
| 694 | await p.evaluate(() => { |
| 695 | const card = [...document.querySelectorAll('.dlg-card')] |
| 696 | .filter(c => c.getClientRects().length && !c.classList.contains('tile-dlg-card')).pop(); |
| 697 | card.querySelector('.dlg-ok').click(); |
| 698 | }); |
| 699 | } |
| 700 | await p.waitForTimeout(2500); |
| 701 | const said = (await p.evaluate(() => |
| 702 | [...document.querySelectorAll('.toast, .toast-msg, [class*="toast"]')] |
| 703 | .map(n => (n.textContent || '').trim()).filter(Boolean).join(' | '))) || earlySaid; |
| 704 | check(/nothing to fold|already as short/i.test(said), |
| 705 | 'a fold of a conversation that cannot be folded says so', said || '(nothing said)'); |
| 706 | // And the transcript is not quietly shorter for having pressed it. |
| 707 | const foldRows = await p.evaluate(() => |
| 708 | document.querySelectorAll('.chat-msg-compacted').length); |
| 709 | check(foldRows === 0, 'and no fold notice was written for a fold that did not happen', |
| 710 | String(foldRows)); |
| 711 | await shot(s, 'fold-dialog'); |
| 712 | await closeDialogs(p); |
| 713 | |
| 714 | // ── 5b. AND IT IS STILL ASKABLE AFTER A RELOAD, which is the ordinary case. |
| 715 | // |
| 716 | // `chat.app` is an engine instance for THIS page load, and `hydrateChat` nulls it on |
| 717 | // every boot. The guard used to read it, so the first thing a fold said about a |
| 718 | // two-hundred-message thread reopened tomorrow was that the chat had nothing in it. |
| 719 | // Nothing could see this from a single-page run, which is why the reload is the |
| 720 | // check: the conversation is real, the engine is not, and the fold has to build one. |
| 721 | // |
| 722 | // What is asserted is the HONEST answer for a one-turn chat -- nothing to fold -- |
| 723 | // rather than the absence of the refusal. Asserting on the refusal's own words would |
| 724 | // be a check written against a string the locales are in the middle of rewording. |
| 725 | await p.reload({ waitUntil: 'domcontentloaded' }); |
| 726 | await signInAs(s, 'diamondmodels'); |
| 727 | await p.waitForTimeout(2000); |
| 728 | await setView(p, 'max'); |
| 729 | const reopened = await p.evaluate((id) => { |
| 730 | const box = document.querySelector(`#session-list .chat-box[data-id="${id}"]`); |
| 731 | if (!box) return false; |
| 732 | box.click(); |
| 733 | return true; |
| 734 | }, cId); |
| 735 | check(reopened, 'the chat is still on the rail after a reload'); |
| 736 | await p.waitForTimeout(900); |
| 737 | const drawnBack = await p.evaluate(() => { |
| 738 | const co = document.getElementById('chat-output'); |
| 739 | return co ? (co.textContent || '').replace(/\s+/g, ' ') : ''; |
| 740 | }); |
| 741 | check(/hello/.test(drawnBack), |
| 742 | 'and it still holds the conversation, so the fold below has a subject', |
| 743 | drawnBack.slice(0, 60)); |
| 744 | const cogAgain = await p.evaluate((id) => { |
| 745 | const box = document.querySelector(`#session-list .chat-box[data-id="${id}"]`); |
| 746 | const cog = box && box.querySelector('.tile-cog'); |
| 747 | if (!cog) return false; |
| 748 | cog.click(); |
| 749 | return true; |
| 750 | }, cId); |
| 751 | check(cogAgain, 'its cog still opens'); |
| 752 | await p.waitForSelector('.tile-dlg-card', { timeout: 8000 }); |
| 753 | await p.evaluate(() => { |
| 754 | const card = document.querySelector('.tile-dlg-card'); |
| 755 | [...card.querySelectorAll('.tile-dlg-level:not(.tile-dlg-clear)')] |
| 756 | .find(b => /fold/i.test(b.textContent || '')).click(); |
| 757 | }); |
| 758 | /// Whatever the app is saying in a toast, right now. |
| 759 | const toasts = () => p.evaluate(() => |
| 760 | [...document.querySelectorAll('.toast, .toast-msg, [class*="toast"]')] |
| 761 | .filter(n => n.getClientRects().length) |
| 762 | .map(n => (n.textContent || '').trim()).filter(Boolean).join(' | ')); |
| 763 | // Read once BEFORE waiting for the confirm. A refusal is instant and its toast is |
| 764 | // gone in a few seconds, so a run that waited out the confirm timeout first would |
| 765 | // report the break as "(nothing said)" -- red for the right reason, and unreadable. |
| 766 | await p.waitForTimeout(1200); |
| 767 | const early = await toasts(); |
| 768 | // The confirm, if one comes: SCOPED past the tile dialog's own card, which carries |
| 769 | // `.dlg-card` and whose Delete at the foot carries `.dlg-ok`. An unscoped wait is |
| 770 | // satisfied by the dialog already standing and an unscoped click then trashes the |
| 771 | // chat -- which is what happened here, under the break, where a refusal returns |
| 772 | // before any confirm is drawn. A missing confirm is not a failure at this point; |
| 773 | // the toast is what is being judged. |
| 774 | const askedToFold = await p.waitForFunction(() => |
| 775 | [...document.querySelectorAll('.dlg-card')] |
| 776 | .filter(c => c.getClientRects().length && !c.classList.contains('tile-dlg-card')) |
| 777 | .length > 0, null, { timeout: 8000 }).then(() => true).catch(() => false); |
| 778 | if (askedToFold) { |
| 779 | await p.evaluate(() => { |
| 780 | const card = [...document.querySelectorAll('.dlg-card')] |
| 781 | .filter(c => c.getClientRects().length && !c.classList.contains('tile-dlg-card')).pop(); |
| 782 | card.querySelector('.dlg-ok').click(); |
| 783 | }); |
| 784 | } |
| 785 | await p.waitForTimeout(3000); |
| 786 | const saidAgain = (await toasts()) || early; |
| 787 | check(/nothing to fold|already as short/i.test(saidAgain), |
| 788 | 'A FOLD ASKED FOR AFTER A RELOAD REACHES THE CONVERSATION, and answers about it', |
| 789 | saidAgain || '(nothing said)'); |
| 790 | await closeDialogs(p); |
| 791 | |
| 792 | } catch (e) { |
| 793 | check(false, 'the run finished', String(e && e.message || e)); |
| 794 | try { await shot(s, 'threw'); } catch {} |
| 795 | } finally { |
| 796 | await s.close(); |
| 797 | } |
| 798 | |
| 799 | console.log(failures === 0 |
| 800 | ? `\nverify_diamondmodels: all checks pass.` |
| 801 | : `\nverify_diamondmodels: ${failures} failed.`); |
| 802 | process.exit(failures === 0 ? 0 : 1); |