oxedyne/daimond/dev/verify_chatstore.mjs
13.9 KiB, 1 run
created by r2519314175:283, 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_chatstore.mjs — where a transcript lives, and what happens when it cannot be saved. |
| 2 | // |
| 3 | // Chats were persisted to localStorage, which holds about five megabytes for the |
| 4 | // WHOLE ORIGIN — shared with the ledger, the provider table, the mail state and |
| 5 | // everything else the app keeps. `setItem` throws when that is full, and the throw |
| 6 | // was caught and dropped: |
| 7 | // |
| 8 | // catch (e) { /* quota or unavailable — chats stay in-memory this session */ } |
| 9 | // |
| 10 | // So the work went on being shown and went on being answered, and simply stopped |
| 11 | // being saved. The user found out on the next reload, when it was gone. |
| 12 | // |
| 13 | // Three properties are asserted here, and each is the failure written out: |
| 14 | // |
| 15 | // 1. A transcript lands in IndexedDB, and what localStorage already held is |
| 16 | // carried across rather than stranded. |
| 17 | // 2. With localStorage at its real ceiling — filled here, in this browser, |
| 18 | // until it actually refuses — a turn taken afterwards SURVIVES A RELOAD. |
| 19 | // 3. When the store itself fails, the user is TOLD, standing on screen, with |
| 20 | // the one action that rescues the work: write it to a file now. |
| 21 | // |
| 22 | // Needs dev/serve.mjs (DAIMOND_PORT, default 8777) and dev/mockllm.mjs |
| 23 | // (DAIMOND_MOCK_PORT, default 9099). |
| 24 | import { open, chat, signInAs, errors, contentText } from './harness.mjs'; |
| 25 | |
| 26 | const ok = [], bad = []; |
| 27 | const check = (name, pass, detail) => { |
| 28 | (pass ? ok : bad).push(name + (detail ? ' — ' + detail : '')); |
| 29 | console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : '')); |
| 30 | }; |
| 31 | |
| 32 | /// Everything the IndexedDB chat store holds, read from OUTSIDE the app so the |
| 33 | /// answer is the disk's and not the app's belief about it. |
| 34 | const readStore = (s) => s.page.evaluate(() => new Promise((res) => { |
| 35 | const req = indexedDB.open('daimond-chats', 1); |
| 36 | req.onsuccess = () => { |
| 37 | const db = req.result; |
| 38 | let t; |
| 39 | try { t = db.transaction('chats', 'readonly'); } catch (e) { res([]); return; } |
| 40 | const out = []; |
| 41 | const cur = t.objectStore('chats').openCursor(); |
| 42 | cur.onsuccess = () => { const c = cur.result; if (c) { out.push(c.value); c.continue(); } else res(out); }; |
| 43 | cur.onerror = () => res(out); |
| 44 | }; |
| 45 | req.onerror = () => res([]); |
| 46 | })); |
| 47 | |
| 48 | async function reloadAndIn(s, name) { |
| 49 | await s.page.reload({ waitUntil: 'domcontentloaded' }); |
| 50 | await s.page.waitForTimeout(1200); |
| 51 | await signInAs(s, name); |
| 52 | await s.page.waitForTimeout(1000); |
| 53 | } |
| 54 | |
| 55 | const NAME = 'chatstore'; |
| 56 | const s = await open({ name: NAME }); |
| 57 | const p = s.page; |
| 58 | |
| 59 | // ── 1. The transcript goes to IndexedDB, and localStorage is left alone ──── |
| 60 | |
| 61 | await chat(s, '@text first thing'); |
| 62 | const rows = await readStore(s); |
| 63 | check('a turn is stored in IndexedDB', rows.length > 0 && (rows[0].messages || []).length > 0, |
| 64 | `${rows.length} chats, ${rows.length ? (rows[0].messages || []).length : 0} messages`); |
| 65 | |
| 66 | const lsAfter = await p.evaluate(() => ({ |
| 67 | chats: localStorage.getItem('daimond-chats'), |
| 68 | rev: localStorage.getItem('daimond-chats-rev'), |
| 69 | })); |
| 70 | check('and localStorage is no longer where transcripts are written', !lsAfter.chats, |
| 71 | lsAfter.chats ? `${lsAfter.chats.length} bytes still there` : 'absent'); |
| 72 | check('a nonce is written instead, so the other tab still learns of the change', !!lsAfter.rev); |
| 73 | |
| 74 | // ── 2. A store already in localStorage is carried across, not stranded ───── |
| 75 | |
| 76 | const SEEDED_ID = 'c9001'; |
| 77 | await p.evaluate((id) => { |
| 78 | localStorage.setItem('daimond-chats', JSON.stringify([{ |
| 79 | id: id, |
| 80 | name: 'From The Old Store', |
| 81 | model: 'mock/fast', |
| 82 | provider: 'custom', |
| 83 | status: 'active', |
| 84 | updatedAt: Date.now(), |
| 85 | messages: [ |
| 86 | { role: 'user', content: 'a question from before the move', mid: 'm-old-1', ts: 1 }, |
| 87 | { role: 'assistant', content: 'an answer from before the move', mid: 'm-old-2', ts: 2 }, |
| 88 | ], |
| 89 | }])); |
| 90 | }, SEEDED_ID); |
| 91 | |
| 92 | // The chat this test writes its transcript into, held by id so every later |
| 93 | // step drives THAT one whatever order the rail chooses to draw. |
| 94 | const workingChatId = await p.evaluate(() => { |
| 95 | const on = document.querySelector('#session-list .chat-box.active') |
| 96 | || document.querySelector('#session-list .chat-box'); |
| 97 | return on ? on.dataset.id : ''; |
| 98 | }); |
| 99 | |
| 100 | await reloadAndIn(s, NAME); |
| 101 | |
| 102 | const migrated = await readStore(s); |
| 103 | const found = migrated.find(c => c.id === SEEDED_ID); |
| 104 | check('a chat sitting in the old localStorage store is migrated on the next boot', |
| 105 | !!found, found ? `"${found.name}", ${(found.messages || []).length} messages` : 'not migrated'); |
| 106 | check('with its transcript intact', |
| 107 | !!(found && (found.messages || []).some(m => m.content === 'a question from before the move'))); |
| 108 | // The tile's name is an <input>, so its value is the label — textContent is blank. |
| 109 | const onRail = await p.evaluate(() => [...document.querySelectorAll('#session-list .chat-box')] |
| 110 | .map(b => { const i = b.querySelector('.tile-when'); return (i ? i.textContent.trim() : '') + ' ' + b.textContent; }).join(' | ')); |
| 111 | check('and it is on the rail, where the user can see it', /From The Old Store/.test(onRail)); |
| 112 | |
| 113 | const afterMig = await p.evaluate(() => ({ |
| 114 | live: localStorage.getItem('daimond-chats'), |
| 115 | archive: localStorage.getItem('daimond-chats-legacy'), |
| 116 | })); |
| 117 | check('the old key is consumed, so nothing unions it back in and resurrects a deletion', |
| 118 | !afterMig.live); |
| 119 | check('but it is kept under an archive name, so an old transcript is still readable', |
| 120 | !!afterMig.archive && afterMig.archive.indexOf('From The Old Store') !== -1, |
| 121 | afterMig.archive ? `${afterMig.archive.length} bytes` : 'gone'); |
| 122 | |
| 123 | // ── 3. With localStorage genuinely full, the work still survives a reload ── |
| 124 | // |
| 125 | // Filled here, in this browser, until `setItem` actually refuses — not simulated. |
| 126 | // One chunk is then freed, so the small unrelated writes the app makes (a chat |
| 127 | // counter, a tombstone) still land: the case under test is "the transcript no |
| 128 | // longer fits", not "nothing at all fits". |
| 129 | |
| 130 | const filled = await p.evaluate(() => { |
| 131 | const CHUNK = 64 * 1024; |
| 132 | const pad = 'x'.repeat(CHUNK); |
| 133 | let n = 0; |
| 134 | let err = ''; |
| 135 | try { |
| 136 | for (; n < 400; n++) localStorage.setItem('__fill_' + n, pad); |
| 137 | } catch (e) { err = e.name || String(e); } |
| 138 | try { localStorage.removeItem('__fill_' + (n - 1)); } catch (e) { /* nothing to free */ } |
| 139 | return { chunks: n, bytes: n * CHUNK, err }; |
| 140 | }); |
| 141 | check('localStorage really does run out, and says so', filled.err === 'QuotaExceededError', |
| 142 | `${(filled.bytes / 1048576).toFixed(1)} MB in, then ${filled.err}`); |
| 143 | |
| 144 | const wouldHaveFailed = await p.evaluate(() => { |
| 145 | // The write the old code made on every turn, with a transcript no bigger than |
| 146 | // a morning's work. This is the line that used to throw and be swallowed. |
| 147 | const oneDay = JSON.stringify([{ id: 'x', messages: Array.from({ length: 40 }, |
| 148 | (_, i) => ({ role: 'tool_log', content: 'y'.repeat(8192), mid: 'k' + i })) }]); |
| 149 | try { localStorage.setItem('daimond-chats', oneDay); localStorage.removeItem('daimond-chats'); return ''; } |
| 150 | catch (e) { return e.name || String(e); } |
| 151 | }); |
| 152 | check('and a day of tool results is exactly what it refuses', |
| 153 | wouldHaveFailed === 'QuotaExceededError', wouldHaveFailed || 'it fitted, which it should not have'); |
| 154 | |
| 155 | // Now work, with the origin's localStorage in that state. |
| 156 | // THE CHAT THIS TEST MEANS, not whichever tile happens to be first. Chat tiles |
| 157 | // now list newest-touched first, which put the MIGRATED chat at the top — a |
| 158 | // chat carrying no model, so the turn typed into it went nowhere and ten checks |
| 159 | // failed for a reason that had nothing to do with storage. The house rule is to |
| 160 | // assert meaning rather than position; it applies to what a test DRIVES just as |
| 161 | // much as to what it checks. |
| 162 | await p.evaluate((id) => { |
| 163 | const b = [...document.querySelectorAll('#session-list .chat-box')] |
| 164 | .find(x => x.dataset.id === id); |
| 165 | if (b) b.click(); |
| 166 | }, workingChatId); |
| 167 | await p.waitForTimeout(500); |
| 168 | await chat(s, '@text written while localStorage was full'); |
| 169 | const fullRows = await readStore(s); |
| 170 | check('a turn taken with localStorage full still reaches the store', |
| 171 | JSON.stringify(fullRows).includes('written while localStorage was full')); |
| 172 | |
| 173 | await reloadAndIn(s, NAME); |
| 174 | const survived = await p.evaluate(() => document.body.innerText); |
| 175 | const survivedStore = await readStore(s); |
| 176 | check('and it is still there after a reload — the failure this replaces, undone', |
| 177 | JSON.stringify(survivedStore).includes('written while localStorage was full'), |
| 178 | survived.includes('written while localStorage was full') ? 'and on the rail' : ''); |
| 179 | |
| 180 | const quota = await p.evaluate(() => navigator.storage.estimate().then(e => e.quota)); |
| 181 | check('the store it moved to is sized for the job', quota > 100 * 1024 * 1024, |
| 182 | `${(quota / 1073741824).toFixed(1)} GB quota`); |
| 183 | |
| 184 | // ── 4. A tool result is shortened on its way in, and says how much went ──── |
| 185 | |
| 186 | // THE CHAT THIS TEST MEANS, not whichever tile happens to be first. Chat tiles |
| 187 | // now list newest-touched first, which put the MIGRATED chat at the top — a |
| 188 | // chat carrying no model, so the turn typed into it went nowhere and ten checks |
| 189 | // failed for a reason that had nothing to do with storage. The house rule is to |
| 190 | // assert meaning rather than position; it applies to what a test DRIVES just as |
| 191 | // much as to what it checks. |
| 192 | await p.evaluate((id) => { |
| 193 | const b = [...document.querySelectorAll('#session-list .chat-box')] |
| 194 | .find(x => x.dataset.id === id); |
| 195 | if (b) b.click(); |
| 196 | }, workingChatId); |
| 197 | await p.waitForTimeout(400); |
| 198 | // THE CHAT'S OWN SCRATCH, not the workspace root, and it has to stay that way. |
| 199 | // This wrote `big.txt` at the root until 2026-08-14; since the chat fence landed on |
| 200 | // 2026-08-12 a chat is confined to `chats/<id>/work` (`scopeChatTo`, |
| 201 | // www/js/daimond.js) and `Tool::guard` (src/tools.rs:5490) refuses a root path before |
| 202 | // the write. The refusal arrives as an ordinary tool result — a SHORT one — so |
| 203 | // nothing was written, the read that follows found nothing, and the 20 KB tool |
| 204 | // result these three checks are about never existed. |
| 205 | const BIGPATH = await p.evaluate((id) => window.DaimondAttach.chatScratch(id), workingChatId) + '/big.txt'; |
| 206 | const BIG = 'Z'.repeat(20000); |
| 207 | const bigWrite = await chat(s, '@tool file_write {"path":"' + BIGPATH + '","content":"' + BIG + '"}'); |
| 208 | check('the big file was really written — a refused write leaves no large result to shorten', |
| 209 | !/Refused/.test(bigWrite) && /Wrote \d+ bytes/.test(bigWrite), |
| 210 | bigWrite.slice(-140).replace(/\n/g, ' | ')); |
| 211 | await chat(s, '@tool file_read {"path":"' + BIGPATH + '"}'); |
| 212 | |
| 213 | const withTool = await readStore(s); |
| 214 | const toolRows = []; |
| 215 | withTool.forEach(c => (c.messages || []).forEach(m => { if (m.role === 'tool_log') toolRows.push(m); })); |
| 216 | const bigRow = toolRows.find(m => (m.elided || 0) > 0); |
| 217 | check('a large tool result is shortened on its way into storage', !!bigRow, |
| 218 | bigRow ? `${bigRow.content.length} chars kept, ${bigRow.elided} elided` : 'nothing was shortened'); |
| 219 | check('and the record says how much went, rather than quietly losing it', |
| 220 | !!(bigRow && /more characters of this result were not saved/.test(bigRow.content))); |
| 221 | // The TOOL REPLY specifically, not merely the bytes somewhere in the session: the |
| 222 | // same 20 KB is also in the call's arguments, so a looser search would pass even |
| 223 | // with the reply shortened. |
| 224 | check('the model\'s own copy is NOT shortened — it had the whole thing', |
| 225 | withTool.some(c => c.session && (c.session.msgs || []).some(m => |
| 226 | m.role === 'tool' && typeof m.content === 'string' && m.content.length > 10000)), |
| 227 | JSON.stringify(withTool.map(c => (c.session ? (c.session.msgs || []).filter(m => m.role === 'tool') |
| 228 | .map(m => contentText(m.content).length) : [])).flat())); |
| 229 | |
| 230 | // ── 5. When the store fails, the user is told, and can act ──────────────── |
| 231 | // |
| 232 | // The failure is injected — a browser cannot be made to lose IndexedDB on |
| 233 | // request — but it is injected at the store's own door, and what is asserted is |
| 234 | // entirely the app's response to it. The old code's response was nothing at all. |
| 235 | |
| 236 | await p.evaluate(() => { |
| 237 | window.__realPut = IDBObjectStore.prototype.put; |
| 238 | IDBObjectStore.prototype.put = function () { |
| 239 | if (this.name === 'chats') throw new DOMException('injected failure', 'QuotaExceededError'); |
| 240 | return window.__realPut.apply(this, arguments); |
| 241 | }; |
| 242 | }); |
| 243 | await chat(s, '@text this one cannot be saved'); |
| 244 | await p.waitForTimeout(900); |
| 245 | |
| 246 | const alarm = await p.evaluate(() => { |
| 247 | const box = document.querySelector('.storage-alarm'); |
| 248 | if (!box) return null; |
| 249 | const r = box.getBoundingClientRect(); |
| 250 | return { |
| 251 | text: box.innerText, |
| 252 | visible: r.width > 0 && r.height > 0, |
| 253 | role: box.getAttribute('role'), |
| 254 | buttons: [...box.querySelectorAll('button')].map(b => b.textContent), |
| 255 | }; |
| 256 | }); |
| 257 | check('a write that fails puts a standing warning on screen', !!(alarm && alarm.visible), |
| 258 | alarm ? alarm.text.replace(/\n/g, ' / ').slice(0, 110) : 'nothing was shown'); |
| 259 | check('it says the conversations are not being saved, in those words', |
| 260 | !!(alarm && /not being saved/i.test(alarm.text))); |
| 261 | check('it names the cause the user can act on', |
| 262 | !!(alarm && /no room left/i.test(alarm.text)), alarm ? '' : 'no alarm'); |
| 263 | check('and it carries the move that rescues the work now', |
| 264 | !!(alarm && alarm.buttons.some(b => /download/i.test(b))), |
| 265 | alarm ? alarm.buttons.join(', ') : ''); |
| 266 | check('it is announced, not only drawn', !!(alarm && alarm.role === 'alert')); |
| 267 | |
| 268 | // It clears itself when saving works again, rather than crying wolf for ever. |
| 269 | await p.evaluate(() => { IDBObjectStore.prototype.put = window.__realPut; }); |
| 270 | await p.evaluate(() => { |
| 271 | const b = [...document.querySelectorAll('.storage-alarm button')].find(x => /try again/i.test(x.textContent)); |
| 272 | if (b) b.click(); |
| 273 | }); |
| 274 | await p.waitForTimeout(1200); |
| 275 | const gone = await p.evaluate(() => !document.querySelector('.storage-alarm')); |
| 276 | check('and it goes when a save lands again', gone); |
| 277 | |
| 278 | // The app's own console.error is part of what is asserted above — a failure |
| 279 | // that reaches the console as well as the screen — so it is not counted here. |
| 280 | const errs = errors(s).filter(e => !/502|Bad Gateway|injected failure|conversations are not being saved/.test(e)); |
| 281 | check('nothing else threw', errs.length === 0, errs.slice(0, 2).join(' | ')); |
| 282 | |
| 283 | await s.close(); |
| 284 | console.log(`\n${ok.length} passed, ${bad.length} failed`); |
| 285 | if (bad.length) { bad.forEach(b => console.log(' FAILED: ' + b)); process.exit(1); } |