Oregami
Repositories/oxedyne/daimond

oxedyne/daimond/dev/verify_chattiles.mjs

20.6 KiB, 1 run

created by r2519314175:285, 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_chattiles.mjs — notes4 "= Chats", items 1 and 2, plus the ordering fix.
2//
3// Three properties, each traced to a real complaint in notes4:
4//
5// 1. THE CLOSER CAME BACK. A chat tile carries a × again (a Diamond tile does
6// not — verify_tiledlg covers that half). SINCE THE TRASH, IT ASKS NOTHING:
7// the chat goes to the trash, so the press takes nothing away and a dialog
8// in front of it would only teach people to click through dialogs. What is
9// asserted is therefore both halves — no dialog, AND the chat is in the
10// trash afterwards, which is what makes the silence safe.
11//
12// 2. DELETE ALL CHATS lives behind the Chats section's own overflow (the ⋯ /
13// `#chats-menu-btn`), never as a second cross beside "+". It too asks
14// nothing and puts every chat in the trash. THIS IS THE BUTTON THE TRASH
15// WAS BUILT FOR: it shipped with a dialog naming the count and no way back,
16// and somebody pressed it expecting an undo. The count in the question was
17// never the protection; the protection is that the chats are still there.
18// The confirm that names a count now lives on "Empty trash", where it is
19// true — see dev/verify_trash.mjs.
20//
21// 3. ORDERING. Tiles list newest-touched first. Before this file's fix,
22// `renderSessionList` drew `chats` in plain array order, which was never a
23// sort: `newChat` unshifts a fresh chat to the front, but a reload reads
24// IndexedDB with a bare `openCursor()`, walking the store ascending BY ID
25// STRING — lexicographic, not numeric, so a tenth chat ('c10') sorted
26// before a second ('c2'). The fix sorts explicitly on `updatedAt`, which
27// is proved here by seeding chats whose ids and creation order say one
28// thing and whose `updatedAt` says another, so a check that only reads
29// row order or id order cannot pass by accident.
30//
31// EACH CHECK IS PROVED AGAINST BROKEN CODE FIRST. `--break <name>` serves a
32// deliberately damaged copy of js/daimond.js to the real page (through
33// `page.route`) and the run is expected to FAIL. A break that does not apply
34// cleanly aborts rather than passing quietly.
35//
36// node dev/verify_chattiles.mjs --break nocross # 1: no × on a chat tile
37// node dev/verify_chattiles.mjs --break notrash # 1: × destroys instead of trashing
38// node dev/verify_chattiles.mjs --break nomenu # 2: the overflow does nothing
39// node dev/verify_chattiles.mjs --break bulkasks # 2: "Delete all" puts a dialog back
40// node dev/verify_chattiles.mjs --break noorder # 3: tiles are not sorted
41// node dev/verify_chattiles.mjs # and then, clean
42//
43// eval "$(bash dev/world.sh 2 --up)"
44// eval "$(bash dev/world.sh 2 --env)"
45// node dev/verify_chattiles.mjs
46//
47// Needs dev/serve.mjs only. Chats are seeded straight into IndexedDB (the same
48// store `daimond-chats` the app itself reads on boot) rather than clicked into
49// existence one at a time, so `updatedAt` and `id` can be set to exactly the
50// values that expose the ordering bug, and a run stays fast with a dozen tiles.
51import fs from 'node:fs';
52import path from 'node:path';
53import { fileURLToPath } from 'node:url';
54import { open, shot, scratch, errors } from './harness.mjs';
55
56const HERE = path.dirname(fileURLToPath(import.meta.url));
57const WWW = path.join(HERE, '..', 'www');
58
59const BREAK = (() => {
60 const i = process.argv.indexOf('--break');
61 return i > 0 ? String(process.argv[i + 1] || '') : '';
62})();
63
64const PROFILE = scratch('pw', 'chattiles' + (BREAK ? '-' + BREAK : ''));
65fs.rmSync(PROFILE, { recursive: true, force: true });
66
67const ok = [], bad = [];
68const check = (name, pass, detail) => {
69 (pass ? ok : bad).push(name);
70 console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : ''));
71};
72
73// ── The breaks ───────────────────────────────────────────────────────
74const BREAKS = {
75 // The × never gets onto the tile at all.
76 nocross: [{
77 file: 'js/daimond.js',
78 find: '\t\theader.appendChild(tileCloser(chatDisplayName(s), function () { deleteChat(s); }));',
79 with: '',
80 }],
81 // The × is there and the tile goes, but the chat is DESTROYED rather than
82 // trashed — the shape the app had when somebody lost fourteen chats. The
83 // "no dialog" half of check 1 still passes under this break, which is the
84 // whole reason the trash half is asked as well.
85 notrash: [{
86 file: 'js/daimond.js',
87 find: '\t\ttry { DaimondTrash.put(chat.id, \'chat\'); }',
88 with: '\t\ttry { throw new Error(\'broken on purpose\'); }',
89 }],
90 // The overflow button is in the markup but wired to nothing.
91 nomenu: [{
92 file: 'js/daimond.js',
93 find: 'openChatsMenu(chatsMenuBtn);',
94 with: '',
95 }],
96 // "Delete all chats" asks again. A dialog in front of a reversible act is
97 // the habit this change removed, and a break that puts one back must fail
98 // the check that says it is gone.
99 bulkasks: [{
100 file: 'js/daimond.js',
101 find: '\t\tloose.forEach(function (c) { removeChat(c); saidMoved(chatDisplayName(c)); });',
102 with: '\t\tconfirmDialog(\'Delete all \' + n + \' chats?\', \'Delete\').then(function (ok) {\n'
103 + '\t\t\tif (!ok) return;\n'
104 + '\t\t\tloose.forEach(function (c) { removeChat(c); });\n'
105 + '\t\t});',
106 }],
107 // The tile list draws in whatever order `chats` happens to hold, which is
108 // what "the ordering is weird" was about.
109 noorder: [{
110 file: 'js/daimond.js',
111 find: '\t\tloose.sort(function (a, b) { return (b.updatedAt || 0) - (a.updatedAt || 0); });',
112 with: '',
113 }],
114};
115
116if (BREAK && !BREAKS[BREAK]) {
117 console.error(`unknown break '${BREAK}'; one of: ${Object.keys(BREAKS).join(', ')}`);
118 process.exit(2);
119}
120
121/// `src` with `spec` applied, or a hard stop. Nothing is served that was not
122/// verified to differ from what it was given.
123function damaged(src, spec) {
124 const n = src.split(spec.find).length - 1;
125 if (n !== 1) {
126 console.error(`break '${BREAK}': the anchor appears ${n} times in ${spec.file}, `
127 + 'so nothing was broken and the run below would prove nothing.');
128 process.exit(2);
129 }
130 return src.replace(spec.find, spec.with);
131}
132
133/// The damaged files, ONE BODY PER FILE.
134///
135/// Every edit a break names for a file goes into the SAME body, in order, and
136/// that one body is what the route serves. A `page.route` per edit spec does not
137/// work and does not say so: Playwright hands a request to the LAST route
138/// registered for its URL, so a two-edit break shipped only its second edit --
139/// and still went red, for half the reason it claims, with nothing to notice it.
140function damagedFiles() {
141 const byFile = new Map();
142 for (const spec of (BREAKS[BREAK] || [])) {
143 const src = byFile.has(spec.file) ? byFile.get(spec.file)
144 : fs.readFileSync(path.join(WWW, spec.file), 'utf8');
145 byFile.set(spec.file, damaged(src, spec));
146 }
147 return byFile;
148}
149
150// ── The fixture: eleven chats, seeded so id order, creation order and
151// updatedAt order all disagree ──────────────────────────────────────────
152//
153// Names are NATO letters so a glance at the tile list reads as a word order,
154// not a guess at what a timestamp meant. `updatedAt` is the permutation that
155// matters; `id` is assigned in a DIFFERENT order again, so a check that
156// happens to pass by reading row/id order instead of `updatedAt` is caught by
157// this file rather than by a user filing a second "weird" report.
158const NAMES = ['Alpha', 'Bravo', 'Charlie', 'Delta', 'Echo', 'Foxtrot',
159 'Golf', 'Hotel', 'India', 'Juliet', 'Kilo'];
160// Touched-most-recently-first, i.e. the order the tiles MUST render in.
161const TOUCH_ORDER = ['Foxtrot', 'Alpha', 'Kilo', 'Charlie', 'India', 'Echo',
162 'Juliet', 'Bravo', 'Hotel', 'Delta', 'Golf'];
163// Ids assigned in yet another order, so ascending-by-id (what a bare
164// `openCursor()` returns, and what `newChat`'s `seq++` would give a fresh
165// chat) does not coincide with TOUCH_ORDER either. Includes the c9/c10 pair
166// that sorts backwards as strings, matching the real bug's shape.
167const ID_ORDER = ['Delta', 'Kilo', 'Bravo', 'Golf', 'Alpha', 'Hotel',
168 'Foxtrot', 'Charlie', 'India', 'Juliet', 'Echo'];
169
170const BASE_T = Date.parse('2026-08-01T00:00:00Z');
171
172function seedRecords() {
173 const idOf = {};
174 ID_ORDER.forEach((name, i) => { idOf[name] = 'c' + (i + 1); }); // c1..c11
175 const touchRank = {};
176 TOUCH_ORDER.forEach((name, i) => { touchRank[name] = i; });
177 return NAMES.map((name) => ({
178 id: idOf[name],
179 name,
180 messages: [{ role: 'user', content: 'hello from ' + name }],
181 model: 'mock/fast',
182 provider: 'mock',
183 status: 'active',
184 promptTokens: 1, completionTokens: 1, cachedTokens: 0, costUsd: 0,
185 prevPrompt: 0, prevCompletion: 0, prevCached: 0, prevCost: 0, lastPrompt: 0,
186 // Latest TOUCH_ORDER entry gets the largest stamp.
187 updatedAt: BASE_T + (TOUCH_ORDER.length - touchRank[name]) * 1000,
188 }));
189}
190
191/// Write straight into `daimond-chats`, the same IndexedDB store `loadChats`
192/// reads on boot — not through the UI, which would only ever create chats in
193/// creation order and could never set up the id/touch mismatch this file needs.
194async function seedChats(page, records) {
195 await page.evaluate((recs) => new Promise((resolve, reject) => {
196 const req = indexedDB.open('daimond-chats', 1);
197 req.onupgradeneeded = () => {
198 const d = req.result;
199 if (!d.objectStoreNames.contains('chats')) d.createObjectStore('chats', { keyPath: 'id' });
200 };
201 req.onsuccess = () => {
202 const db = req.result;
203 const t = db.transaction('chats', 'readwrite');
204 const store = t.objectStore('chats');
205 recs.forEach((r) => store.put(r));
206 t.oncomplete = () => resolve();
207 t.onerror = () => reject(t.error);
208 };
209 req.onerror = () => reject(req.error);
210 }), records);
211}
212
213/// The tile names as drawn, top to bottom.
214///
215/// Read from `.tile-when`, which is the span carrying a chat's IDENTITY: the
216/// user's own name where one was set, and the derived relative time where none
217/// was. Every chat in this file's fixture is given a name, so here it is always
218/// the name — and reading the whole `.tile-label` instead would pick up the
219/// clock that now sits beside a named chat as well.
220///
221/// It was `.value` off an `<input>` until the rename gesture left the tile: the
222/// label is a button now, and a button has no value, so this read silently
223/// returned eleven empty strings and failed four checks that were all correct.
224const tileNames = (page) => page.$$eval('#session-list .session-box .tile-when',
225 (els) => els.map((e) => (e.textContent || '').trim()));
226
227/// The one visible confirm dialog's message, or null if none is open.
228const openDialogMsg = (page) => page.evaluate(() => {
229 const card = [...document.querySelectorAll('.modal.dlg .dlg-card')]
230 .find((c) => c.getClientRects().length);
231 return card ? (card.querySelector('.dlg-msg') || {}).textContent || '' : null;
232});
233const s = await open({
234 name: 'chattiles', profile: PROFILE, connect: false, defaults: false,
235});
236const { page } = s;
237
238if (BREAK) {
239 for (const [file, body] of damagedFiles()) {
240 await page.route('**/' + file, (r) => r.fulfill({
241 status: 200, contentType: 'application/javascript', body,
242 }));
243 }
244 // The stub only takes effect on a load after it is installed, and
245 // `clearDiamonds` inside `open()` already reloaded once with the routes
246 // absent — so the page is reloaded again, now with the broken file served.
247 await page.reload({ waitUntil: 'domcontentloaded' });
248 const { signInAs } = await import('./harness.mjs');
249 await signInAs(s, 'chattiles');
250}
251
252try {
253 await seedChats(page, seedRecords());
254 // A fresh load, through the real boot path (`chats = await loadChats()`),
255 // not a poke at the DOM: this is what proves the fix reads the STORE
256 // correctly and does not merely happen to sort whatever the UI built.
257 await page.reload({ waitUntil: 'domcontentloaded' });
258 const { signInAs } = await import('./harness.mjs');
259 await signInAs(s, 'chattiles');
260 await page.waitForTimeout(1000);
261
262 // ── 0. The gate ────────────────────────────────────────────────
263 const seeded = await tileNames(page);
264 check('all eleven seeded chats reached the rail', seeded.length === 11,
265 `${seeded.length}: ${seeded.join(', ')}`);
266 if (seeded.length !== 11) {
267 console.log('\nnothing to test against — refusing to report a vacuous pass.');
268 await s.close();
269 process.exit(1);
270 }
271 await shot(s, 'chattiles-seeded' + (BREAK ? '-' + BREAK : ''));
272
273 // ── 3. ORDERING: newest-touched first ─────────────────────────
274 // The MEANING asserted is "the tile at the top is the one most recently
275 // touched, named Foxtrot" — not a count and not "the list changed shape".
276 const drawn = await tileNames(page);
277 check(`the top tile is the most recently touched chat, "${TOUCH_ORDER[0]}"`,
278 drawn[0] === TOUCH_ORDER[0], `top was "${drawn[0]}"`);
279 check(`the bottom tile is the least recently touched, "${TOUCH_ORDER[TOUCH_ORDER.length - 1]}"`,
280 drawn[drawn.length - 1] === TOUCH_ORDER[TOUCH_ORDER.length - 1],
281 `bottom was "${drawn[drawn.length - 1]}"`);
282 check('the WHOLE order is newest-touched-first, not merely its two ends',
283 JSON.stringify(drawn) === JSON.stringify(TOUCH_ORDER),
284 `drawn: ${drawn.join(', ')}\n want: ${TOUCH_ORDER.join(', ')}`);
285 // And it does not just happen to equal id order or seeding order — if it
286 // did, the check above could pass by accident on unsorted code.
287 check('the id-ascending order is a DIFFERENT sequence from the drawn one — proof this is not accidental',
288 JSON.stringify(ID_ORDER) !== JSON.stringify(TOUCH_ORDER));
289
290 // ── 1. THE CLOSER CAME BACK ────────────────────────────────────
291 // The top tile — "Foxtrot" — is the one a user reaches for first, so it is
292 // the one this half of the file acts on.
293 //
294 // Clicked through `page.evaluate`, not a Playwright locator: a locator's
295 // `.click()` auto-waits up to 30s for the element to appear and then
296 // THROWS, which under `--break nocross` (no × at all) would abort the
297 // script before it reached its own `bad.length` report — a break that
298 // crashes the harness instead of failing a check proves nothing more than
299 // the break above proved. `clickCross` returns false when there is
300 // nothing to click, and every check below reads that as a plain FAIL.
301 const clickCross = () => page.evaluate(() => {
302 const box = document.querySelector('#session-list .session-box');
303 const x = box && box.querySelector('.tile-x');
304 if (!x) return false;
305 x.click();
306 return true;
307 });
308 const crossCount = await page.evaluate(() => {
309 const box = document.querySelector('#session-list .session-box');
310 return box ? box.querySelectorAll('.tile-x').length : -1;
311 });
312 check('the top chat tile carries a × ', crossCount === 1, String(crossCount));
313
314 // Foxtrot's id, taken BEFORE it is deleted: the trash is keyed by id, and a
315 // check that looked it up by name afterwards would be asking the panel to
316 // agree with itself.
317 const foxId = await page.evaluate(() => new Promise((res) => {
318 const req = indexedDB.open('daimond-chats', 1);
319 req.onsuccess = () => {
320 const all = req.result.transaction('chats', 'readonly').objectStore('chats').getAll();
321 all.onsuccess = () => res(((all.result || []).find((c) => c.name === 'Foxtrot') || {}).id || '');
322 all.onerror = () => res('');
323 };
324 req.onerror = () => res('');
325 }));
326
327 const clicked1 = await clickCross();
328 await page.waitForTimeout(500);
329 const askMsg = clicked1 ? await openDialogMsg(page) : null;
330 check('the × asks NOTHING — a delete you can undo does not want a dialog',
331 clicked1 && askMsg === null,
332 !clicked1 ? 'no × to click' : `a dialog opened: ${JSON.stringify(askMsg)}`);
333 const afterYes = await tileNames(page);
334 const wantAfterYes = clicked1 ? TOUCH_ORDER.filter((n) => n !== 'Foxtrot') : TOUCH_ORDER;
335 check('and the press removes THAT chat — the list is everyone else, still newest-first',
336 clicked1 && JSON.stringify(afterYes) === JSON.stringify(wantAfterYes),
337 `${afterYes.join(', ')}`);
338 // The half that makes the silence above safe rather than reckless.
339 const inTrash = await page.evaluate((id) => {
340 try { return !!(window.DaimondTrash && DaimondTrash.has(id)); } catch (e) { return false; }
341 }, foxId);
342 check('THE CHAT IS IN THE TRASH, which is why the × no longer has to ask',
343 !!foxId && inTrash, foxId ? `${foxId} not in the trash record` : 'could not read the id');
344 await shot(s, 'chattiles-closed' + (BREAK ? '-' + BREAK : ''));
345
346 // ── 2. DELETE ALL CHATS ─────────────────────────────────────────
347 // Reachable from the section's own overflow, and nowhere beside "+".
348 const railButtons = await page.evaluate(() => {
349 const head = document.querySelector('#new-session-btn').closest('.railhead');
350 return [...head.querySelectorAll('button')].map((b) => ({
351 id: b.id,
352 // A cross by SHAPE: two crossing diagonal strokes, the same test
353 // `verify_tiledlg` uses. The overflow must not be one of these.
354 crossy: /M6 6l12 12M18 6L6 18/.test(b.innerHTML),
355 dots: (b.innerHTML.match(/<circle/g) || []).length,
356 }));
357 });
358 check('the Chats railhead has exactly two buttons: "+" and the overflow — no second cross beside "+"',
359 railButtons.length === 2 && railButtons.every((b) => !b.crossy),
360 JSON.stringify(railButtons));
361 check('the overflow button is drawn as dots (⋯), not as a cross',
362 (railButtons.find((b) => b.id === 'chats-menu-btn') || {}).dots === 3,
363 JSON.stringify(railButtons));
364
365 // Every menu lookup below is guarded against `.railhead-menu` not existing
366 // at all — under `--break nomenu` the button is clickable but wired to
367 // nothing, and a bare `m.querySelectorAll` on a null menu would crash the
368 // harness the same way an un-guarded × click did above.
369 const menuItemText = () => page.evaluate(() => {
370 const m = document.querySelector('.railhead-menu');
371 if (!m) return null;
372 const btn = [...m.querySelectorAll('button')].find((b) => /delete all/i.test(b.textContent));
373 return btn ? btn.textContent.trim() : null;
374 });
375 const clickDeleteAllItem = () => page.evaluate(() => {
376 const m = document.querySelector('.railhead-menu');
377 if (!m) return false;
378 const btn = [...m.querySelectorAll('button')].find((b) => /delete all/i.test(b.textContent));
379 if (!btn) return false;
380 btn.click();
381 return true;
382 });
383
384 await page.click('#chats-menu-btn');
385 await page.waitForTimeout(300);
386 const menuItem = await menuItemText();
387 check('the overflow menu offers "Delete all chats"', !!menuItem, String(menuItem));
388
389 const pickedItem1 = await clickDeleteAllItem();
390 await page.waitForTimeout(800);
391 const bulkMsg = pickedItem1 ? await openDialogMsg(page) : null;
392 check('"Delete all chats" asks NOTHING — the chats are all in the trash a moment later',
393 pickedItem1 && bulkMsg === null,
394 !pickedItem1 ? 'no menu item to click' : `a dialog opened: ${JSON.stringify(bulkMsg)}`);
395 const afterBulkYes = await tileNames(page);
396 check('and it empties the Chats rail entirely',
397 pickedItem1 && afterBulkYes.length === 0,
398 `${afterBulkYes.length} left: ${afterBulkYes.join(', ')}`);
399 // NAMED, not counted: every chat that was on the rail is in the trash by
400 // name, so a run that trashed nine of ten cannot pass.
401 const trashedNames = await page.evaluate(async () => {
402 try { return (await window.DaimondCore.trashList()).map((i) => i.name); }
403 catch (e) { return []; }
404 });
405 check('EVERY chat that was on the rail is in the trash, by name',
406 TOUCH_ORDER.filter((x) => x !== 'Foxtrot').every((x) => trashedNames.includes(x))
407 && trashedNames.includes('Foxtrot'),
408 `trash holds: ${trashedNames.join(', ') || 'nothing'}`);
409 await shot(s, 'chattiles-deleteall' + (BREAK ? '-' + BREAK : ''));
410 const emptyNote = await page.evaluate(() =>
411 (document.querySelector('#session-list .rail-note') || {}).textContent || '');
412 check('and the rail says there are no chats, rather than showing an empty list with no explanation',
413 /no chats/i.test(emptyNote), JSON.stringify(emptyNote));
414
415 const errs = errors(s).filter((e) => !/favicon/i.test(e) && !/502 \(Bad Gateway\)/.test(e));
416 check('no console errors', errs.length === 0, errs.slice(0, 3).join(' | ') || 'none');
417
418 await shot(s, 'chattiles-empty' + (BREAK ? '-' + BREAK : ''));
419} finally {
420 await s.close();
421}
422
423if (BREAK) {
424 console.log(`\nbreak '${BREAK}': ${bad.length} check(s) failed`
425 + (bad.length ? ' — ' + bad.join('; ') : ' — NOTHING FAILED, so the checks above prove nothing'));
426 process.exit(bad.length ? 0 : 1); // a break MUST fail something
427}
428console.log(bad.length === 0 ? '\nall checks passed' : `\n${bad.length} check(s) FAILED`);
429process.exit(bad.length === 0 ? 0 : 1);