Oregami
Repositories/oxedyne/daimond

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).
24import { open, chat, signInAs, errors, contentText } from './harness.mjs';
25
26const ok = [], bad = [];
27const 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.
34const 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
48async 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
55const NAME = 'chatstore';
56const s = await open({ name: NAME });
57const p = s.page;
58
59// ── 1. The transcript goes to IndexedDB, and localStorage is left alone ────
60
61await chat(s, '@text first thing');
62const rows = await readStore(s);
63check('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
66const lsAfter = await p.evaluate(() => ({
67 chats: localStorage.getItem('daimond-chats'),
68 rev: localStorage.getItem('daimond-chats-rev'),
69}));
70check('and localStorage is no longer where transcripts are written', !lsAfter.chats,
71 lsAfter.chats ? `${lsAfter.chats.length} bytes still there` : 'absent');
72check('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
76const SEEDED_ID = 'c9001';
77await 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.
94const 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
100await reloadAndIn(s, NAME);
101
102const migrated = await readStore(s);
103const found = migrated.find(c => c.id === SEEDED_ID);
104check('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');
106check('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.
109const 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(' | '));
111check('and it is on the rail, where the user can see it', /From The Old Store/.test(onRail));
112
113const afterMig = await p.evaluate(() => ({
114 live: localStorage.getItem('daimond-chats'),
115 archive: localStorage.getItem('daimond-chats-legacy'),
116}));
117check('the old key is consumed, so nothing unions it back in and resurrects a deletion',
118 !afterMig.live);
119check('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
130const 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});
141check('localStorage really does run out, and says so', filled.err === 'QuotaExceededError',
142 `${(filled.bytes / 1048576).toFixed(1)} MB in, then ${filled.err}`);
143
144const 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});
152check('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.
162await 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);
167await p.waitForTimeout(500);
168await chat(s, '@text written while localStorage was full');
169const fullRows = await readStore(s);
170check('a turn taken with localStorage full still reaches the store',
171 JSON.stringify(fullRows).includes('written while localStorage was full'));
172
173await reloadAndIn(s, NAME);
174const survived = await p.evaluate(() => document.body.innerText);
175const survivedStore = await readStore(s);
176check('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
180const quota = await p.evaluate(() => navigator.storage.estimate().then(e => e.quota));
181check('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.
192await 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);
197await 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.
205const BIGPATH = await p.evaluate((id) => window.DaimondAttach.chatScratch(id), workingChatId) + '/big.txt';
206const BIG = 'Z'.repeat(20000);
207const bigWrite = await chat(s, '@tool file_write {"path":"' + BIGPATH + '","content":"' + BIG + '"}');
208check('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, ' | '));
211await chat(s, '@tool file_read {"path":"' + BIGPATH + '"}');
212
213const withTool = await readStore(s);
214const toolRows = [];
215withTool.forEach(c => (c.messages || []).forEach(m => { if (m.role === 'tool_log') toolRows.push(m); }));
216const bigRow = toolRows.find(m => (m.elided || 0) > 0);
217check('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');
219check('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.
224check('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
236await 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});
243await chat(s, '@text this one cannot be saved');
244await p.waitForTimeout(900);
245
246const 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});
257check('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');
259check('it says the conversations are not being saved, in those words',
260 !!(alarm && /not being saved/i.test(alarm.text)));
261check('it names the cause the user can act on',
262 !!(alarm && /no room left/i.test(alarm.text)), alarm ? '' : 'no alarm');
263check('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(', ') : '');
266check('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.
269await p.evaluate(() => { IDBObjectStore.prototype.put = window.__realPut; });
270await p.evaluate(() => {
271 const b = [...document.querySelectorAll('.storage-alarm button')].find(x => /try again/i.test(x.textContent));
272 if (b) b.click();
273});
274await p.waitForTimeout(1200);
275const gone = await p.evaluate(() => !document.querySelector('.storage-alarm'));
276check('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.
280const errs = errors(s).filter(e => !/502|Bad Gateway|injected failure|conversations are not being saved/.test(e));
281check('nothing else threw', errs.length === 0, errs.slice(0, 2).join(' | '));
282
283await s.close();
284console.log(`\n${ok.length} passed, ${bad.length} failed`);
285if (bad.length) { bad.forEach(b => console.log(' FAILED: ' + b)); process.exit(1); }