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. |
| 80 | import fs from 'node:fs'; |
| 81 | import path from 'node:path'; |
| 82 | import { fileURLToPath } from 'node:url'; |
| 83 | import { open, shot, scratch, errors, signInAs, transcript } from './harness.mjs'; |
| 84 | |
| 85 | const HERE = path.dirname(fileURLToPath(import.meta.url)); |
| 86 | const WWW = path.join(HERE, '..', 'www'); |
| 87 | |
| 88 | const BREAK = (() => { |
| 89 | const i = process.argv.indexOf('--break'); |
| 90 | return i > 0 ? String(process.argv[i + 1] || '') : ''; |
| 91 | })(); |
| 92 | |
| 93 | const ok = [], bad = []; |
| 94 | const 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. |
| 101 | const 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 | |
| 234 | if (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. |
| 241 | function 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. |
| 258 | function 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 | |
| 268 | async 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". |
| 281 | const 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 | |
| 286 | const 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 | |
| 298 | function 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`. |
| 321 | const 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. |
| 324 | const 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. |
| 332 | const 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. |
| 345 | const 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. |
| 358 | const 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 | }); |
| 363 | const 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. |
| 373 | const 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 | |
| 386 | const 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 | |
| 394 | const 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. |
| 399 | const redraw = (page) => page.evaluate(() => { |
| 400 | try { window.DaimondTrashPanel.render(); } catch (e) { /* broken on purpose */ } |
| 401 | }); |
| 402 | |
| 403 | // ── Device A ───────────────────────────────────────────────────────── |
| 404 | const PROFILE_A = scratch('pw', 'trash-a' + (BREAK ? '-' + BREAK : '')); |
| 405 | const PROFILE_B = scratch('pw', 'trash-b' + (BREAK ? '-' + BREAK : '')); |
| 406 | fs.rmSync(PROFILE_A, { recursive: true, force: true }); |
| 407 | fs.rmSync(PROFILE_B, { recursive: true, force: true }); |
| 408 | |
| 409 | const a = await open({ name: 'trash', profile: PROFILE_A, defaults: false }); |
| 410 | await breakInto(a.page); |
| 411 | await a.page.reload({ waitUntil: 'domcontentloaded' }); |
| 412 | await signInAs(a, 'trash'); |
| 413 | await a.page.waitForTimeout(600); |
| 414 | |
| 415 | let b = null; // the second device, opened only for the sync section |
| 416 | |
| 417 | try { |
| 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 | |
| 885 | if (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 | } |
| 890 | console.log(bad.length === 0 ? '\nall checks passed' : `\n${bad.length} check(s) FAILED`); |
| 891 | process.exit(bad.length === 0 ? 0 : 1); |