Oregami
Repositories/oxedyne/daimond

oxedyne/daimond/dev/verify_dataloss.mjs

27.3 KiB, 1 run

created by r2519314175:349, 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_dataloss.mjs — the four ways a sync round used to destroy a user's
2// work, each asserted where it was destroyed.
3//
4// 1. WORKSPACE FILES DELETED BY ABSENCE. Every other store here deletes on a
5// tombstone; files deleted on a path missing from the parcel. Four innocent
6// things produce a parcel with no files in it -- a real folder open, tools
7// not up, a directory that would not list, a file left out for budget -- and
8// each of them read as "the user deleted everything", account-wide. A parcel
9// now carries `filesComplete`, and NOTHING is deleted without it.
10// 2. THE CLOUD CHUNKS SWEPT BY THE SAME PUSH. A device that refused to merge
11// the other device's chunk index (same condition) still committed its own as
12// the account's live set, and the gateway swept every chunk it did not name.
13// The commit is now gated on the merge.
14// 3. A BACKUP WITH NO IDENTITY IN IT. The Forget flow told the user their
15// credits were recoverable from a backup; the backup carried no key, so the
16// balance and the Pro licence went with the identity. The export carries the
17// wrapped identity now, and the string says what it can and cannot do.
18// 4. ONE CONVERSATION CLEARED, EVERY OTHER ONE SHORTENED. Clearing a daimon
19// tombstones the messages it discards, by id, in a map that is global and
20// travels in the parcel. A message stored before message-ids existed is
21// given one on the way in -- and that id was minted from its POSITION
22// ALONE, so message one of every old conversation carried the same id.
23// Clearing one therefore deleted the opening messages of all of them, on
24// every device, with nothing said. Shipped 2026-08-14.
25//
26// Needs dev/serve.mjs (DAIMOND_PORT, default 8777) and dev/mockllm.mjs
27// (DAIMOND_MOCK_PORT, default 9099). No gateway: the sync engine is driven against a
28// stubbed mailbox, which is what makes the commit gate observable at all.
29//
30// CHECK 4 IS PROVED AGAINST BROKEN CODE FIRST. `--break <name>` serves a
31// deliberately damaged copy of js/daimond.js to the real page (through
32// `page.route`, so the browser loads it as it loads any other script) and the run
33// is expected to FAIL. An anchor that does not appear exactly once aborts the run
34// rather than passing quietly.
35//
36// node dev/verify_dataloss.mjs --break shipped # 4b+4d fail: the defect as it shipped
37// node dev/verify_dataloss.mjs --break stamp-only # 4c+4e fail: the clear stops sticking
38// node dev/verify_dataloss.mjs # and then, clean
39import fs from 'node:fs';
40import path from 'node:path';
41import { fileURLToPath } from 'node:url';
42import { open, errors, scratch, signInAs, storedChats } from './harness.mjs';
43
44const ok = [], bad = [];
45const check = (name, pass, detail) => {
46 (pass ? ok : bad).push(name + (detail ? ' — ' + detail : ''));
47 console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : ''));
48};
49
50const HERE = path.dirname(fileURLToPath(import.meta.url));
51const WWW = path.join(HERE, '..', 'www');
52const SRC = 'js/daimond.js';
53
54const BREAK = (() => {
55 const i = process.argv.indexOf('--break');
56 return i > 0 ? String(process.argv[i + 1] || '') : '';
57})();
58
59// ── The breaks ──────────────────────────────────────────────────────────
60//
61// A break names every site it damages, and every one of them has to land: an
62// anchor that has drifted is a break that quietly stopped applying, and a green
63// run under it proves nothing.
64const STAMP = "\t\tvar at = scope || 'nochat';\t// Absent only for a record with no id, which is never stored\n"
65 + "\t\t(msgs || []).forEach(function (m, i) {\n"
66 + "\t\t\tif (!m.mid) m.mid = 'legacy-' + at + '-' + ('0000' + i).slice(-4);\n"
67 + "\t\t\telse if (OLD_LEGACY.test(m.mid)) m.mid = 'legacy-' + at + '-' + m.mid.slice(7);";
68// The stamp as it shipped: the position, and nothing that says WHICH conversation.
69const UNSCOPED = "\t\t(msgs || []).forEach(function (m, i) {\n"
70 + "\t\t\tif (!m.mid) m.mid = 'legacy-' + ('0000' + i).slice(-4);";
71const DROP_OLD = "\t\t\tif (OLD_LEGACY.test(id)) return;\n";
72
73const BREAKS = {
74 // The defect exactly as it shipped, and it takes BOTH sites — which is worth
75 // saying, because a break at the stamp alone does not reproduce it. The read
76 // filter that now drops an unscoped tombstone would catch the colliding ids on
77 // their way back out and quietly protect the very chat this check is about, so
78 // a stamp-only break goes green here for the wrong reason. See `stamp-only`.
79 shipped: [
80 { file: SRC, find: STAMP, with: UNSCOPED },
81 { file: SRC, find: DROP_OLD, with: '' },
82 ],
83 // Only the stamp. The colliding ids come back, the filter still refuses to
84 // honour them, and so nothing is deleted from anybody else's conversation —
85 // but nothing is deleted from the cleared one either, and it comes straight
86 // back. This is why 4c and 4e are here: without them the whole property could
87 // be satisfied by never tombstoning anything at all.
88 'stamp-only': [
89 { file: SRC, find: STAMP, with: UNSCOPED },
90 ],
91};
92
93if (BREAK && !BREAKS[BREAK]) {
94 console.error(`unknown break '${BREAK}'; one of: ${Object.keys(BREAKS).join(', ')}`);
95 process.exit(2);
96}
97
98/// The damaged source, or a hard stop. Nothing is served that was not verified to
99/// differ from the file on disk.
100function damaged(spec) {
101 const src = fs.readFileSync(path.join(WWW, spec.file), 'utf8');
102 const n = src.split(spec.find).length - 1;
103 if (n !== 1) {
104 console.error(`break '${BREAK}': the anchor appears ${n} times in ${spec.file}, `
105 + 'so nothing was broken and the run below would prove nothing.');
106 process.exit(2);
107 }
108 return src.replace(spec.find, spec.with);
109}
110
111/// Serve the damaged file to the page, before anything navigates.
112async function breakInto(page) {
113 const bodies = {};
114 for (const spec of BREAKS[BREAK]) {
115 bodies[spec.file] = bodies[spec.file] || fs.readFileSync(path.join(WWW, spec.file), 'utf8');
116 // Each spec is checked against the file ON DISK, so two edits to one file
117 // cannot mask each other's anchor.
118 damaged(spec);
119 bodies[spec.file] = bodies[spec.file].replace(spec.find, spec.with);
120 }
121 for (const file of Object.keys(bodies)) {
122 await page.route('**/' + file, (r) => r.fulfill({
123 status: 200, contentType: 'application/javascript', body: bodies[file],
124 }));
125 }
126}
127
128const s = await open({ name: 'dataloss', route: BREAK ? breakInto : null });
129const p = s.page;
130if (BREAK) console.log(`\n*** RUNNING UNDER --break ${BREAK}: failures below are the point ***\n`);
131
132// ── 1a. The receiving side: what may delete, and what may not ────────────
133
134// Seeded through the engine's own door and NOT through a turn, which is the
135// repair and not a shortcut. These two lines were `@tool file_write` of a
136// workspace-root path, and since 2026-08-11 every chat is fenced to
137// `chats/<id>/work` (`scopeChatTo`, www/js/daimond.js) -- so the engine refused
138// both writes, said so in the tool result, and the turn finished normally. The
139// census below then measured the system-seeded docs and nothing else, and three
140// checks went red for a reason that had nothing to do with sync.
141//
142// What this section is about is the deletion guard, so the files go where the
143// census will meet them by the shortest route that puts them there. `write_file`
144// resolves against the active Workspace root, which here is the OPFS sandbox.
145await p.evaluate(async () => {
146 const mod = await import('../pkg/oxedyne_daimond.js');
147 await mod.write_file('keep-a.md', 'alpha');
148 await mod.write_file('keep-b.md', 'beta');
149});
150
151/// The workspace as the sync census sees it.
152const census = () => p.evaluate(async () => {
153 const st = await window.DaimondCore.collectSync();
154 return { paths: Object.keys(st.files || {}).sort(), complete: st.filesComplete };
155});
156
157/// Merge one parcel, then say which of the two seeded files survive.
158const applyAndList = (parcel) => p.evaluate(async (parcel) => {
159 const rep = await window.DaimondCore.applySync(parcel);
160 const st = await window.DaimondCore.collectSync();
161 return { failed: rep.failed, paths: Object.keys(st.files || {}).sort() };
162}, parcel);
163
164const seeded = await census();
165check('the sandbox census sees both seeded files',
166 seeded.paths.includes('keep-a.md') && seeded.paths.includes('keep-b.md'), seeded.paths.join(' '));
167check('and reports itself complete', seeded.complete === true, String(seeded.complete));
168
169// Both devices now agree on both files: that is the baseline the deletion pass
170// measures absence against, and without it there is nothing to delete.
171await p.evaluate(() => window.DaimondCore.syncCommitBaseline());
172
173const empty = await applyAndList({ v: 2, chats: [], files: {}, filesComplete: false });
174check('a parcel of no files whose census was INCOMPLETE deletes nothing',
175 empty.paths.includes('keep-a.md') && empty.paths.includes('keep-b.md'),
176 empty.paths.join(' ') || '(workspace emptied)');
177
178const old = await applyAndList({ v: 2, chats: [], files: {} });
179check('and neither does one from a device too old to say (no flag at all)',
180 old.paths.includes('keep-a.md') && old.paths.includes('keep-b.md'),
181 old.paths.join(' ') || '(workspace emptied)');
182
183// The other half of the property: a COMPLETE census still propagates a real
184// deletion, or the guard has simply switched deletions off.
185const real = await applyAndList({ v: 2, chats: [], files: { 'keep-a.md': 'alpha' }, filesComplete: true });
186check('a complete census still deletes what the other device really deleted',
187 real.paths.includes('keep-a.md') && !real.paths.includes('keep-b.md'),
188 real.paths.join(' '));
189
190// ── 3. The backup carries the identity, and the string is honest ─────────
191
192await p.click('#user-row');
193await p.waitForTimeout(400);
194const dl = p.waitForEvent('download', { timeout: 15000 });
195await p.click('button.admin-item:has-text("Export a backup")');
196const file = scratch('dataloss-backup.json');
197await (await dl).saveAs(file);
198const backup = JSON.parse(fs.readFileSync(file, 'utf8'));
199await p.keyboard.press('Escape');
200await p.waitForTimeout(200);
201
202const id = backup.identity || null;
203check('the backup carries the identity', !!(id && id.priv && id.salt && id.pub),
204 id ? Object.keys(id).join(',') : 'no identity field');
205check('and carries it WRAPPED, exactly as it is at rest — no passphrase, no derived key',
206 !!id && !JSON.stringify(id).includes('testpass'),
207 'bundle is the localStorage values');
208const adoptable = await p.evaluate((b) => {
209 // Would a fresh browser be able to take this account over? Ask the importer,
210 // without writing anything: a bundle it rejects restores nothing.
211 return !!(b && b.v === 1 && b.salt && b.pub && b.priv);
212}, id);
213check('and in the shape importBundle accepts', adoptable);
214
215const said = await p.evaluate(() => ({
216 credits: window.DaimondI18n.t('forget.credits_body', { amount: 'a balance of $10.00' }),
217 kept: window.DaimondI18n.t('backup.identity_kept', { name: 'Someone' }),
218}));
219check('the Forget flow no longer promises a backup alone brings the money back',
220 !/no way to get it back without a backup/i.test(said.credits), said.credits.slice(0, 90) + '…');
221// THE PROPERTY. A user standing in front of this dialog must not walk away
222// believing the file alone reaches their money: the copy has to name the
223// passphrase, say the backup does not carry it, and say that losing it costs
224// the balance for good.
225//
226// This was `/passphrase/ && /lost/`, and it went red when the copy inflected
227// "Lose" instead of "lost". An inflection is not a property. Three clauses are
228// asked for because one is not enough -- a sentence can name the passphrase and
229// still promise the backup is sufficient, which is the exact harm.
230const backupNeedsPassphrase = (s) => {
231 const bits = s.split(/(?<=[.!?])\s+/);
232 const neg = /\b(not|no|never|nothing|only|without|alone|cannot)\b|n[’']t\b/i;
233 return {
234 names: /passphrase/i.test(s),
235 // Somewhere it says the backup by itself does not open the account.
236 short: bits.some(b => /backup|file|export/i.test(b) && /passphrase/i.test(b) && neg.test(b)),
237 // Somewhere it says that losing the passphrase is final, for the money.
238 final: bits.some(b => /\b(lose|loses|losing|lost|forget|forgets|forgotten|without|gone)\b/i.test(b)
239 && /\b(nothing|never|no way|cannot|unrecoverable|irrecoverable|for good|gone)\b/i.test(b)
240 && /\b(balance|credits?|money|funds?|it)\b/i.test(b)),
241 };
242};
243const cred = backupNeedsPassphrase(said.credits);
244check('it names the passphrase, says the backup does not carry it, and says losing it is final',
245 cred.names && cred.short && cred.final,
246 Object.entries(cred).filter(([, v]) => !v).map(([k]) => 'no ' + k).join(', '));
247check('and the import says plainly when it left an identity alone',
248 /left alone/i.test(said.kept) && /\{name\}/.test(said.kept) === false, said.kept.slice(0, 70) + '…');
249
250// ── 2a. A device that DID merge the index commits a live set ─────────────
251
252/// Stub the mailbox and count commits, then push once.
253const pushWithStubs = () => p.evaluate(async () => {
254 window.__commits = 0;
255 if (!window.DaimondChunks) window.DaimondChunks = {};
256 window.DaimondChunks.commit = async function () { window.__commits++; return { swept: 0 }; };
257 if (!window.DaimondCloud) window.DaimondCloud = {};
258 if (!window.DaimondCloud.tierPlan) window.DaimondCloud.tierPlan = function () { return null; };
259 if (!window.DaimondCloud.allowance) window.DaimondCloud.allowance = function () { return 0; };
260 window.DaimondGateway.state = function () { return { authed: true, credits: 0, pro: false }; };
261 if (!window.__realFetch) window.__realFetch = window.fetch;
262 window.__version = (window.__version || 0);
263 window.fetch = function (url, opts) {
264 if (String(url).indexOf('/api/sync') === 0 || String(url).indexOf('/api/sync') > -1) {
265 window.__version++;
266 return Promise.resolve(new Response(
267 JSON.stringify({ ok: true, version: window.__version }),
268 { status: 200, headers: { 'content-type': 'application/json' } }));
269 }
270 return window.__realFetch.apply(window, arguments);
271 };
272 await window.DaimondSync.push();
273 return { commits: window.__commits, mayCommit: window.DaimondCore.syncMayCommitChunks() };
274});
275
276const sandboxPush = await pushWithStubs();
277check('in the sandbox, where the chunk index IS merged, a push commits the live set',
278 sandboxPush.mayCommit === true && sandboxPush.commits === 1,
279 `mayCommit=${sandboxPush.mayCommit} commits=${sandboxPush.commits}`);
280
281// ── 1b. A census truncated by the byte budget is not a deletion ──────────
282
283// Eighty files of 120 KB: over the 8 MB inline budget, under the 128 KB
284// per-file ceiling, so they are skipped for BUDGET and not offloaded.
285const budget = await p.evaluate(async () => {
286 const root = await navigator.storage.getDirectory();
287 const dir = await root.getDirectoryHandle('bulk', { create: true });
288 const body = 'x'.repeat(120 * 1024);
289 for (let i = 0; i < 80; i++) {
290 const fh = await dir.getFileHandle('f' + i + '.txt', { create: true });
291 const w = await fh.createWritable();
292 await w.write(body);
293 await w.close();
294 }
295 const st = await window.DaimondCore.collectSync();
296 return { n: Object.keys(st.files || {}).length, complete: st.filesComplete };
297});
298check('a census truncated by the byte budget says it is INCOMPLETE',
299 budget.complete === false, `${budget.n} files carried, complete=${budget.complete}`);
300check('while still carrying the files it did read — truncation is not silence',
301 budget.n > 0, `${budget.n} files`);
302
303// ── 1c + 2b. Folder mode: the census that started all this ───────────────
304
305const folder = await p.evaluate(async () => {
306 // Stand a real folder up out of OPFS and hand it to the picker, which is the
307 // only door into folder mode. Permission is granted the way a user grants it.
308 const root = await navigator.storage.getDirectory();
309 const dir = await root.getDirectoryHandle('picked', { create: true });
310 dir.queryPermission = async () => 'granted';
311 dir.requestPermission = async () => 'granted';
312 window.showDirectoryPicker = async () => dir;
313 return true;
314});
315check('a folder is ready to be picked', folder === true);
316
317// Open the Workspace panel and press the Machine chip, which is where the mode
318// row puts "open a folder".
319await p.evaluate(() => window.DaimondPanels && window.DaimondPanels.open && window.DaimondPanels.open('work'));
320await p.waitForTimeout(600);
321await p.evaluate(() => {
322 const chips = [...document.querySelectorAll('.files-mode-chip')];
323 const machine = chips.find(c => /machine/.test(c.className) || c.querySelector('[data-icon="machine"]')) || chips[1];
324 if (machine) machine.click();
325});
326await p.waitForTimeout(1200);
327
328const mode = await p.evaluate(async () => {
329 const mod = await import('../pkg/oxedyne_daimond.js');
330 const st = await window.DaimondCore.collectSync();
331 return {
332 mode: mod.workspace_mode(),
333 files: Object.keys(st.files || {}).length,
334 complete: st.filesComplete,
335 };
336});
337check('the app is really in real-folder mode', mode.mode === 'folder', mode.mode);
338check('a device in folder mode sends no files AND says so — the parcel cannot delete',
339 mode.files === 0 && mode.complete === false,
340 `${mode.files} files, complete=${mode.complete}`);
341
342// And the same device must not declare the account's live chunk set.
343const folderPush = await pushWithStubs();
344check('a device that did not merge the chunk index does not commit one',
345 folderPush.mayCommit === false && folderPush.commits === 0,
346 `mayCommit=${folderPush.mayCommit} commits=${folderPush.commits}`);
347
348// Prove the parcel a folder-mode device sends is harmless on the far side, by
349// feeding it to the merge on a device that holds files.
350// Back to the sandbox the way a user goes back: the Browser chip in the mode row.
351await p.evaluate(() => {
352 const chips = [...document.querySelectorAll('.files-mode-chip')];
353 if (chips[0]) chips[0].click();
354});
355await p.waitForTimeout(900);
356const afterFolderParcel = await applyAndList({ v: 2, chats: [], files: {}, filesComplete: mode.complete });
357check('and merging that very parcel leaves the other device\'s workspace intact',
358 afterFolderParcel.paths.includes('keep-a.md'), afterFolderParcel.paths.slice(0, 3).join(' '));
359
360// ── 4. Clearing one conversation must not shorten another ───────────────
361//
362// THE PROPERTY, and it is written without reference to how a message is named:
363// CLEARING ONE CONVERSATION MUST NEVER REMOVE A MESSAGE FROM A DIFFERENT ONE.
364// Nothing below asserts an id, a shape, or the presence of any constant. The
365// shipped defect was in the ids, but the next one need not be, and a check
366// written against the repair would have gone green the day the repair moved.
367//
368// WHAT FOOLED THE LAST PERSON. Every check that watched this control watched the
369// CONVERSATION IT WAS PRESSED ON — the thread emptied, the store agreed, it
370// survived a reload — and all of that was true. The harm was in the conversations
371// nobody was looking at, and no test had two of them. So the fixture here is two
372// chats and the assertion is on the one that was never touched.
373//
374// The seeded transcripts carry NO `mid` FIELD AT ALL. That is the whole of what
375// "predates message-ids" means, and it is the only way to reach the stamping path
376// where the collision was: a chat whose messages already have ids never goes near
377// it, which is why a fixture built by sending real turns cannot see this at all.
378// (Seeded straight into the store rather than through `chat()`, which is how the
379// chat fixtures elsewhere in this suite are built. The workspace seeding at the
380// top of this file goes through `chat()` and cannot: a real turn mints real ids.)
381
382const KEEP_ID = 'dl-keep';
383const DAIMON_ID = 'dl-daimon';
384const KEEP = [
385 'the pump seal on the boat',
386 'Fit a new one before the season.',
387 'and the bilge switch',
388 'That is a separate part, and it is cheap.',
389];
390const DAIMON = [
391 'the mooring line has chafed through',
392 'Replace it this weekend.',
393];
394
395/// A Diamond to hang the daimon's conversation on. The rail's first will do — the
396/// app seeds two on a first boot — and one is made if the rail is empty.
397async function aDiamond() {
398 const ids = () => p.$$eval('.diamond-box', (els) => els.map((e) => e.dataset.id || '').filter(Boolean));
399 const have = await ids();
400 if (have.length) return have[0];
401 await p.click('#new-diamond-btn', { force: true });
402 await p.waitForSelector('.dlg-input', { timeout: 10000 });
403 await p.fill('.dlg-input', 'Dataloss');
404 await p.click('.dlg-ok', { force: true });
405 await p.waitForTimeout(1800);
406 return (await ids())[0] || '';
407}
408
409/// Two conversations written straight into the store, neither of them carrying a
410/// single message id. `updatedAt` is now, so nothing here is old enough for the
411/// expiry sweep to have an opinion about it.
412function record(id, diamondId, lines) {
413 const now = Date.now();
414 return {
415 id, name: '', diamondId,
416 messages: lines.map((content, i) => ({
417 role: i % 2 ? 'assistant' : 'user',
418 content,
419 ts: now - (lines.length - i) * 1000,
420 // NO `mid`. See above.
421 })),
422 model: 'mock/fast', provider: 'mock', status: 'active',
423 promptTokens: 0, completionTokens: 0, cachedTokens: 0, costUsd: 0,
424 prevPrompt: 0, prevCompletion: 0, prevCached: 0, prevCost: 0, lastPrompt: 0,
425 updatedAt: now,
426 };
427}
428
429const seedChats = (recs) => p.evaluate((rs) => new Promise((resolve, reject) => {
430 const req = indexedDB.open('daimond-chats', 1);
431 req.onupgradeneeded = () => {
432 const d = req.result;
433 if (!d.objectStoreNames.contains('chats')) d.createObjectStore('chats', { keyPath: 'id' });
434 };
435 req.onsuccess = () => {
436 const t = req.result.transaction('chats', 'readwrite');
437 const store = t.objectStore('chats');
438 rs.forEach((r) => store.put(r));
439 t.oncomplete = () => resolve(true);
440 t.onerror = () => reject(t.error);
441 };
442 req.onerror = () => reject(req.error);
443}), recs);
444
445/// What a stored conversation actually holds, as text, in order.
446const held = (all, id) => ((all.find((c) => c.id === id) || {}).messages || [])
447 .map((m) => String(m.content == null ? '' : m.content));
448
449/// The same conversation, word for word and in the same order.
450///
451/// NOT A COUNT. A count is green on a transcript that lost its opening message
452/// and gained another in its place, which is the shape a merge fails in — and it
453/// is the shape this defect had: the messages that went were the FIRST ones.
454const same = (a, b) => a.length === b.length && a.every((x, i) => x === b[i]);
455
456const dId = await aDiamond();
457check('there is a Diamond to hold a daimon\'s conversation', !!dId, dId || '(none on the rail)');
458await seedChats([record(DAIMON_ID, dId, DAIMON), record(KEEP_ID, '', KEEP)]);
459
460// Read back through the app, which is what makes these two PRE-MID chats and not
461// merely two rows: the boot is where a message without an id is given one.
462await p.reload({ waitUntil: 'domcontentloaded' });
463await signInAs(s, 'dataloss');
464await p.waitForSelector('.diamond-box', { timeout: 20000 });
465await p.waitForTimeout(1500);
466
467const before = await storedChats(s);
468check('4a. the fixture is two conversations, neither of which the other wrote',
469 held(before, DAIMON_ID).length === DAIMON.length && held(before, KEEP_ID).length === KEEP.length,
470 `daimon:${held(before, DAIMON_ID).length} other:${held(before, KEEP_ID).length}`);
471
472// THE REAL ROUTE, and it has to be: the harm is in what the control and the merge
473// do TOGETHER, so calling the clear directly would test half of it. This is the
474// Diamond's cog dialog and the "Fresh daimon" button inside it, pressed and
475// confirmed the way a reader presses and confirms.
476//
477// Tried more than once on purpose: the control is only mounted where the daimon
478// HAS a conversation, and the conversation is read out of the store at boot. A
479// single attempt would be a race with that read, and the way it would fail is a
480// green run on a clear that never happened.
481let pressed = false;
482for (let i = 0; i < 6 && !pressed; i++) {
483 await p.evaluate((id) => {
484 const box = document.querySelector('.diamond-box[data-id="' + id + '"]');
485 const cog = box && box.querySelector('.tile-cog');
486 if (cog) cog.click();
487 }, dId);
488 await p.waitForTimeout(900);
489 pressed = await p.evaluate(() => {
490 const b = [...document.querySelectorAll('.tile-dlg-clear')]
491 .find((e) => /fresh daimon/i.test(e.textContent || ''));
492 if (!b) return false;
493 b.click();
494 return true;
495 });
496 if (!pressed) {
497 await p.evaluate(() => {
498 const x = document.querySelector('.tile-dlg-card .tile-dlg-done');
499 if (x) x.click();
500 });
501 await p.waitForTimeout(800);
502 }
503}
504check('the daimon offers to start fresh, which is the control this is about', pressed);
505await p.waitForTimeout(800);
506// The confirm's OK is scoped AWAY from the tile dialog: the tile's own foot
507// carries a `.dlg-ok` that deletes the Diamond, and it sits earlier in the
508// document. An unscoped click there deletes the Diamond instead, which reads
509// exactly like a thread that cleared itself.
510const confirmed = await p.evaluate(() => {
511 const b = document.querySelector('.dlg-card:not(.tile-dlg-card) .dlg-ok');
512 if (!b) return false;
513 b.click();
514 return true;
515});
516check('and asks before it discards anything', confirmed);
517await p.waitForTimeout(1500);
518await p.evaluate(() => {
519 const x = document.querySelector('.tile-dlg-card .tile-dlg-done');
520 if (x) x.click();
521});
522await p.waitForTimeout(600);
523
524// The trigger is A RELOAD: the merge that does the damage is the save the clear
525// itself performs, and a reload is how the owner meets the result of it — he
526// comes back the next morning and reads a conversation he never touched.
527await p.reload({ waitUntil: 'domcontentloaded' });
528await signInAs(s, 'dataloss');
529await p.waitForSelector('.diamond-box', { timeout: 20000 });
530await p.waitForTimeout(1500);
531
532const after = await storedChats(s);
533const kept = held(after, KEEP_ID);
534check('4b. CLEARING ONE CONVERSATION LEAVES ANOTHER WHOLE — every message, by content (after a reload)',
535 same(kept, KEEP),
536 `${kept.length}/${KEEP.length} held, opens with "${(kept[0] || '(nothing)').slice(0, 40)}"`);
537check('4c. and the conversation that WAS cleared is still cleared',
538 held(after, DAIMON_ID).length === 0,
539 `${held(after, DAIMON_ID).length} messages, "${(held(after, DAIMON_ID)[0] || '').slice(0, 40)}"`);
540
541// The other half of the harm, and the half that reached the other devices: the
542// tombstones travel in the parcel, so a peer that still holds both transcripts
543// whole hands them back into the same merge. It must restore neither the
544// conversation that was cleared nor a hole in the one that was not.
545const peer = {
546 v: 2, files: {}, filesComplete: false,
547 chats: [
548 { ...record(DAIMON_ID, dId, DAIMON), updatedAt: Date.now() - 60000 },
549 { ...record(KEEP_ID, '', KEEP), updatedAt: Date.now() - 60000 },
550 ],
551};
552const merged = await p.evaluate(async (parcel) => (await window.DaimondCore.applySync(parcel)).failed, peer);
553await p.waitForTimeout(1200);
554const synced = await storedChats(s);
555const keptS = held(synced, KEEP_ID);
556check('4d. AND A SYNC MERGE FROM A PEER THAT HOLDS BOTH WHOLE DOES NOT SHORTEN THE OTHER ONE',
557 same(keptS, KEEP),
558 `${keptS.length}/${KEEP.length} held, sections that failed: ${merged.join(',') || 'none'}`);
559check('4e. while the cleared conversation stays cleared THROUGH that same merge',
560 held(synced, DAIMON_ID).length === 0,
561 `${held(synced, DAIMON_ID).length} messages back from the peer`);
562
563const errs = errors(s).filter(e => !/502|Bad Gateway|api\/sync/.test(e));
564check('nothing threw', errs.length === 0, errs.slice(0, 2).join(' | '));
565
566await s.close();
567console.log(`\n${ok.length} passed, ${bad.length} failed`);
568if (bad.length) { bad.forEach(b => console.log(' FAILED: ' + b)); process.exit(1); }