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 |
| 39 | import fs from 'node:fs'; |
| 40 | import path from 'node:path'; |
| 41 | import { fileURLToPath } from 'node:url'; |
| 42 | import { open, errors, scratch, signInAs, storedChats } from './harness.mjs'; |
| 43 | |
| 44 | const ok = [], bad = []; |
| 45 | const check = (name, pass, detail) => { |
| 46 | (pass ? ok : bad).push(name + (detail ? ' — ' + detail : '')); |
| 47 | console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : '')); |
| 48 | }; |
| 49 | |
| 50 | const HERE = path.dirname(fileURLToPath(import.meta.url)); |
| 51 | const WWW = path.join(HERE, '..', 'www'); |
| 52 | const SRC = 'js/daimond.js'; |
| 53 | |
| 54 | const 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. |
| 64 | const 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. |
| 69 | const UNSCOPED = "\t\t(msgs || []).forEach(function (m, i) {\n" |
| 70 | + "\t\t\tif (!m.mid) m.mid = 'legacy-' + ('0000' + i).slice(-4);"; |
| 71 | const DROP_OLD = "\t\t\tif (OLD_LEGACY.test(id)) return;\n"; |
| 72 | |
| 73 | const 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 | |
| 93 | if (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. |
| 100 | function 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. |
| 112 | async 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 | |
| 128 | const s = await open({ name: 'dataloss', route: BREAK ? breakInto : null }); |
| 129 | const p = s.page; |
| 130 | if (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. |
| 145 | await 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. |
| 152 | const 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. |
| 158 | const 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 | |
| 164 | const seeded = await census(); |
| 165 | check('the sandbox census sees both seeded files', |
| 166 | seeded.paths.includes('keep-a.md') && seeded.paths.includes('keep-b.md'), seeded.paths.join(' ')); |
| 167 | check('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. |
| 171 | await p.evaluate(() => window.DaimondCore.syncCommitBaseline()); |
| 172 | |
| 173 | const empty = await applyAndList({ v: 2, chats: [], files: {}, filesComplete: false }); |
| 174 | check('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 | |
| 178 | const old = await applyAndList({ v: 2, chats: [], files: {} }); |
| 179 | check('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. |
| 185 | const real = await applyAndList({ v: 2, chats: [], files: { 'keep-a.md': 'alpha' }, filesComplete: true }); |
| 186 | check('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 | |
| 192 | await p.click('#user-row'); |
| 193 | await p.waitForTimeout(400); |
| 194 | const dl = p.waitForEvent('download', { timeout: 15000 }); |
| 195 | await p.click('button.admin-item:has-text("Export a backup")'); |
| 196 | const file = scratch('dataloss-backup.json'); |
| 197 | await (await dl).saveAs(file); |
| 198 | const backup = JSON.parse(fs.readFileSync(file, 'utf8')); |
| 199 | await p.keyboard.press('Escape'); |
| 200 | await p.waitForTimeout(200); |
| 201 | |
| 202 | const id = backup.identity || null; |
| 203 | check('the backup carries the identity', !!(id && id.priv && id.salt && id.pub), |
| 204 | id ? Object.keys(id).join(',') : 'no identity field'); |
| 205 | check('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'); |
| 208 | const 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); |
| 213 | check('and in the shape importBundle accepts', adoptable); |
| 214 | |
| 215 | const 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 | })); |
| 219 | check('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. |
| 230 | const 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 | }; |
| 243 | const cred = backupNeedsPassphrase(said.credits); |
| 244 | check('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(', ')); |
| 247 | check('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. |
| 253 | const 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 | |
| 276 | const sandboxPush = await pushWithStubs(); |
| 277 | check('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. |
| 285 | const 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 | }); |
| 298 | check('a census truncated by the byte budget says it is INCOMPLETE', |
| 299 | budget.complete === false, `${budget.n} files carried, complete=${budget.complete}`); |
| 300 | check('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 | |
| 305 | const 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 | }); |
| 315 | check('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". |
| 319 | await p.evaluate(() => window.DaimondPanels && window.DaimondPanels.open && window.DaimondPanels.open('work')); |
| 320 | await p.waitForTimeout(600); |
| 321 | await 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 | }); |
| 326 | await p.waitForTimeout(1200); |
| 327 | |
| 328 | const 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 | }); |
| 337 | check('the app is really in real-folder mode', mode.mode === 'folder', mode.mode); |
| 338 | check('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. |
| 343 | const folderPush = await pushWithStubs(); |
| 344 | check('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. |
| 351 | await p.evaluate(() => { |
| 352 | const chips = [...document.querySelectorAll('.files-mode-chip')]; |
| 353 | if (chips[0]) chips[0].click(); |
| 354 | }); |
| 355 | await p.waitForTimeout(900); |
| 356 | const afterFolderParcel = await applyAndList({ v: 2, chats: [], files: {}, filesComplete: mode.complete }); |
| 357 | check('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 | |
| 382 | const KEEP_ID = 'dl-keep'; |
| 383 | const DAIMON_ID = 'dl-daimon'; |
| 384 | const 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 | ]; |
| 390 | const 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. |
| 397 | async 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. |
| 412 | function 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 | |
| 429 | const 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. |
| 446 | const 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. |
| 454 | const same = (a, b) => a.length === b.length && a.every((x, i) => x === b[i]); |
| 455 | |
| 456 | const dId = await aDiamond(); |
| 457 | check('there is a Diamond to hold a daimon\'s conversation', !!dId, dId || '(none on the rail)'); |
| 458 | await 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. |
| 462 | await p.reload({ waitUntil: 'domcontentloaded' }); |
| 463 | await signInAs(s, 'dataloss'); |
| 464 | await p.waitForSelector('.diamond-box', { timeout: 20000 }); |
| 465 | await p.waitForTimeout(1500); |
| 466 | |
| 467 | const before = await storedChats(s); |
| 468 | check('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. |
| 481 | let pressed = false; |
| 482 | for (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 | } |
| 504 | check('the daimon offers to start fresh, which is the control this is about', pressed); |
| 505 | await 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. |
| 510 | const 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 | }); |
| 516 | check('and asks before it discards anything', confirmed); |
| 517 | await p.waitForTimeout(1500); |
| 518 | await p.evaluate(() => { |
| 519 | const x = document.querySelector('.tile-dlg-card .tile-dlg-done'); |
| 520 | if (x) x.click(); |
| 521 | }); |
| 522 | await 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. |
| 527 | await p.reload({ waitUntil: 'domcontentloaded' }); |
| 528 | await signInAs(s, 'dataloss'); |
| 529 | await p.waitForSelector('.diamond-box', { timeout: 20000 }); |
| 530 | await p.waitForTimeout(1500); |
| 531 | |
| 532 | const after = await storedChats(s); |
| 533 | const kept = held(after, KEEP_ID); |
| 534 | check('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)}"`); |
| 537 | check('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. |
| 545 | const 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 | }; |
| 552 | const merged = await p.evaluate(async (parcel) => (await window.DaimondCore.applySync(parcel)).failed, peer); |
| 553 | await p.waitForTimeout(1200); |
| 554 | const synced = await storedChats(s); |
| 555 | const keptS = held(synced, KEEP_ID); |
| 556 | check('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'}`); |
| 559 | check('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 | |
| 563 | const errs = errors(s).filter(e => !/502|Bad Gateway|api\/sync/.test(e)); |
| 564 | check('nothing threw', errs.length === 0, errs.slice(0, 2).join(' | ')); |
| 565 | |
| 566 | await s.close(); |
| 567 | console.log(`\n${ok.length} passed, ${bad.length} failed`); |
| 568 | if (bad.length) { bad.forEach(b => console.log(' FAILED: ' + b)); process.exit(1); } |