Oregami
Repositories/oxedyne/daimond

oxedyne/daimond/dev/verify_trash.mjs

43.8 KiB, 1 run

created by r2519314175:757, 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_trash.mjs — deleting is reversible, and the reversal travels.
2//
3// WHY THIS FILE EXISTS. "Delete all chats" shipped with a dialog and no trash.
4// A user pressed it expecting to be able to undo, and could not. So the
5// properties below are not a feature checklist; each one is a way that promise
6// could still be false while the panel looks perfectly correct.
7//
8// 1. DELETING ASKS NOTHING AND TAKES NOTHING. The chat leaves the rail with no
9// dialog in the way, and it is in the Trash panel. Both halves: no dialog
10// alone is a data-loss bug, and a trash entry alone is a rail that did not
11// update.
12//
13// 2. A RESTORE BRINGS THE CONVERSATION BACK, NOT THE NAME. Asserted on the
14// MESSAGES — their text, in order — because a chat restored as an empty
15// record with the right label would pass every count.
16//
17// 3. THE TWO IRREVERSIBLE ACTS ASK, AND EMPTY NAMES THE COUNT. That is where
18// the ceremony went when it came off the reversible act, and a question
19// that does not say how much it is about to destroy is the question the
20// old "Delete all 14 chats?" dialog was, attached to the wrong act.
21//
22// 4. A TRASHED THING IS GONE FROM EVERYWHERE, not merely from the rail. Out of
23// the finders a person uses to reach a Diamond (the fold picker, the graph)
24// and out of `Files.bounds` — the ONE input to the fence a daimon runs
25// inside. Half-alive is worse than either state: a daimon that can still
26// read a Diamond you deleted is a surprise nobody wants.
27//
28// 5. THE STATE SYNCS, AND NEITHER SIDE CAN LOSE. This is the hard one and the
29// reason the whole feature is more than a filter. Deleting already
30// propagates through tombstones, so a local-only trash would be WORSE than
31// none: a restore here would be silently undone by the other device, which
32// had buried the same chat and never heard otherwise. Driven through the
33// parcel — collect on one device, apply on another — and asserted in both
34// directions:
35// * trashed here → trashed there;
36// * restored on either → restored on both, INCLUDING when the other
37// device is still carrying the older trashing (the burial);
38// * destroyed for good → gone on both, and a restore pressed afterwards
39// on the device that still had it does NOT bring it back (the
40// resurrection).
41// Two real devices are two browser profiles: the parcel is carried between
42// them by this file, which is exactly what the gateway does with it.
43//
44// 6. RETENTION IS A FUNCTION OF THE STAMP. An item trashed 31 days ago is
45// destroyed on the next boot, by the device that finds it, with nobody
46// having told it to. That is what makes a device offline past the retention
47// period converge rather than resurrect.
48//
49// EACH CHECK IS PROVED AGAINST BROKEN CODE FIRST. `--break <name>` serves a
50// deliberately damaged copy of a source file to the real page (through
51// `page.route`, so the browser loads it as it loads any other script) and the
52// run is expected to FAIL. A break whose anchor does not appear exactly once
53// aborts rather than passing quietly: a check proved against code that was never
54// broken is not proved at all.
55//
56// node dev/verify_trash.mjs --break nodialogless # 1: deleting asks again
57// node dev/verify_trash.mjs --break notrashed # 1: deleting destroys
58// node dev/verify_trash.mjs --break emptyrestore # 2: restore loses the transcript
59// node dev/verify_trash.mjs --break earlyrestore # 2: the undo repaints before it lands
60// node dev/verify_trash.mjs --break silentpurge # 3: "Delete permanently" asks nothing
61// node dev/verify_trash.mjs --break countless # 3: "Empty trash" drops the count
62// node dev/verify_trash.mjs --break stolenview # 1: the shared tile ignores the
63// # view this panel asked for
64// node dev/verify_trash.mjs --break stillfound # 4: a trashed Diamond is still in the finders
65// node dev/verify_trash.mjs --break fenced # 4: and still inside a daimon's fence
66// node dev/verify_trash.mjs --break nosync # 5: the state never leaves the device
67// node dev/verify_trash.mjs --break buryrestore # 5: a stale trashing buries a restore
68// node dev/verify_trash.mjs --break resurrect # 5: a restore undoes a permanent delete
69// node dev/verify_trash.mjs --break nobackuptrash # 5: a backup un-deletes on restore
70// node dev/verify_trash.mjs --break nokeep # 6: retention never fires
71// node dev/verify_trash.mjs # and then, clean
72//
73// eval "$(bash dev/world.sh 3 --up)"
74// node dev/verify_trash.mjs
75//
76// Needs dev/serve.mjs only. No gateway: the parcel is collected and applied
77// through `DaimondSync.parcel()` / `DaimondSync.apply()`, which sync.js publishes
78// precisely so a test measures the fixed point THROUGH the two functions the
79// wire uses rather than around them.
80import fs from 'node:fs';
81import path from 'node:path';
82import { fileURLToPath } from 'node:url';
83import { open, shot, scratch, errors, signInAs, transcript } from './harness.mjs';
84
85const HERE = path.dirname(fileURLToPath(import.meta.url));
86const WWW = path.join(HERE, '..', 'www');
87
88const BREAK = (() => {
89 const i = process.argv.indexOf('--break');
90 return i > 0 ? String(process.argv[i + 1] || '') : '';
91})();
92
93const ok = [], bad = [];
94const check = (name, pass, detail) => {
95 (pass ? ok : bad).push(name);
96 console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : ''));
97};
98
99// ── The breaks ───────────────────────────────────────────────────────
100// Each is a real edit to a real file, served in place of it.
101const BREAKS = {
102 // The dialog is back in front of an act that takes nothing away.
103 nodialogless: [{
104 file: 'js/daimond.js',
105 find: '\tfunction deleteChat(chat) {\n\t\tremoveChat(chat);',
106 with: '\tasync function deleteChat(chat) {\n'
107 + '\t\tif (!await confirmDialog(\'Delete this chat?\', \'Delete\')) return false;\n'
108 + '\t\tremoveChat(chat);',
109 }],
110 // Deleting destroys rather than trashing: the app as it was when somebody
111 // lost fourteen chats. The "no dialog" check still passes here, which is why
112 // the trash half is asked as well.
113 notrashed: [{
114 file: 'js/daimond.js',
115 find: '\t\ttry { DaimondTrash.put(chat.id, \'chat\'); }',
116 with: '\t\ttry { throw new Error(\'broken on purpose\'); }',
117 }],
118 // Trashing "tidies up" the messages on its way past — a tombstone per turn,
119 // which is a thing somebody would plausibly write and which the transcript
120 // union then honours for ever. The chat comes back with its name, its size,
121 // its tile and nothing in it. A count-based check would not see this;
122 // asserting the MESSAGES does. This one proves the ON-SCREEN half red; the
123 // stored half is proved by `notrashed`, where there is no record to restore
124 // and the transcript check fails at the store.
125 emptyrestore: [{
126 file: 'js/daimond.js',
127 find: '\t\tdetachChat(chat);\n\t\ttry { DaimondTrash.put(chat.id, \'chat\'); }',
128 with: '\t\tdetachChat(chat);\n'
129 + '\t\tmsgTombstone((chat.messages || []).map(function (m) { return m.mid; }));\n'
130 + '\t\ttry { DaimondTrash.put(chat.id, \'chat\'); }',
131 }],
132 // THE UNDO REPAINTS BEFORE THE RESTORE HAS LANDED. One `await` off the
133 // restore and the panel is drawn from a trash list read while the item was
134 // still in it, so the thing the user just put back is still sitting in the
135 // bin they are looking at. It settles on the next repaint, which is what
136 // makes it the worst shape a defect has: it is a race, so it fails some of
137 // the time, and Undo is a daily press.
138 //
139 // Found by dev/mutations.mjs (`js-trash-await`) as a mutation NOTHING here
140 // killed: fifty seconds of checks over this surface and no reading was taken
141 // close enough to the press to see it.
142 earlyrestore: [{
143 file: 'js/trash.js',
144 find: '\t\tawait core().trashRestore(it.id);\n\t\tawait render();',
145 with: '\t\tcore().trashRestore(it.id);\n\t\tawait render();',
146 }],
147 // "Delete permanently" destroys without asking — the one place in this flow
148 // where a question is owed.
149 silentpurge: [{
150 file: 'js/trash.js',
151 find: '\t\tvar ok = await core().confirm(\n'
152 + '\t\t\tt(\'trash.purge_ask\', { name: it.name }),\n'
153 + '\t\t\tt(\'trash.purge_ok\'),\n'
154 + '\t\t\t{ title: t(\'trash.purge\') });',
155 with: '\t\tvar ok = true;',
156 }],
157 // "Empty trash" still asks, but the question no longer says how much it is
158 // about to destroy.
159 countless: [{
160 file: 'js/trash.js',
161 find: '\t\t\ttn(\'trash.empty_ask\', items.length, { n: items.length }),',
162 with: '\t\t\tt(\'trash.empty\'),',
163 }],
164 // The trash asks for its own view and is given the user's instead. Whoever
165 // last pressed the attachment footers' toggle then decides whether the Trash
166 // panel shows dates and sizes at all -- in a panel with no toggle to get
167 // back with. It is the one way the shared component can quietly stop serving
168 // the caller that has no chrome of its own.
169 stolenview: [{
170 file: 'js/daimond.js',
171 find: '\t\tvar icons = (item.view || attachView()) === \'icons\';',
172 with: '\t\tvar icons = true;',
173 }],
174 // A trashed Diamond is still handed to the fence and still in the finders:
175 // the half-alive state, which is the one this feature refuses.
176 stillfound: [{
177 file: 'js/daimond.js',
178 find: '\t\t\t\t\tdiamonds = JSON.parse(json).filter(function (d) {\n'
179 + '\t\t\t\t\t\treturn d && d.id && !trashed(d.id);\n'
180 + '\t\t\t\t\t});',
181 with: '\t\t\t\t\tdiamonds = JSON.parse(json);',
182 }],
183 // The fence still hands a trashed Diamond its own directory, so an agent
184 // dispatched into a Diamond the user deleted runs in it. `stillfound` above
185 // does not reach this: `bounds` has a guard of its own, which is the point —
186 // the two ways in are closed separately and are broken separately.
187 fenced: [{
188 file: 'js/daimond.js',
189 find: '\t\t\t\tif (trashed(did)) return { own_dir: \'\', attached: [], read_only: [], toolkits: [] };',
190 with: '',
191 }],
192 // The state never gets into the parcel, so the trash is local: exactly the
193 // design this file's section 5 exists to refuse.
194 nosync: [{
195 file: 'js/sync.js',
196 find: '\t\ttry { if (window.DaimondTrash) state.trash = DaimondTrash.snapshot(); }',
197 with: '\t\ttry { if (false) state.trash = DaimondTrash.snapshot(); }',
198 }],
199 // The merge takes the arriving record wholesale instead of the later of each
200 // stamp, so a device still carrying yesterday's trashing buries today's
201 // restore. The state still syncs, so `nosync`'s checks pass here.
202 buryrestore: [{
203 file: 'js/trash.js',
204 find: '\t\t\tif (r.at > mine.at) { mine.at = r.at; moved = true; }\n'
205 + '\t\t\tif (r.back > mine.back) { mine.back = r.back; moved = true; }',
206 with: '\t\t\tif (r.at !== mine.at || r.back !== mine.back) { mine.at = r.at; mine.back = r.back; moved = true; }',
207 }],
208 // A permanent deletion that does not travel. The Diamond is destroyed here
209 // and no tombstone goes with it, so the other device — which still holds it
210 // in its own trash — restores it and hands it straight back. This is the
211 // resurrection, and it is the reason permanent deletion stays a tombstone
212 // rather than becoming another state in the trash record.
213 resurrect: [{
214 file: 'js/daimond.js',
215 find: '\t\tdiamondTombstone(id);',
216 with: '',
217 }],
218 // A backup that carries the trashed things and not the state that says they
219 // are deleted. Restoring it un-deletes everything the user had deleted.
220 nobackuptrash: [{
221 file: 'js/daimond.js',
222 find: '\t\t\ttrash: (function () {',
223 with: '\t\t\ttrash: (function () { if (true) return null;',
224 }],
225 // Retention never fires: the trash grows for ever, and the date on every
226 // tile is a promise nothing keeps.
227 nokeep: [{
228 file: 'js/trash.js',
229 find: '\t\t\t\tif (now - r.at >= RETAIN_MS) expired.push({ id: id, kind: r.k === \'d\' ? \'diamond\' : \'chat\', at: r.at });',
230 with: '\t\t\t\tif (false) expired.push({ id: id, kind: r.k === \'d\' ? \'diamond\' : \'chat\', at: r.at });',
231 }],
232};
233
234if (BREAK && !BREAKS[BREAK]) {
235 console.error(`unknown break '${BREAK}'; one of: ${Object.keys(BREAKS).join(', ')}`);
236 process.exit(2);
237}
238
239/// `src` with `spec` applied, or a hard stop. Nothing is served that was not
240/// verified to differ from what it was given.
241function damaged(src, spec) {
242 const n = src.split(spec.find).length - 1;
243 if (n !== 1) {
244 console.error(`break '${BREAK}': the anchor appears ${n} times in ${spec.file}, `
245 + 'so nothing was broken and the run below would prove nothing.');
246 process.exit(2);
247 }
248 return src.replace(spec.find, spec.with);
249}
250
251/// The damaged files, ONE BODY PER FILE.
252///
253/// Every edit a break names for a file goes into the SAME body, in order, and
254/// that one body is what the route serves. A `page.route` per edit spec does not
255/// work and does not say so: Playwright hands a request to the LAST route
256/// registered for its URL, so a two-edit break shipped only its second edit --
257/// and still went red, for half the reason it claims, with nothing to notice it.
258function damagedFiles() {
259 const byFile = new Map();
260 for (const spec of (BREAKS[BREAK] || [])) {
261 const src = byFile.has(spec.file) ? byFile.get(spec.file)
262 : fs.readFileSync(path.join(WWW, spec.file), 'utf8');
263 byFile.set(spec.file, damaged(src, spec));
264 }
265 return byFile;
266}
267
268async function breakInto(page) {
269 if (!BREAK) return;
270 for (const [file, body] of damagedFiles()) {
271 await page.route('**/' + file, (r) => r.fulfill({
272 status: 200, contentType: 'application/javascript', body,
273 }));
274 }
275}
276
277// ── The fixture ──────────────────────────────────────────────────────
278// Two chats with transcripts whose exact words are the oracle for the restore,
279// and two Diamonds so that "gone from the rail" can be told apart from "the
280// rail is empty".
281const SAID = {
282 Ledger: ['what does the ledger owe', 'four pounds and ninepence', 'and to whom'],
283 Recipe: ['how long do I proof it', 'ninety minutes, covered'],
284};
285
286const seedChats = (page, records) => page.evaluate((rows) => new Promise((res) => {
287 const req = indexedDB.open('daimond-chats', 1);
288 req.onsuccess = () => {
289 const db = req.result;
290 const t = db.transaction('chats', 'readwrite');
291 rows.forEach((r) => t.objectStore('chats').put(r));
292 t.oncomplete = () => res(true);
293 t.onerror = () => res(false);
294 };
295 req.onerror = () => res(false);
296}), records);
297
298function chatRecords() {
299 const base = Date.parse('2026-08-01T00:00:00Z');
300 return Object.keys(SAID).map((name, i) => ({
301 id: 'tc' + (i + 1),
302 name,
303 messages: SAID[name].map((text, j) => ({
304 role: j % 2 === 0 ? 'user' : 'assistant',
305 content: text,
306 mid: `m-${i}-${j}`,
307 ts: base + j,
308 })),
309 model: 'mock/fast', provider: '', diamondId: '', status: 'active',
310 promptTokens: 0, completionTokens: 0, updatedAt: base + 1000 * (i + 1),
311 }));
312}
313
314/// The tile names on the rail, top to bottom.
315///
316/// `.tile-when` carries a chat's identity — the user's own name where one is
317/// set, and the derived relative time where none is. Every chat in this
318/// fixture is given a name, so here it is always the name. Read as TEXT: the
319/// label stopped being an `<input>` when the rename gesture left the tile, and
320/// a button has no `.value`.
321const railChats = (page) => page.$$eval('#session-list .session-box .tile-when',
322 (els) => els.map((e) => (e.textContent || '').trim()));
323/// The Diamond names on the rail, which is a filtered view of the store.
324const railDiamonds = (page) => page.evaluate(() =>
325 [...document.querySelectorAll('#diamond-list .diamond-box')]
326 .map((e) => e.getAttribute('aria-label')));
327
328/// What the STORE holds, unfiltered — read through a wasm app of its own, the
329/// way `clearDiamonds` in the harness does. This is how "trashed is a state,
330/// not a deletion" is asserted: the bytes are still on disk while the rail
331/// says the Diamond is gone.
332const storedDiamonds = (page) => page.evaluate(async () => {
333 const m = await import('/pkg/oxedyne_daimond.js');
334 const app = new m.DaimondApp('http://127.0.0.1/v1/chat/completions', '', 'none', 4096, '', true);
335 try { return JSON.parse(await app.list_diamonds()).map((d) => ({ id: d.id, name: d.name })); }
336 catch (e) { return []; }
337});
338
339/// What the Trash panel actually draws. These are the ATTACHMENT tile's own
340/// class names, not a set this panel invented (ATTACH_CONTRACT.md §9): `shut`
341/// for an item that cannot be opened, `.arte-why` for the reason it draws in
342/// words, `.arte-note` for the facts under it. Reading them here is half of
343/// what says the two callers are one component -- if the trash grew a renderer
344/// of its own, this selector would stop finding anything.
345const panelTiles = (page) => page.$$eval('#trash-list .arte-row', (els) => els.map((e) => ({
346 kind: (e.querySelector('.arte-kind') || {}).textContent || '',
347 label: (e.querySelector('.arte-open') || {}).textContent || '',
348 why: (e.querySelector('.arte-why') || {}).textContent || '',
349 note: (e.querySelector('.arte-note') || {}).textContent || '',
350 shut: e.classList.contains('shut'),
351 // A tile that cannot be opened must not be a button either: a label that
352 // looks pressable and is not is the empty-folder failure in another coat.
353 openable: !!e.querySelector('button.arte-open'),
354 acts: [...e.querySelectorAll('button')].map((b) => b.className.split(' ')[0]),
355})));
356
357/// The one visible dialog's message, or null if none is open.
358const dialogMsg = (page) => page.evaluate(() => {
359 const card = [...document.querySelectorAll('.modal.dlg .dlg-card')]
360 .find((c) => c.getClientRects().length);
361 return card ? (card.querySelector('.dlg-msg') || {}).textContent || '' : null;
362});
363const answer = (page, cls) => page.evaluate((c) => {
364 const btns = [...document.querySelectorAll('.modal.dlg .' + c)]
365 .filter((b) => b.getClientRects().length);
366 const b = btns[btns.length - 1];
367 if (b) b.click();
368 return !!b;
369}, cls);
370
371/// The transcript a chat holds now, read from the store rather than the screen:
372/// what has to survive a restore is what is on disk.
373const storedSaid = (page, name) => page.evaluate((n) => new Promise((res) => {
374 const req = indexedDB.open('daimond-chats', 1);
375 req.onsuccess = () => {
376 const all = req.result.transaction('chats', 'readonly').objectStore('chats').getAll();
377 all.onsuccess = () => {
378 const c = (all.result || []).find((x) => x.name === n);
379 res(c ? (c.messages || []).map((m) => m.content) : null);
380 };
381 all.onerror = () => res(null);
382 };
383 req.onerror = () => res(null);
384}), name);
385
386const newDiamond = async (page, name) => {
387 await page.click('#new-diamond-btn', { timeout: 8000 });
388 await page.waitForSelector('.dlg-input', { timeout: 10000 });
389 await page.fill('.dlg-input', name);
390 await page.click('.dlg-ok', { force: true });
391 await page.waitForTimeout(900);
392};
393
394const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
395
396/// Ask the panel to redraw, tolerating a break that has taken the module out
397/// altogether. A break that CRASHES the harness proves no more than one that
398/// does nothing: the run has to reach its own report.
399const redraw = (page) => page.evaluate(() => {
400 try { window.DaimondTrashPanel.render(); } catch (e) { /* broken on purpose */ }
401});
402
403// ── Device A ─────────────────────────────────────────────────────────
404const PROFILE_A = scratch('pw', 'trash-a' + (BREAK ? '-' + BREAK : ''));
405const PROFILE_B = scratch('pw', 'trash-b' + (BREAK ? '-' + BREAK : ''));
406fs.rmSync(PROFILE_A, { recursive: true, force: true });
407fs.rmSync(PROFILE_B, { recursive: true, force: true });
408
409const a = await open({ name: 'trash', profile: PROFILE_A, defaults: false });
410await breakInto(a.page);
411await a.page.reload({ waitUntil: 'domcontentloaded' });
412await signInAs(a, 'trash');
413await a.page.waitForTimeout(600);
414
415let b = null; // the second device, opened only for the sync section
416
417try {
418 const A = a.page;
419 await seedChats(A, chatRecords());
420 await A.reload({ waitUntil: 'domcontentloaded' });
421 await signInAs(a, 'trash');
422 await A.waitForTimeout(1200);
423
424 await newDiamond(A, 'Kept');
425 await newDiamond(A, 'Doomed');
426
427 const seededChats = await railChats(A);
428 const seededDiamonds = await A.evaluate(() =>
429 [...document.querySelectorAll('#diamond-list .diamond-box')]
430 .map((e) => e.getAttribute('aria-label')));
431 check('the fixture is on the rail: two chats and two Diamonds',
432 seededChats.length === 2 && seededDiamonds.length === 2,
433 `${seededChats.join(', ')} | ${seededDiamonds.join(', ')}`);
434 if (seededChats.length !== 2 || seededDiamonds.length !== 2) {
435 console.log('\nnothing to test against — refusing to report a vacuous pass.');
436 await a.close();
437 process.exit(1);
438 }
439
440 // ── 1. Deleting asks nothing and takes nothing ──────────────────
441 await A.evaluate(() => {
442 const box = [...document.querySelectorAll('#session-list .session-box')]
443 .find((e) => ((e.querySelector('.tile-when') || {}).textContent || '').trim() === 'Ledger');
444 const x = box && box.querySelector('.tile-x');
445 if (x) x.click();
446 });
447 await A.waitForTimeout(700);
448 const askedOnDelete = await dialogMsg(A);
449 check('deleting a chat asks NOTHING', askedOnDelete === null,
450 `a dialog opened: ${JSON.stringify(askedOnDelete)}`);
451 // A dialog left standing under a break would swallow every click after it.
452 if (askedOnDelete !== null) { await answer(A, 'dlg-cancel'); await A.waitForTimeout(300); }
453
454 const railAfterDelete = await railChats(A);
455 check('and the chat leaves the rail — by NAME, with the other one still there',
456 !railAfterDelete.includes('Ledger') && railAfterDelete.includes('Recipe'),
457 railAfterDelete.join(', ') || 'empty');
458
459 await A.evaluate(() => window.DaimondPanels.show('trash'));
460 await A.waitForTimeout(700);
461 let tiles = await panelTiles(A);
462 check('IT IS IN THE TRASH PANEL, named, as a chat',
463 tiles.length === 1 && tiles[0].label === 'Ledger' && /chat/i.test(tiles[0].kind),
464 JSON.stringify(tiles));
465 check('the tile is drawn as something that cannot be opened — and not as a button',
466 !!(tiles[0] && tiles[0].shut && tiles[0].openable === false),
467 JSON.stringify(tiles[0]));
468 check('and it SAYS WHY in words on the tile, not in a title a touch cannot reach',
469 !!(tiles[0] && /delet/i.test(tiles[0].why)), JSON.stringify(tiles[0] && tiles[0].why));
470 check('and the tile says when it stops existing, and what it weighs',
471 !!(tiles[0] && /\d/.test(tiles[0].note) && /B|KB|MB/.test(tiles[0].note)),
472 tiles[0] && tiles[0].note);
473 const head = await A.evaluate(() => (document.getElementById('trash-note') || {}).textContent || '');
474 check('the panel says what the trash is holding, in bytes',
475 /\d/.test(head) && /B|KB|MB/.test(head), JSON.stringify(head));
476 await shot(a, 'trash-one' + (BREAK ? '-' + BREAK : ''));
477
478 // ── 2. A restore brings the CONVERSATION back ───────────────────
479 //
480 // THE PRESS IS TIMED, because the thing this is proved against is a race.
481 // `restore()` awaits the restore and only then repaints; take that one
482 // `await` off and the repaint is drawn from a trash list read while the item
483 // was still in it, so the thing the user has just put back is still sitting
484 // in the bin in front of them. Some later repaint tidies it, which is why
485 // fifty seconds of checks over this surface never saw it: every reading was
486 // taken long after the moment that goes wrong. dev/mutations.mjs found it as
487 // `js-trash-await`, a mutation nothing here killed.
488 //
489 // WHAT MASKS IT, AND WHY THE TWO STORE READS ARE BOTH SLOWED. `DaimondTrash`
490 // announces when the item leaves it, and this panel redraws on that — so a
491 // stale paint is normally tidied a tick later and nobody sees anything. Not
492 // when the two overlap: `render()` takes one at a time (`drawing`), so the
493 // repaint the announcement asks for while a paint is in flight is DROPPED,
494 // not queued, and the stale one is the one that lands. That is the shape a
495 // user meets on a slow read — a big trash, a busy device — and it is why the
496 // defect is intermittent.
497 //
498 // Both reads are given a known duration so the overlap is certain rather
499 // than lucky: the restore lands at 200 ms, inside a list read that runs to
500 // 600 ms. Correct code starts its paint AFTER the restore and paints the
501 // truth. Code that started first paints what it read before, and the
502 // announcement that would have corrected it was refused. Proved by
503 // `--break earlyrestore`.
504 const SLOW = { restore: 200, list: 600 };
505 await A.evaluate((ms) => {
506 const c = window.DaimondCore;
507 window.__real = { restore: c.trashRestore.bind(c), list: c.trashList.bind(c) };
508 // A WRITE that takes a while to happen.
509 const slowWrite = (t, f) => (...a) =>
510 new Promise((ok) => setTimeout(ok, t)).then(() => f(...a));
511 // And a READ that happens now and answers late — which is the whole
512 // point. Delaying the read itself would let it see the restore, and a
513 // list read that sees the restore is not the read this is about.
514 const slowRead = (t, f) => (...a) => Promise.resolve(f(...a))
515 .then((v) => new Promise((ok) => setTimeout(() => ok(v), t)));
516 c.trashRestore = slowWrite(ms.restore, window.__real.restore);
517 c.trashList = slowRead(ms.list, window.__real.list);
518 }, SLOW);
519 await A.evaluate(() => {
520 const r = document.querySelector('#trash-list .trash-restore');
521 if (r) r.click();
522 });
523 await A.waitForTimeout(SLOW.list + SLOW.restore + 600);
524 const atLanding = await panelTiles(A);
525 check('THE UNDO REPAINTS AFTER THE RESTORE HAS LANDED — what was just put back is not '
526 + 'still sitting in the bin the user is looking at',
527 atLanding.every((x) => x.label !== 'Ledger'), JSON.stringify(atLanding));
528 await A.evaluate(() => {
529 if (!window.__real) return;
530 window.DaimondCore.trashRestore = window.__real.restore;
531 window.DaimondCore.trashList = window.__real.list;
532 });
533 await A.waitForTimeout(1500);
534 const railAfterRestore = await railChats(A);
535 check('restoring puts the chat back on the rail', railAfterRestore.includes('Ledger'),
536 railAfterRestore.join(', ') || 'empty');
537 const restored = await storedSaid(A, 'Ledger');
538 check('AND ITS TRANSCRIPT, WORD FOR WORD — not a chat with the right name',
539 JSON.stringify(restored) === JSON.stringify(SAID.Ledger),
540 JSON.stringify(restored));
541 // The screen, not only the store: a restore that never reached the thread is
542 // a restore the user cannot see.
543 await A.evaluate(() => {
544 const box = [...document.querySelectorAll('#session-list .session-box')]
545 .find((e) => ((e.querySelector('.tile-when') || {}).textContent || '').trim() === 'Ledger');
546 if (box) box.click();
547 });
548 await A.waitForTimeout(1000);
549 const thread = await transcript(a);
550 check('and the restored conversation is on screen, with what was said in it',
551 SAID.Ledger.every((line) => thread.includes(line)),
552 JSON.stringify(thread.slice(0, 200)));
553 check('the trash is empty again after the restore',
554 (await panelTiles(A)).length === 0 || (await panelTiles(A)).every((x) => x.label !== 'Ledger'),
555 JSON.stringify(await panelTiles(A)));
556
557 // ── 3. "Delete permanently" asks, and names what it destroys ────
558 await A.evaluate(() => {
559 const box = [...document.querySelectorAll('#session-list .session-box')]
560 .find((e) => ((e.querySelector('.tile-when') || {}).textContent || '').trim() === 'Ledger');
561 const x = box && box.querySelector('.tile-x');
562 if (x) x.click();
563 });
564 await A.waitForTimeout(900);
565 await redraw(A);
566 await A.waitForTimeout(600);
567 await A.evaluate(() => {
568 const p = document.querySelector('#trash-list .trash-purge');
569 if (p) p.click();
570 });
571 await A.waitForTimeout(800);
572 const purgeAsk = await dialogMsg(A);
573 check('"Delete permanently" ASKS FIRST, and names what it is about to destroy',
574 !!(purgeAsk && purgeAsk.includes('Ledger')), JSON.stringify(purgeAsk));
575 if (purgeAsk !== null) { await answer(A, 'dlg-cancel'); await A.waitForTimeout(500); }
576 check('and answering no leaves it in the trash',
577 (await panelTiles(A)).some((x) => x.label === 'Ledger'),
578 JSON.stringify(await panelTiles(A)));
579
580 // ── A Diamond is deleted the same way, from its tile's dialog ───
581 await A.evaluate(() => {
582 const box = [...document.querySelectorAll('#diamond-list .diamond-box')]
583 .find((e) => (e.getAttribute('aria-label') || '') === 'Doomed');
584 const cog = box && box.querySelector('.tile-cog');
585 if (cog) cog.click();
586 });
587 await A.waitForTimeout(800);
588 await A.evaluate(() => {
589 const del = [...document.querySelectorAll('.tile-dlg-delete')]
590 .filter((btn) => btn.getClientRects().length).pop();
591 if (del) del.click();
592 });
593 await A.waitForTimeout(1600);
594 const askedOnDiamond = await dialogMsg(A);
595 check('deleting a Diamond asks NOTHING either', askedOnDiamond === null,
596 `a dialog opened: ${JSON.stringify(askedOnDiamond)}`);
597 if (askedOnDiamond !== null) { await answer(A, 'dlg-ok'); await A.waitForTimeout(1200); }
598 const railDsNow = await railDiamonds(A);
599 check('and it leaves the rail, with the Diamond that was not deleted still on it',
600 !railDsNow.includes('Doomed') && railDsNow.includes('Kept'),
601 railDsNow.join(', ') || 'empty');
602 // TRASHED IS A STATE, NOT A DELETION. The store still has every byte, which
603 // is the only reason a restore on this or any other device can work at all.
604 const onDisk = await storedDiamonds(A);
605 check('THE DIAMOND IS STILL IN THE STORE — nothing was destroyed by deleting it',
606 onDisk.some((d) => d.name === 'Doomed') && onDisk.some((d) => d.name === 'Kept'),
607 JSON.stringify(onDisk.map((d) => d.name)));
608
609 await redraw(A);
610 await A.waitForTimeout(700);
611 let tiles2 = await panelTiles(A);
612 check('the panel holds both, NEWEST FIRST — the Diamond above the chat',
613 tiles2.length === 2 && tiles2[0].label === 'Doomed' && tiles2[1].label === 'Ledger',
614 JSON.stringify(tiles2.map((x) => x.label)));
615 check('and the Diamond is badged as a Diamond, not as a chat',
616 !!(tiles2[0] && /diamond/i.test(tiles2[0].kind)), JSON.stringify(tiles2[0]));
617 await shot(a, 'trash-two' + (BREAK ? '-' + BREAK : ''));
618
619 // ── 4. Gone from the finders, and gone from the fence ───────────
620 const doomedId = (onDisk.find((d) => d.name === 'Doomed') || {}).id || '';
621 const keptId = (onDisk.find((d) => d.name === 'Kept') || {}).id || '';
622
623 const fold = await A.evaluate(() => {
624 const box = [...document.querySelectorAll('#session-list .session-box')][0];
625 const f = box && box.querySelector('.tile-fold');
626 if (f) f.click();
627 return [...document.querySelectorAll('.fold-menu-item')].map((e) => e.textContent.trim());
628 });
629 check('a trashed Diamond is not offered as somewhere to fold a chat, while a live one is',
630 !fold.includes('Doomed') && fold.includes('Kept'), JSON.stringify(fold));
631 await A.evaluate(() => { const m = document.querySelector('.fold-menu'); if (m) m.remove(); });
632
633 const deadBounds = await A.evaluate((id) => window.DaimondDiamond.bounds(id), doomedId)
634 .catch(() => null);
635 const liveBounds = await A.evaluate((id) => window.DaimondDiamond.bounds(id), keptId)
636 .catch(() => null);
637 check('AND IT IS OUT OF THE FENCE: a trashed Diamond names no directory to confine an agent to',
638 !!(deadBounds && deadBounds.own_dir === ''), JSON.stringify(deadBounds));
639 // The other half, so the check above cannot pass because `bounds` is broken
640 // for everything.
641 check('while a Diamond that was NOT deleted still has its own directory',
642 !!(liveBounds && liveBounds.own_dir === 'diamonds/' + keptId),
643 JSON.stringify(liveBounds));
644
645 // ── 3b. Empty trash names the count ─────────────────────────────
646 const n = (await panelTiles(A)).length;
647 await A.evaluate(() => { const e = document.getElementById('trash-empty'); if (e) e.click(); });
648 await A.waitForTimeout(900);
649 const emptyAsk = await dialogMsg(A);
650 check(`"Empty trash" ASKS, and the question NAMES THE COUNT (${n})`,
651 !!(emptyAsk && new RegExp('\\b' + n + '\\b').test(emptyAsk)),
652 JSON.stringify(emptyAsk));
653 await shot(a, 'trash-empty-ask' + (BREAK ? '-' + BREAK : ''));
654 if (emptyAsk !== null) { await answer(A, 'dlg-cancel'); await A.waitForTimeout(600); }
655 const afterEmptyNo = (await panelTiles(A)).length;
656 check('and answering no leaves the trash exactly as it was',
657 afterEmptyNo === n, `${afterEmptyNo} of ${n}`);
658
659 // ── 5. The state syncs, and neither side can lose ───────────────
660 // The parcel is collected here and applied on a second, independent browser
661 // profile — the same two calls sync.js makes at the wire.
662 b = await open({ name: 'trash', profile: PROFILE_B, defaults: false });
663 await breakInto(b.page);
664 await b.page.reload({ waitUntil: 'domcontentloaded' });
665 await signInAs(b, 'trash');
666 await b.page.waitForTimeout(1000);
667 const B = b.page;
668
669 const carry = async (from, to) => {
670 const parcel = await from.evaluate(() => window.DaimondSync.parcel().then(JSON.stringify));
671 await to.evaluate((p) => window.DaimondSync.apply(JSON.parse(p)), parcel);
672 await to.waitForTimeout(1500);
673 return JSON.parse(parcel);
674 };
675
676 const parcelA = await carry(A, B);
677 check('the parcel carries the trash at all',
678 !!(parcelA.trash && parcelA.trash.items
679 && Object.keys(parcelA.trash.items).length >= 2),
680 JSON.stringify(parcelA.trash || null).slice(0, 200));
681
682 const bTrash = await B.evaluate(async () =>
683 (await window.DaimondCore.trashList()).map((x) => x.name).sort());
684 check('THE SECOND DEVICE SEES BOTH IN ITS OWN TRASH, by name',
685 JSON.stringify(bTrash) === JSON.stringify(['Doomed', 'Ledger']),
686 JSON.stringify(bTrash));
687 const bRail = await railChats(B);
688 const bDiamonds = await B.evaluate(() =>
689 [...document.querySelectorAll('#diamond-list .diamond-box')]
690 .map((e) => e.getAttribute('aria-label')));
691 check('and they are NOT on its rail, while what was not deleted is',
692 !bRail.includes('Ledger') && bRail.includes('Recipe')
693 && !bDiamonds.includes('Doomed') && bDiamonds.includes('Kept'),
694 `${bRail.join(', ')} | ${bDiamonds.join(', ')}`);
695 await shot(b, 'trash-deviceb' + (BREAK ? '-' + BREAK : ''));
696
697 // The parcel B would send RIGHT NOW, kept aside. It carries the trashing and
698 // nothing else, and it is what the burial below is made of: a parcel already
699 // in flight when somebody presses Restore on the other device.
700 const staleFromB = await B.evaluate(() => window.DaimondSync.parcel().then(JSON.stringify));
701
702 // A RESTORE ON ONE IS A RESTORE ON BOTH.
703 await B.evaluate(async () => {
704 const list = await window.DaimondCore.trashList();
705 const led = list.find((x) => x.name === 'Ledger');
706 if (led) await window.DaimondCore.trashRestore(led.id);
707 });
708 await B.waitForTimeout(900);
709 await carry(B, A);
710 const aTrashAfter = await A.evaluate(async () =>
711 (await window.DaimondCore.trashList()).map((x) => x.name).sort());
712 const aRailAfter = await railChats(A);
713 check('A RESTORE ON THE OTHER DEVICE IS A RESTORE HERE — the chat is back on this rail',
714 aRailAfter.includes('Ledger'), aRailAfter.join(', ') || 'empty');
715 check('and it is no longer in this device\'s trash, while the Diamond still is',
716 JSON.stringify(aTrashAfter) === JSON.stringify(['Doomed']),
717 JSON.stringify(aTrashAfter));
718 await carry(A, B);
719 const bRailBack = await railChats(B);
720 check('and pushing this device\'s state back keeps it restored there too',
721 bRailBack.includes('Ledger'), bRailBack.join(', ') || 'empty');
722
723 // THE BURIAL, asked directly. `staleFromB` was collected before the restore
724 // and still says "trashed"; a merge that took an arriving record wholesale —
725 // or that let a trashing outrank a later restore — would put the chat
726 // straight back in the bin, and the user would watch their undo undone by a
727 // device nobody had touched. Taking the LATER of each stamp is what refuses
728 // it, and this is the check that says so.
729 await A.evaluate((p) => window.DaimondSync.apply(JSON.parse(p)), staleFromB);
730 await A.waitForTimeout(1800);
731 const aRailStale = await railChats(A);
732 const aTrashStale = await A.evaluate(async () =>
733 (await window.DaimondCore.trashList()).map((x) => x.name));
734 check('A STALE PARCEL STILL CARRYING THE OLD TRASHING CANNOT BURY THE RESTORE',
735 aRailStale.includes('Ledger') && !aTrashStale.includes('Ledger'),
736 `rail: ${aRailStale.join(', ')} | trash: ${JSON.stringify(aTrashStale)}`);
737
738 // A DELETION CANNOT BE RESURRECTED. Device A destroys the Diamond for good;
739 // device B, which still has it in its trash, presses Restore — and it must
740 // stay gone.
741 await A.evaluate(async () => {
742 const list = await window.DaimondCore.trashList();
743 const doomed = list.find((x) => x.name === 'Doomed');
744 if (doomed) await window.DaimondCore.trashPurge(doomed.id);
745 });
746 await A.waitForTimeout(1200);
747 const aGone = await A.evaluate(async () =>
748 (await window.DaimondCore.trashList()).map((x) => x.name));
749 check('destroying a Diamond for good empties this device\'s trash',
750 aGone.length === 0, JSON.stringify(aGone));
751
752 const bRestoredDead = await B.evaluate(async () => {
753 const list = await window.DaimondCore.trashList();
754 const doomed = list.find((x) => x.name === 'Doomed');
755 if (doomed) await window.DaimondCore.trashRestore(doomed.id);
756 return !!doomed;
757 });
758 await B.waitForTimeout(900);
759 await carry(A, B); // the tombstone arrives
760 const bDiamondsEnd = await B.evaluate(() =>
761 [...document.querySelectorAll('#diamond-list .diamond-box')]
762 .map((e) => e.getAttribute('aria-label')));
763 check('A PERMANENT DELETION IS NOT UNDONE BY A RESTORE ON THE OTHER DEVICE',
764 bRestoredDead && !bDiamondsEnd.includes('Doomed'),
765 `${bDiamondsEnd.join(', ')} | restore was pressed: ${bRestoredDead}`);
766 // And the parcel it would now send agrees, rather than this device merely
767 // drawing a rail that disagrees with what it holds.
768 await carry(B, A);
769 const aDiamondsEnd = await A.evaluate(() =>
770 [...document.querySelectorAll('#diamond-list .diamond-box')]
771 .map((e) => e.getAttribute('aria-label')));
772 check('and the device that pressed Restore does not push it back to the one that destroyed it',
773 !aDiamondsEnd.includes('Doomed'), aDiamondsEnd.join(', ') || 'empty');
774
775 // The parcel is a fixed point: two collects with nothing between them are
776 // byte-identical, and applying one does not change what would be sent next.
777 // A section that failed this would push the two devices at each other for
778 // ever, which the pause tree has already taught this app once.
779 const fixed = await A.evaluate(async () => {
780 const x = JSON.stringify((await window.DaimondSync.parcel()).trash);
781 const y = JSON.stringify((await window.DaimondSync.parcel()).trash);
782 const whole = await window.DaimondSync.parcel();
783 await window.DaimondSync.apply(whole);
784 const z = JSON.stringify((await window.DaimondSync.parcel()).trash);
785 return { x, y, z };
786 });
787 check('the trash section is the same bytes on two collects', fixed.x === fixed.y,
788 `${fixed.x}\n vs \n${fixed.y}`);
789 check('and applying the parcel does not change what would be sent next',
790 fixed.x === fixed.z, `${fixed.x}\n vs \n${fixed.z}`);
791
792 // ── 6. Retention is a function of the stamp ─────────────────────
793 // A chat trashed thirty-one days ago, written straight into the record: the
794 // point is that NOTHING TELLS THIS DEVICE to destroy it. It works the date
795 // out from a stamp it already holds, which is what lets a device that was
796 // switched off past the retention period converge instead of resurrecting.
797 await A.evaluate(() => {
798 const box = [...document.querySelectorAll('#session-list .session-box')]
799 .find((e) => ((e.querySelector('.tile-when') || {}).textContent || '').trim() === 'Recipe');
800 const x = box && box.querySelector('.tile-x');
801 if (x) x.click();
802 });
803 await A.waitForTimeout(900);
804 // ── A BACKUP CARRIES THE STATE TOO ──────────────────────────────
805 // A backup already carries every trashed chat and Diamond, because trashing
806 // destroys nothing. Without the STATE beside them, restoring one would
807 // quietly un-delete everything the user had deleted — the same failure a
808 // local-only trash has, arriving by a different road.
809 // Any dialog a break has left standing goes first: the admin menu is
810 // unreachable underneath one, and the export below would then time out and
811 // take the whole run with it. A break that CRASHES the harness proves no
812 // more than one that does nothing.
813 await A.evaluate(() => {
814 document.querySelectorAll('.modal').forEach((m) => m.remove());
815 document.body.classList.remove('modal-open');
816 });
817 await A.waitForTimeout(400);
818 let backup = null, why = '';
819 try {
820 const dl = A.waitForEvent('download', { timeout: 20000 });
821 await A.click('#user-row');
822 await A.waitForTimeout(500);
823 await A.click('button.admin-item:has-text("Export a backup")');
824 const file = scratch('trash-backup' + (BREAK ? '-' + BREAK : '') + '.json');
825 await (await dl).saveAs(file);
826 backup = JSON.parse(fs.readFileSync(file, 'utf8'));
827 } catch (e) { why = String((e && e.message) || e).split('\n')[0]; }
828 const recipeId = await A.evaluate(async () => {
829 const list = await window.DaimondCore.trashList();
830 return (list.find((x) => x.name === 'Recipe') || {}).id || '';
831 });
832 const inBackup = backup && backup.trash && backup.trash.items && backup.trash.items[recipeId];
833 check('a backup carries WHAT WAS IN THE TRASH, not only the things themselves',
834 !!(inBackup && inBackup.at > inBackup.back),
835 backup ? JSON.stringify(backup.trash || null).slice(0, 200) : `no backup: ${why}`);
836 check('and it carries the trashed chat whole, so the restore has something to restore',
837 !!backup && (backup.chats || []).some((c) => c.id === recipeId && (c.messages || []).length),
838 backup ? `${(backup.chats || []).length} chat(s) in the backup` : `no backup: ${why}`);
839 await A.evaluate(() => { document.querySelectorAll('.modal').forEach((m) => m.remove()); });
840 await A.keyboard.press('Escape');
841 await A.waitForTimeout(500);
842
843 // Aged BY NAME, not by taking whichever record happens to be first: the map
844 // still carries restored records for things this run destroyed, and winding
845 // one of those back would age an entry the sweep is right to ignore — which
846 // is a test that passes while proving nothing.
847 const aged = await A.evaluate(async () => {
848 const list = await window.DaimondCore.trashList();
849 const it = list.find((x) => x.name === 'Recipe');
850 if (!it) return null;
851 const raw = JSON.parse(localStorage.getItem('daimond-trash') || '{}');
852 if (!raw.items || !raw.items[it.id]) return null;
853 raw.items[it.id].at = Date.now() - 31 * 24 * 3600 * 1000;
854 localStorage.setItem('daimond-trash', JSON.stringify(raw));
855 return it.id;
856 });
857 check('the chat to age is in the trash, and its stamp is now 31 days old', !!aged, String(aged));
858 // A reload, so the sweep runs on the boot path a returning device takes.
859 await A.reload({ waitUntil: 'domcontentloaded' });
860 await signInAs(a, 'trash');
861 await A.waitForTimeout(2500);
862 const afterSweep = await A.evaluate(async () =>
863 (await window.DaimondCore.trashList()).map((x) => x.name));
864 const stillStored = await storedSaid(A, 'Recipe');
865 check('AN ITEM PAST ITS THIRTY DAYS IS DESTROYED ON THE NEXT BOOT, unprompted',
866 !afterSweep.includes('Recipe') && stillStored === null,
867 `trash: ${JSON.stringify(afterSweep)}, store: ${JSON.stringify(stillStored)}`);
868 const railEnd = await railChats(A);
869 check('and it does not come back to the rail either',
870 !railEnd.includes('Recipe'), railEnd.join(', ') || 'empty');
871
872 await A.evaluate(() => window.DaimondPanels.show('trash'));
873 await A.waitForTimeout(600);
874 await shot(a, 'trash-swept' + (BREAK ? '-' + BREAK : ''));
875
876 const errs = errors(a).filter((e) => !/favicon/i.test(e)
877 && !/Failed to load resource/.test(e) && !/502 \(Bad Gateway\)/.test(e));
878 check('nothing was done by way of an unhandled error', errs.length === 0,
879 errs.slice(0, 3).join(' | ') || 'none');
880} finally {
881 if (b) await b.close();
882 await a.close();
883}
884
885if (BREAK) {
886 console.log(`\nbreak '${BREAK}': ${bad.length} check(s) failed`
887 + (bad.length ? ' — ' + bad.join('; ') : ' — NOTHING FAILED, so the checks above prove nothing'));
888 process.exit(bad.length ? 0 : 1); // a break MUST fail something
889}
890console.log(bad.length === 0 ? '\nall checks passed' : `\n${bad.length} check(s) FAILED`);
891process.exit(bad.length === 0 ? 0 : 1);