Oregami
Repositories/oxedyne/daimond

oxedyne/daimond/dev/verify_chatid.mjs

16.7 KiB, 1 run

created by r2519314175:275, 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_chatid.mjs — a new chat is not born in the trash.
2//
3// WHY THIS FILE EXISTS. Build 664d9f47bd1d could not start an ordinary chat on any
4// device where a chat had ever been deleted. Press "New chat", get a sensible name,
5// press Start, and about a second later the tile went. The two features were fine
6// on their own and lethal together:
7//
8// * chat ids were `'c' + seq`, and `seq` was seeded at boot from the chats
9// `loadChats` returned — a list that deliberately LEAVES OUT everything in the
10// trash and everything tombstoned;
11// * the trash (seq 111) keys by id, and `trashed(id)` is asked wherever a list is
12// BUILT — the rail, the finders, the parcel, the backup.
13//
14// So a device whose last chat had been deleted counted from wherever the visible
15// list stopped and minted an id the trash still owned. The tile drew, and the next
16// reconciliation — a sync round, a trash redraw, another tab — filtered it straight
17// out again. Worse, and invisible: the store is keyed by id, so the new chat had
18// already OVERWRITTEN the deleted chat's record. The undo the trash exists to offer
19// was destroyed by the act that broke the new chat.
20//
21// The properties below are therefore not "ids are unique". Each is a way the app
22// could still be wrong while `newChat` looks perfectly correct:
23//
24// 1. A CHAT MADE AFTER A RELOAD IS NOT CLAIMED BY THE TRASH. Asked of the trash
25// record itself, not of the screen: a tile that has not been filtered YET
26// looks exactly like a tile that never will be.
27//
28// 2. AND IT DID NOT OVERWRITE WHAT WAS IN THE TRASH. Asserted on the STORED
29// record — its id and its name — because a rail that looks right is served
30// by a store that has quietly lost a conversation.
31//
32// 3. AND IT SURVIVES A SYNC ROUND AND A RE-RENDER. This is the user's symptom,
33// driven through `DaimondSync.parcel()`/`apply()` — the two functions the wire
34// uses — because that is what took the tile away a second after Start.
35//
36// 4. THE SAME AFTER A PERMANENT DELETE. Destroying for good leaves a TOMBSTONE,
37// which is a second id space that outlives the record, and `storedChats`
38// omits it. A counter reseeded from the stored list rather than the visible
39// one — the obvious near-fix — passes 1-3 and fails here.
40//
41// EACH CHECK IS PROVED AGAINST BROKEN CODE FIRST. `--break <name>` serves a
42// deliberately damaged copy of a source file to the real page (through
43// `page.route`, so the browser loads it as it loads any other script) and the run
44// is expected to FAIL. A break whose anchor does not appear exactly once aborts
45// rather than passing quietly: a check proved against code that was never broken
46// is not proved at all.
47//
48// node dev/verify_chatid.mjs --break counter # the shipped bug, restored verbatim
49// node dev/verify_chatid.mjs --break seeded # the near-fix: seed from the STORED list
50// node dev/verify_chatid.mjs # and then, clean
51//
52// eval "$(bash dev/world.sh 5 --up)"
53// node dev/verify_chatid.mjs
54//
55// Needs dev/serve.mjs and the mock only. No gateway.
56import fs from 'node:fs';
57import path from 'node:path';
58import { fileURLToPath } from 'node:url';
59import { open, shot, scratch, signInAs } from './harness.mjs';
60
61const HERE = path.dirname(fileURLToPath(import.meta.url));
62const WWW = path.join(HERE, '..', 'www');
63
64const BREAK = (() => {
65 const i = process.argv.indexOf('--break');
66 return i > 0 ? String(process.argv[i + 1] || '') : '';
67})();
68
69const ok = [], bad = [];
70const check = (name, pass, detail) => {
71 (pass ? ok : bad).push(name);
72 console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : ''));
73};
74
75// ── The breaks ───────────────────────────────────────────────────────
76const NEW_ID = '\tfunction newChatId() {\n\t\treturn \'c\' + newMid();\n\t}';
77const NO_SEED = '\t\t// NOTHING IS SEEDED FROM THIS LIST. A counter used to be, and `loadChats`\n'
78 + '\t\t// omits every chat in the trash — which is how a new chat came back with an\n'
79 + '\t\t// id the trash still owned. See `newChatId`.';
80
81const BREAKS = {
82 // The bug as it shipped in 664d9f47bd1d, restored word for word: a counter
83 // seeded from the chats that are ON THE RAIL.
84 counter: [
85 { file: 'js/daimond.js', find: NEW_ID,
86 with: '\tvar seq = 1;\n\tfunction newChatId() {\n\t\treturn \'c\' + (seq++);\n\t}' },
87 { file: 'js/daimond.js', find: NO_SEED,
88 with: '\t\tchats.forEach(function (c) { var n = parseInt((c.id || \'\').replace(/^c/, \'\'), 10);'
89 + ' if (n >= seq) seq = n + 1; });' },
90 ],
91 // The near-fix, and the reason a counter was rejected rather than reseeded:
92 // `storedChats` DOES include the trashed, so 1-3 go green — and it does not
93 // include the tombstoned, so a permanent delete hands the id straight back.
94 seeded: [
95 { file: 'js/daimond.js', find: NEW_ID,
96 with: '\tvar seq = 1;\n'
97 + '\tfunction newChatId() {\n'
98 + '\t\ttry {\n'
99 + '\t\t\tstoredChats().forEach(function (c) {\n'
100 + '\t\t\t\tvar n = parseInt((c.id || \'\').replace(/^c/, \'\'), 10);\n'
101 + '\t\t\t\tif (n >= seq) seq = n + 1;\n'
102 + '\t\t\t});\n'
103 + '\t\t} catch (e) { /* no store yet */ }\n'
104 + '\t\treturn \'c\' + (seq++);\n'
105 + '\t}' },
106 ],
107};
108
109if (BREAK && !BREAKS[BREAK]) {
110 console.error(`unknown break '${BREAK}'; one of: ${Object.keys(BREAKS).join(', ')}`);
111 process.exit(2);
112}
113
114/// The damaged source, or a hard stop. Nothing is served that was not verified to
115/// differ from the file on disk.
116function damaged(spec) {
117 const src = fs.readFileSync(path.join(WWW, spec.file), 'utf8');
118 const n = src.split(spec.find).length - 1;
119 if (n !== 1) {
120 console.error(`break '${BREAK}': the anchor appears ${n} times in ${spec.file}, `
121 + 'so nothing was broken and the run below would prove nothing.');
122 process.exit(2);
123 }
124 return src.replace(spec.find, spec.with);
125}
126
127async function breakInto(page) {
128 if (!BREAK) return;
129 const bodies = {};
130 for (const spec of BREAKS[BREAK]) {
131 bodies[spec.file] = bodies[spec.file] || fs.readFileSync(path.join(WWW, spec.file), 'utf8');
132 // Each spec is checked against the file ON DISK, so two edits to one file
133 // cannot mask each other's anchor.
134 damaged(spec);
135 bodies[spec.file] = bodies[spec.file].replace(spec.find, spec.with);
136 }
137 for (const file of Object.keys(bodies)) {
138 await page.route('**/' + file, (r) => r.fulfill({
139 status: 200, contentType: 'application/javascript', body: bodies[file],
140 }));
141 }
142}
143
144// ── Reading the truth, not the screen ────────────────────────────────
145
146/// The chat tile IDS on the rail, top to bottom.
147///
148/// It was the tile NAMES, which worked while every chat was born as
149/// `Chat-0025`. Chats carry no name now — the rail derives a relative time — so
150/// names could no longer tell two tiles apart, and half this file's lookups
151/// silently matched nothing. The id is the right handle here in any case: this
152/// whole file is about whether a NEW chat's id collides with one the trash or a
153/// tombstone still claims, and every claim it checks is keyed by id.
154const railChats = (page) => page.$$eval('#session-list .session-box',
155 (els) => els.map((e) => e.dataset.id || ''));
156
157/// Every chat the STORE holds, unfiltered — id and name. This is where the
158/// overwrite shows: the rail cannot report a record that was replaced under it.
159const storedChats = (page) => page.evaluate(() => new Promise((res) => {
160 const req = indexedDB.open('daimond-chats', 1);
161 req.onsuccess = () => {
162 const all = req.result.transaction('chats', 'readonly').objectStore('chats').getAll();
163 all.onsuccess = () => res((all.result || []).map((c) => ({ id: c.id, name: c.name })));
164 all.onerror = () => res([]);
165 };
166 req.onerror = () => res([]);
167}));
168
169/// Every id that can still claim a chat: the trash record and the tombstones.
170///
171/// Read by key SUFFIX rather than by exact name, because a second account
172/// namespaces its storage and a verifier that hardcoded the primary account's
173/// key would report an empty trash on a run that had one.
174const claimedIds = (page) => page.evaluate(() => {
175 const grab = (suffix) => {
176 for (let i = 0; i < localStorage.length; i++) {
177 const k = localStorage.key(i);
178 if (k === suffix || k.endsWith(':' + suffix) || k.endsWith(suffix)) {
179 try { return JSON.parse(localStorage.getItem(k) || 'null'); } catch (e) { return null; }
180 }
181 }
182 return null;
183 };
184 const trash = grab('daimond-trash');
185 const tombs = grab('daimond-chats-deleted');
186 return {
187 trashed: Object.keys((trash && trash.items) || {}),
188 tombed: Object.keys(tombs || {}),
189 };
190});
191
192/// One sync round against this device's own parcel: collect what it would send
193/// and apply it back. `applyChats` runs, which reconciles the rail against the
194/// stores — the exact path that took the user's tile away a second after Start.
195const syncRound = async (page) => {
196 await page.evaluate(async () => {
197 const p = await window.DaimondSync.parcel();
198 await window.DaimondSync.apply(p);
199 });
200 await page.waitForTimeout(2000);
201};
202
203/// A genuine reload: the counter's whole failure was that it restarted, so a test
204/// that never restarts the page cannot see it. Sign-in does not survive the
205/// reload, so it is done again — the same thing a returning user does.
206const restart = async (s) => {
207 await s.page.reload({ waitUntil: 'domcontentloaded' });
208 await signInAs(s, 'chatid');
209 await s.page.waitForTimeout(1800);
210};
211
212/// Press "New chat" and then Start, unconditionally.
213///
214/// NOT the harness's `newChat`, which returns early when a composer is already on
215/// screen. After the second reload a chat is restored AND selected, so the
216/// composer is up and the harness would quietly make no chat at all — and a
217/// section that creates nothing passes every question asked about what it created.
218const makeChat = async (page) => {
219 const close = page.locator('#admin-close');
220 if (await close.isVisible().catch(() => false)) {
221 await close.click({ force: true });
222 await page.waitForTimeout(200);
223 }
224 await page.click('#new-session-btn', { force: true });
225 await page.waitForTimeout(600);
226 const start = page.locator('.tile-start').first();
227 if (await start.count()) await start.click({ force: true });
228 await page.waitForTimeout(700);
229};
230
231/// Delete one chat the way a user does: the tile's own ✕, no dialog.
232///
233/// BY ID, not by the name on the tile. Chats carry no name now — the rail
234/// derives a relative time — so a lookup by displayed text matched nothing, the
235/// ✕ was never clicked, and this file reported that deleting a chat failed to
236/// trash it. Which is also the right identifier for this file: it is about ids,
237/// and the trash is keyed by one.
238const deleteChat = async (page, id) => {
239 const clicked = await page.evaluate((want) => {
240 const box = document.querySelector('#session-list .session-box[data-id="' + want + '"]');
241 const x = box && box.querySelector('.tile-x');
242 if (!x) return false;
243 x.click();
244 return true;
245 }, id);
246 await page.waitForTimeout(900);
247 return clicked;
248};
249
250// ── The run ──────────────────────────────────────────────────────────
251const PROFILE = scratch('pw', 'chatid' + (BREAK ? '-' + BREAK : ''));
252fs.rmSync(PROFILE, { recursive: true, force: true });
253
254const s = await open({ name: 'chatid', profile: PROFILE, defaults: false, route: breakInto });
255const P = s.page;
256
257try {
258 // ── The fixture: one chat, deleted ──────────────────────────────
259 await makeChat(P);
260 const first = await storedChats(P);
261 check('a chat was made and stored', first.length === 1, JSON.stringify(first));
262 const doomedId = first.length === 1 ? first[0].id : null;
263 const doomedName = first.length === 1 ? first[0].name : null;
264
265 const pressed = await deleteChat(P, doomedId);
266 check('the doomed chat\'s ✕ was actually pressed', pressed === true,
267 'no tile with that id, so nothing below would mean anything');
268 const afterDelete = await claimedIds(P);
269 check('and deleting it put its id in the trash',
270 afterDelete.trashed.includes(doomedId),
271 `${JSON.stringify(afterDelete.trashed)} vs ${doomedId}`);
272 if (!doomedId || !afterDelete.trashed.includes(doomedId)) {
273 console.log('\nthe fixture never reached the state under test — refusing to report a vacuous pass.');
274 await s.close();
275 process.exit(1);
276 }
277
278 // ── 1-3. A chat made after a reload ─────────────────────────────
279 await restart(s);
280 check('the deleted chat is off the rail after the reload',
281 (await railChats(P)).length === 0, (await railChats(P)).join(', '));
282
283 await makeChat(P);
284 const railNow = await railChats(P);
285 const madeId = railNow[0] || null;
286 const stored2 = await storedChats(P);
287 const madeRec = stored2.find((c) => c.id === madeId);
288 const claimed = await claimedIds(P);
289
290 check('a new chat drew a tile at all', !!madeId, railNow.join(', ') || 'empty');
291 check('1. THE NEW CHAT\'S ID IS NOT ONE THE TRASH STILL CLAIMS',
292 !!madeRec && !claimed.trashed.includes(madeRec.id) && !claimed.tombed.includes(madeRec.id),
293 `${madeRec ? madeRec.id : '(no record)'} vs trashed ${JSON.stringify(claimed.trashed)}`);
294 // Under its own id AND with its name unchanged — which is now the empty
295 // string a chat is born with, so what is asserted is that trashing did not
296 // quietly rewrite the record, whatever the name happens to be.
297 check('2. AND THE TRASHED CHAT\'S RECORD IS STILL IN THE STORE, UNCHANGED',
298 stored2.some((c) => c.id === doomedId && c.name === doomedName),
299 JSON.stringify(stored2));
300
301 await syncRound(P);
302 const railAfterSync = await railChats(P);
303 check('3. AND THE CHAT IS STILL THERE AFTER A SYNC ROUND AND A RE-RENDER',
304 railAfterSync.includes(madeId), railAfterSync.join(', ') || 'empty');
305 await shot(s, 'chatid-after-sync' + (BREAK ? '-' + BREAK : ''));
306
307 // ── 4. The same after a permanent delete ────────────────────────
308 // Destroying for good leaves a TOMBSTONE, which is a second id space, and one
309 // that `storedChats` does not show either — so it is where a counter reseeded
310 // from the STORED list (the obvious near-fix, and the `seeded` break) still
311 // collides.
312 //
313 // THE CHAT DESTROYED HERE IS THE NEWEST ONE, deliberately. Destroying an old
314 // one leaves a newer record behind for a counter to count past, so the near-fix
315 // would survive it and this section would prove nothing about it. The id that
316 // has to be tombstoned is the HIGHEST, because that is the one a counter
317 // reseeded from what is left would mint next.
318 await makeChat(P);
319 await P.waitForTimeout(400);
320 const storedV = await storedChats(P);
321 // BY ID, not by name: chats have no names now, so "the one that is not the
322 // two I already know about" has to be asked of the ids. It is the same
323 // question — the newest record — asked of the field that still answers it.
324 const knownIds = [doomedId, madeId].filter(Boolean);
325 const victim = storedV.find((c) => knownIds.indexOf(c.id) === -1) || null;
326 check('a second chat was made, and it is the newest record',
327 !!victim, JSON.stringify(storedV));
328
329 if (victim) {
330 await deleteChat(P, victim.id);
331 const purged = await P.evaluate((id) => window.DaimondCore.trashPurge(id), victim.id);
332 await P.waitForTimeout(900);
333 const afterPurge = await claimedIds(P);
334 check('destroying it for good leaves a TOMBSTONE for its id',
335 purged !== false && afterPurge.tombed.includes(victim.id),
336 JSON.stringify(afterPurge.tombed));
337 }
338
339 await restart(s);
340 await makeChat(P);
341 const rail3 = await railChats(P);
342 const thirdId = rail3.find((i) => i !== madeId && i !== (victim && victim.id)) || null;
343 const stored3 = await storedChats(P);
344 const thirdRec = stored3.find((c) => c.id === thirdId);
345 const claimed3 = await claimedIds(P);
346 check('4. A CHAT MADE AFTER A PERMANENT DELETE IS NOT TOMBSTONED EITHER',
347 !!thirdRec && !claimed3.tombed.includes(thirdRec.id) && !claimed3.trashed.includes(thirdRec.id),
348 `${thirdRec ? thirdRec.id : '(no record)'} vs tombed ${JSON.stringify(claimed3.tombed)}`);
349
350 await syncRound(P);
351 const rail4 = await railChats(P);
352 check(' and it too survives a sync round',
353 !!thirdId && rail4.includes(thirdId), rail4.join(', ') || 'empty');
354
355 // The ids the app minted, for the record.
356 console.log('\n ids minted: ' + JSON.stringify((await storedChats(P)).map((c) => c.id)));
357} finally {
358 await s.close();
359}
360
361console.log(`\n${ok.length} ok, ${bad.length} failed`);
362if (BREAK) {
363 console.log(bad.length
364 ? `break '${BREAK}' was CAUGHT, which is what this run had to prove.`
365 : `break '${BREAK}' PASSED — the checks do not discriminate and prove nothing.`);
366 process.exit(bad.length ? 0 : 1);
367}
368process.exit(bad.length ? 1 : 0);