oxedyne/daimond/dev/verify_foldall.mjs
15.4 KiB, 1 run
created by r2519314175:417, 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_foldall.mjs — the fold never fails in silence. |
| 2 | // |
| 3 | // The bug this exists for: a user selected every turn of a chat, pressed Fold, |
| 4 | // picked a Diamond, and NOTHING happened. Not an error, not a spinner, not a |
| 5 | // diff. Every exit from the fold path was either invisible (a 12px status line |
| 6 | // on a panel the user may have left) or literally a bare `return`, and a throw |
| 7 | // past the one try block left `crystalBusy` true for the rest of the session — |
| 8 | // Steer and Propose dead, with no explanation. |
| 9 | // |
| 10 | // So each check here is one way out of the fold, and what it asserts is that the |
| 11 | // user can SEE it happened. The one that matters most is the last shape of the |
| 12 | // failure: an empty reply from the reducer used to render as a diff deleting |
| 13 | // every line of the crystal, with Accept enabled — one click from wiping the |
| 14 | // document the app exists to build. |
| 15 | // |
| 16 | // node dev/verify_foldall.mjs |
| 17 | // |
| 18 | // Needs dev/serve.mjs (DAIMOND_PORT, default 8777) and dev/mockllm.mjs |
| 19 | // (DAIMOND_MOCK_PORT, default 9099). No gateway. |
| 20 | |
| 21 | import { open, chat, shot, errors } from './harness.mjs'; |
| 22 | |
| 23 | const ok = [], bad = []; |
| 24 | const check = (name, pass, detail) => { |
| 25 | (pass ? ok : bad).push(name + (detail ? ' — ' + detail : '')); |
| 26 | console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : '')); |
| 27 | }; |
| 28 | const sleep = ms => new Promise(r => setTimeout(r, ms)); |
| 29 | |
| 30 | const DIAMOND = 'Fold Target'; |
| 31 | const s = await open({ name: 'foldall' }); |
| 32 | const p = s.page; |
| 33 | |
| 34 | // Toasts are the app's own floating status line; `.err` marks a failure. They |
| 35 | // remove themselves after ~4s, which is shorter than some of the waits here, so |
| 36 | // they are RECORDED as they appear rather than looked for afterwards — a check |
| 37 | // that polls for a live toast passes or fails on the sleep it happened to use. |
| 38 | await p.evaluate(() => { |
| 39 | window.__toasts = []; |
| 40 | new MutationObserver(muts => { |
| 41 | for (const m of muts) for (const n of m.addedNodes) { |
| 42 | if (n.nodeType === 1 && n.classList && n.classList.contains('daimond-toast')) { |
| 43 | window.__toasts.push({ text: n.textContent, err: n.classList.contains('err') }); |
| 44 | } |
| 45 | } |
| 46 | }).observe(document.body, { childList: true }); |
| 47 | }); |
| 48 | const toasts = () => p.evaluate(() => window.__toasts.slice()); |
| 49 | const clearToasts = () => p.evaluate(() => { |
| 50 | window.__toasts = []; |
| 51 | document.querySelectorAll('.daimond-toast').forEach(e => e.remove()); |
| 52 | }); |
| 53 | // The Diamond's own crystal surface: is a diff on show, and can it be applied? |
| 54 | const diffState = () => p.evaluate(() => { |
| 55 | const lines = document.querySelectorAll('.diff-lines .diff-line'); |
| 56 | const acc = document.querySelector('.diff-accept'); |
| 57 | let add = 0, del = 0; |
| 58 | lines.forEach(l => { if (l.classList.contains('add')) add++; if (l.classList.contains('del')) del++; }); |
| 59 | return { |
| 60 | lines: lines.length, add, del, |
| 61 | accept: !!acc, acceptEnabled: !!acc && !acc.disabled, |
| 62 | // The crystal's own delta box and Propose button are gone -- a Diamond |
| 63 | // has one composer, in the chat face -- so what "not stuck" means now is |
| 64 | // that the SELECTION path can be used again: `crystalBusy` released, and |
| 65 | // Fold selected offered. |
| 66 | foldSel: !!document.getElementById('sel-fold'), |
| 67 | foldSelDisabled: !!(document.getElementById('sel-fold') || {}).disabled, |
| 68 | }; |
| 69 | }); |
| 70 | /// How much of the user's own text a Diamond's crystal holds, read from |
| 71 | /// `crystal.json` through the store. |
| 72 | /// |
| 73 | /// IT USED TO MEASURE THE PANEL, and the panel no longer holds it: a crystal with |
| 74 | /// anything in it is drawn by that Diamond's own page inside a sandboxed frame, |
| 75 | /// so `.crystal-body` has no text of its own for `textContent` to count. That |
| 76 | /// probe would not have failed -- it would have returned 0 for every Diamond and |
| 77 | /// turned two preconditions into checks that can never bite, which is the quieter |
| 78 | /// and worse outcome. Both uses below are preconditions of the form "there is |
| 79 | /// something here worth deleting", so what they want is the words, not the |
| 80 | /// markup: the JSON's own braces and key names are not the user's, and are not |
| 81 | /// counted. |
| 82 | /// |
| 83 | /// Returns -1 for a Diamond that is not there and -2 for one whose crystal is not |
| 84 | /// JSON, so a broken store reads differently from an empty one. |
| 85 | const crystalWords = (name) => p.evaluate(async (name) => { |
| 86 | const m = await import('/pkg/oxedyne_daimond.js'); |
| 87 | const app = new m.DaimondApp('http://127.0.0.1/v1/chat/completions', '', 'none', 4096, '', true); |
| 88 | const row = JSON.parse(await app.list_diamonds()).find(r => r.name === name); |
| 89 | if (!row) return -1; |
| 90 | const text = await app.read_crystal_data(row.id); |
| 91 | if (!String(text).trim()) return 0; |
| 92 | let data; |
| 93 | try { data = JSON.parse(text); } catch (e) { return -2; } |
| 94 | let n = 0; |
| 95 | (function walk(v) { |
| 96 | if (typeof v === 'string') n += v.length; |
| 97 | else if (Array.isArray(v)) v.forEach(walk); |
| 98 | else if (v && typeof v === 'object') Object.keys(v).forEach(k => walk(v[k])); |
| 99 | })(data); |
| 100 | return n; |
| 101 | }, name); |
| 102 | const dialogText = () => p.evaluate(() => { |
| 103 | const c = document.querySelector('.dlg-card'); |
| 104 | return c ? c.textContent : ''; |
| 105 | }); |
| 106 | const dismissDialog = async () => { |
| 107 | if (await p.$('.dlg-ok')) { await p.click('.dlg-ok', { force: true }); await sleep(300); } |
| 108 | }; |
| 109 | |
| 110 | // ── Seed: one Diamond and a chat of four turns ─────────────────────── |
| 111 | await p.click('#new-diamond-btn', { force: true }); |
| 112 | await p.waitForSelector('.dlg-input', { timeout: 10000 }); |
| 113 | await p.fill('.dlg-input', DIAMOND); |
| 114 | await p.click('.dlg-ok', { force: true }); |
| 115 | await sleep(1000); |
| 116 | for (const m of ['MARK-ONE', 'MARK-TWO', 'MARK-THREE', 'MARK-FOUR']) { |
| 117 | await chat(s, `@text ${m} and something worth keeping about it.`); |
| 118 | } |
| 119 | |
| 120 | /// Select every turn and fold the lot into the Diamond. Returns once the picker |
| 121 | /// item has been clicked; the caller decides how long to wait for the reducer. |
| 122 | async function foldAll({ pickName = DIAMOND } = {}) { |
| 123 | // Back into the chat, in case a previous check left the centre on a Diamond. |
| 124 | await p.evaluate(() => { |
| 125 | const box = document.querySelector('.session-box:not(.diamond-box)'); |
| 126 | if (box) box.click(); |
| 127 | }); |
| 128 | await sleep(600); |
| 129 | await p.evaluate(() => { |
| 130 | const c = document.getElementById('collapse-btn'); |
| 131 | if (c && !c.classList.contains('on')) c.click(); |
| 132 | }); |
| 133 | await sleep(400); |
| 134 | await p.click('#sel-all', { force: true }); |
| 135 | await sleep(200); |
| 136 | await p.click('#sel-fold', { force: true }); |
| 137 | await p.waitForSelector('.fold-menu', { timeout: 8000 }); |
| 138 | await p.evaluate((name) => { |
| 139 | const item = [...document.querySelectorAll('.fold-menu-item')] |
| 140 | .find(b => b.textContent.trim() === name); |
| 141 | if (item) item.click(); |
| 142 | }, pickName); |
| 143 | } |
| 144 | |
| 145 | /// Wait until the fold has visibly resolved one way or the other. |
| 146 | async function settled(timeout = 20000) { |
| 147 | const t0 = Date.now(); |
| 148 | while (Date.now() - t0 < timeout) { |
| 149 | const d = await diffState(); |
| 150 | if (d.lines > 0) return 'diff'; |
| 151 | if ((await toasts()).length) return 'toast'; |
| 152 | if (await p.$('.dlg-card')) return 'dialog'; |
| 153 | await sleep(250); |
| 154 | } |
| 155 | return 'nothing'; |
| 156 | } |
| 157 | |
| 158 | // ── 1. Folding the whole chat says something within ten seconds ────── |
| 159 | await clearToasts(); |
| 160 | await foldAll(); |
| 161 | const outcome1 = await settled(20000); |
| 162 | const d1 = await diffState(); |
| 163 | check('folding every turn produces a visible result, not silence', |
| 164 | outcome1 !== 'nothing', `outcome=${outcome1}`); |
| 165 | check('and that result is a diff with real changes in it', |
| 166 | d1.lines > 0 && (d1.add + d1.del) > 0, `${d1.lines} lines, +${d1.add}/-${d1.del}`); |
| 167 | await shot(s, 'foldall-diff'); |
| 168 | |
| 169 | // Accept it, so the crystal has content for the empty-reply check below to risk. |
| 170 | if (d1.acceptEnabled) { await p.click('.diff-accept', { force: true }); await sleep(3500); } |
| 171 | const crystalLen = await crystalWords(DIAMOND); |
| 172 | check('the accepted fold left a crystal with something in it (so the next check can bite)', |
| 173 | crystalLen > 20, `${crystalLen} chars`); |
| 174 | |
| 175 | // ── 2. A provider failure is shown, and does not wedge the crystal ─── |
| 176 | await clearToasts(); |
| 177 | await p.route('**/v1/chat/completions', route => route.fulfill({ |
| 178 | status: 400, |
| 179 | contentType: 'application/json', |
| 180 | body: JSON.stringify({ error: { message: 'routed: refused' } }), |
| 181 | })); |
| 182 | await foldAll(); |
| 183 | await settled(20000); |
| 184 | const t2 = await toasts(); |
| 185 | check('a reducer that fails says so, out loud', |
| 186 | t2.some(x => x.err), JSON.stringify(t2.slice(0, 2))); |
| 187 | await p.unroute('**/v1/chat/completions'); |
| 188 | // Back on the Diamond: the controls must be live again. A sticky crystalBusy |
| 189 | // leaves Propose disabled for the rest of the session. |
| 190 | await p.evaluate((name) => { |
| 191 | const box = [...document.querySelectorAll('.diamond-box')] |
| 192 | .find(b => (b.querySelector('.session-box-name') || {}).textContent === name); |
| 193 | if (box) box.click(); |
| 194 | }, DIAMOND); |
| 195 | await sleep(1200); |
| 196 | const d2 = await diffState(); |
| 197 | check('and the crystal is not left disabled by the failure', |
| 198 | d2.foldSel && !d2.foldSelDisabled, `fold-selected present=${d2.foldSel} disabled=${d2.foldSelDisabled}`); |
| 199 | |
| 200 | // ── 3. A Diamond deleted under the picker is refused loudly ────────── |
| 201 | // The realistic case: another tab deletes it while the picker is open. The rail |
| 202 | // re-reads on the storage event, so the id the menu item holds is stale. |
| 203 | await clearToasts(); |
| 204 | await p.evaluate(() => { |
| 205 | const box = document.querySelector('.session-box:not(.diamond-box)'); |
| 206 | if (box) box.click(); |
| 207 | }); |
| 208 | await sleep(600); |
| 209 | await p.evaluate(() => { |
| 210 | const c = document.getElementById('collapse-btn'); |
| 211 | if (c && !c.classList.contains('on')) c.click(); |
| 212 | }); |
| 213 | await sleep(400); |
| 214 | await p.click('#sel-all', { force: true }); |
| 215 | await p.click('#sel-fold', { force: true }); |
| 216 | await p.waitForSelector('.fold-menu', { timeout: 8000 }); |
| 217 | // Delete it for real, then tell this tab the way another tab would. |
| 218 | await p.evaluate(async (name) => { |
| 219 | const m = await import('/pkg/oxedyne_daimond.js'); |
| 220 | const app = new m.DaimondApp('http://127.0.0.1/v1/chat/completions', '', 'none', 4096, '', true); |
| 221 | const row = JSON.parse(await app.list_diamonds()).find(r => r.name === name); |
| 222 | if (row) await app.delete_diamond(row.id); |
| 223 | window.dispatchEvent(new StorageEvent('storage', { key: 'daimond-diamonds-rev', newValue: String(Date.now()) })); |
| 224 | }, DIAMOND); |
| 225 | await sleep(1500); |
| 226 | await p.evaluate((name) => { |
| 227 | const item = [...document.querySelectorAll('.fold-menu-item')] |
| 228 | .find(b => b.textContent.trim() === name); |
| 229 | if (item) item.click(); |
| 230 | }, DIAMOND); |
| 231 | await sleep(1200); |
| 232 | const dlg3 = await dialogText(); |
| 233 | check('folding into a Diamond that has gone says so, rather than doing nothing', |
| 234 | /gone/i.test(dlg3), dlg3 ? dlg3.slice(0, 90) : '(no dialog at all)'); |
| 235 | await dismissDialog(); |
| 236 | |
| 237 | // ── 4. An empty reply is a failure, never a deletion diff ──────────── |
| 238 | // A second Diamond, folded once so its crystal is not empty, then the reducer is |
| 239 | // made to answer with nothing at all. |
| 240 | await p.click('#new-diamond-btn', { force: true }); |
| 241 | await p.waitForSelector('.dlg-input', { timeout: 10000 }); |
| 242 | await p.fill('.dlg-input', 'Empty Reply'); |
| 243 | await p.click('.dlg-ok', { force: true }); |
| 244 | await sleep(1000); |
| 245 | await clearToasts(); |
| 246 | await foldAll({ pickName: 'Empty Reply' }); |
| 247 | await settled(20000); |
| 248 | const dA = await diffState(); |
| 249 | if (dA.acceptEnabled) { await p.click('.diff-accept', { force: true }); await sleep(3500); } |
| 250 | // The precondition, asserted rather than assumed: an empty reply against an EMPTY |
| 251 | // crystal diffs to "no change" and Accept is disabled anyway, so the check below |
| 252 | // would pass without proving anything. |
| 253 | const emptyBase = await crystalWords('Empty Reply'); |
| 254 | check('the second Diamond has a crystal worth deleting (the precondition of the next check)', |
| 255 | emptyBase > 20, `${emptyBase} chars`); |
| 256 | |
| 257 | // Now the empty stream: a well-formed SSE turn that says nothing. |
| 258 | const EMPTY_SSE = [ |
| 259 | 'data: {"id":"x","object":"chat.completion.chunk","model":"mock/fast","choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":null}]}', |
| 260 | '', |
| 261 | 'data: {"id":"x","object":"chat.completion.chunk","model":"mock/fast","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}', |
| 262 | '', |
| 263 | 'data: {"id":"x","object":"chat.completion.chunk","model":"mock/fast","choices":[],"usage":{"prompt_tokens":10,"completion_tokens":0,"total_tokens":10}}', |
| 264 | '', |
| 265 | 'data: [DONE]', |
| 266 | '', |
| 267 | ].join('\n'); |
| 268 | await clearToasts(); |
| 269 | await p.route('**/v1/chat/completions', route => route.fulfill({ |
| 270 | status: 200, |
| 271 | headers: { 'content-type': 'text/event-stream', 'cache-control': 'no-cache' }, |
| 272 | body: EMPTY_SSE, |
| 273 | })); |
| 274 | await foldAll({ pickName: 'Empty Reply' }); |
| 275 | await sleep(9000); |
| 276 | const t4 = await toasts(); |
| 277 | const d4 = await diffState(); |
| 278 | await p.unroute('**/v1/chat/completions'); |
| 279 | check('an empty reducer reply is reported as a failure', |
| 280 | t4.some(x => x.err), JSON.stringify(t4.slice(0, 2))); |
| 281 | check('and is NEVER offered as a diff that deletes the crystal', |
| 282 | !(d4.acceptEnabled && d4.del > 0), `accept=${d4.acceptEnabled} deletions=${d4.del}`); |
| 283 | await shot(s, 'foldall-empty-reply'); |
| 284 | // Nothing was left pending on that Diamond, so no rail row claims one is waiting. |
| 285 | const pendingAfterEmpty = await p.$$eval('.diamond-box', els => els.filter(e => |
| 286 | e.querySelector('.diamond-pending')).map(e => (e.querySelector('.session-box-name') || {}).textContent)); |
| 287 | check('and no proposal is left pending from it', |
| 288 | pendingAfterEmpty.length === 0, JSON.stringify(pendingAfterEmpty)); |
| 289 | |
| 290 | // ── 5. Leaving mid-propose leaves a mark on the rail row ───────────── |
| 291 | // The reducer is slow on purpose; the user goes back to the chat while it runs. |
| 292 | await clearToasts(); |
| 293 | await p.route('**/v1/chat/completions', async route => { |
| 294 | await sleep(6000); |
| 295 | await route.continue(); |
| 296 | }); |
| 297 | await foldAll({ pickName: 'Empty Reply' }); |
| 298 | await sleep(1500); |
| 299 | await p.evaluate(() => { |
| 300 | const box = document.querySelector('.session-box:not(.diamond-box)'); |
| 301 | if (box) box.click(); // away from the Diamond, mid-propose |
| 302 | }); |
| 303 | await sleep(14000); |
| 304 | await p.unroute('**/v1/chat/completions'); |
| 305 | const pending = await p.$$eval('.diamond-box', els => els.map(e => ({ |
| 306 | name: (e.querySelector('.session-box-name') || {}).textContent, |
| 307 | pending: !!e.querySelector('.diamond-pending'), |
| 308 | }))); |
| 309 | check('a proposal that landed while you were elsewhere is marked on its rail row', |
| 310 | pending.some(x => x.pending), JSON.stringify(pending)); |
| 311 | const t5 = await toasts(); |
| 312 | check('and the toast names the Diamond it is waiting on', |
| 313 | t5.some(x => /Empty Reply/.test(x.text)), JSON.stringify(t5.slice(0, 2))); |
| 314 | await shot(s, 'foldall-pending-badge'); |
| 315 | |
| 316 | // ── 6. The floor under the whole class ─────────────────────────────── |
| 317 | // Most of this app is started rather than awaited: a click hands a promise to |
| 318 | // nobody. Each of the folds above is one such promise, and the fix for each was |
| 319 | // local — but a rejection escaping ANY of them must reach the user, or the next |
| 320 | // one written without a .catch is the same silence again. |
| 321 | await clearToasts(); |
| 322 | await p.evaluate(() => { Promise.reject(new Error('probe: an escaped rejection')); }); |
| 323 | await sleep(600); |
| 324 | const t6 = await toasts(); |
| 325 | check('a promise rejection nobody handled still reaches the user', |
| 326 | t6.some(x => x.err), JSON.stringify(t6.slice(0, 2))); |
| 327 | |
| 328 | // The gateway is not running for this flow, so its 502s are the absence of a |
| 329 | // server. The routed 400 is this test's own doing. |
| 330 | // The probe rejection above is this test's own doing and is reported to the |
| 331 | // console as a pageerror by design: the app's handler tells the USER, it does not |
| 332 | // swallow the developer's copy. |
| 333 | const errs = errors(s).filter(e => !/favicon|404|401|402|502|Bad Gateway|net::ERR|400|probe: an escaped rejection/.test(e)); |
| 334 | console.log('\nconsole errors:', errs.slice(0, 5)); |
| 335 | check('nothing throws into the console while all this happens', errs.length === 0, errs[0] || ''); |
| 336 | |
| 337 | await s.close(); |
| 338 | console.log(`\n${ok.length} passed, ${bad.length} failed`); |
| 339 | if (bad.length) console.log('FAILED:\n ' + bad.join('\n ')); |
| 340 | process.exit(bad.length ? 1 : 0); |