Oregami
Repositories/oxedyne/daimond

oxedyne/daimond/dev/verify_chatlife.mjs

35.0 KiB, 1 run

created by r2519314175:279, 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_chatlife.mjs — what a chat is called, and how it leaves.
2//
3// The record half of this feature — the merge, the two clocks, the operator
4// setting — is proved without a browser by dev/verify_chatexpiry.mjs, which is
5// where the convergence claims live. This file is the other half: what the
6// person actually sees, in the running app.
7//
8// 1. A NEW CHAT CARRIES NO NUMBER. `Chat-0025` is gone from the rail and from
9// the record. It was wrong twice: the counter was per DEVICE while the
10// chats it named were shared, so the numbers implied a chronology they did
11// not have; and an accession number is what a museum gives a thing it is
12// keeping, which a chat is not.
13//
14// 2. THE RAIL IS GROUPED BY DAY and each tile says when, relatively. The
15// fixture puts chats in three different days on purpose, so a check cannot
16// pass by finding one heading and stopping.
17//
18// 3. THE FIRST MESSAGE SHOWS, DIMMED, AND CAN BE TURNED OFF. Both halves:
19// the rail persists across every chat, so somebody who does not want their
20// own sentence on screen all day has to be able to say so, and the switch
21// has to actually take it away.
22//
23// 4. KEEP AS A DIAMOND makes a Diamond and CARRIES THE TRANSCRIPT IN. Not
24// "a Diamond appeared" — the conversation has to be inside it, as a file
25// and as a link, or the act has quietly lost the thing it was for.
26//
27// 5. A CHAT PAST ITS WINDOW IS TRASHED BY THE APP ITSELF, and one inside its
28// window is not. Driven through `DaimondExpiryTick`, the same function the
29// hourly clock calls, so what is exercised is the shipped path and not a
30// re-implementation of it in this file.
31//
32// 6. A CHAT WITH A LIVE RUN UNDER IT DOES NOT EXPIRE, however stale. A
33// dispatched worker's scratch belongs to the run, and "the chat expired"
34// must never be why somebody's work was lost.
35//
36// 7. THE BOOT SWEEP WAITS TO HEAR FROM THE OTHER DEVICES BEFORE DESTROYING
37// ANYTHING. This is the one that loses data if it is wrong, and it was
38// wrong until this release: retention destroys for good and lays a
39// tombstone, so a device sweeping on stale records can defeat a restore
40// made a month earlier on another device.
41//
42// EACH CHECK IS PROVED AGAINST BROKEN CODE FIRST. `--break <name>` serves a
43// damaged js/daimond.js to the real page through `page.route`; the run is then
44// expected to FAIL. A break whose anchor does not appear exactly once aborts,
45// so a break that has rotted cannot report a quiet pass.
46//
47// node dev/verify_chatlife.mjs --break numbered # 1: chats are named Chat-NNNN again
48// node dev/verify_chatlife.mjs --break noday # 2: no day headings
49// node dev/verify_chatlife.mjs --break nowhen # 2: tiles say nothing about when
50// node dev/verify_chatlife.mjs --break alwayspeek # 3: the setting does not suppress it
51// node dev/verify_chatlife.mjs --break emptykeep # 4: the Diamond is made without the transcript
52// node dev/verify_chatlife.mjs --break noexpiry # 5: nothing ever expires
53// node dev/verify_chatlife.mjs --break expireall # 5: everything expires, window or not
54// node dev/verify_chatlife.mjs --break takeslive # 6: a chat with a live run expires anyway
55// node dev/verify_chatlife.mjs --break halflive # 6: a queued run holds a chat, a RUNNING one does not
56// node dev/verify_chatlife.mjs --break sweepnow # 7: the boot sweep destroys before it has heard
57// node dev/verify_chatlife.mjs # and then, clean
58//
59// eval "$(bash dev/world.sh 6 --up)"
60// node dev/verify_chatlife.mjs
61import fs from 'node:fs';
62import path from 'node:path';
63import { fileURLToPath } from 'node:url';
64import { open, shot, scratch, signInAs } from './harness.mjs';
65
66const HERE = path.dirname(fileURLToPath(import.meta.url));
67const WWW = path.join(HERE, '..', 'www');
68
69const BREAK = (() => {
70 const i = process.argv.indexOf('--break');
71 return i > 0 ? String(process.argv[i + 1] || '') : '';
72})();
73
74const PROFILE = scratch('pw', 'chatlife' + (BREAK ? '-' + BREAK : ''));
75fs.rmSync(PROFILE, { recursive: true, force: true });
76
77const DAY = 24 * 3600 * 1000;
78
79const ok = [], bad = [];
80const check = (name, pass, detail) => {
81 (pass ? ok : bad).push(name);
82 console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : ''));
83};
84
85// ── The breaks ───────────────────────────────────────────────────────
86const BREAKS = {
87 // The accession number comes back. This is the shape the app shipped with.
88 numbered: [{
89 file: 'js/daimond.js',
90 find: "\t\t\tname: '',",
91 with: "\t\t\tname: 'Chat-' + ('000' + ((storedChats().length || 0) + 1)).slice(-4),",
92 }],
93 // No day headings: the rail is one undifferentiated column again.
94 noday: [{
95 file: 'js/daimond.js',
96 find: "\t\t\t\tsessionList.appendChild(h);",
97 with: '',
98 }],
99 // The tile says nothing about when it was touched, so with no name and no
100 // number there is nothing on it at all.
101 nowhen: [{
102 file: 'js/daimond.js',
103 find: "\t\twhen.textContent = s.name || tileWhen(stamp);",
104 with: "\t\twhen.textContent = s.name || '';",
105 }],
106 // The preview is drawn whatever the setting says — a switch that does not
107 // switch, which is worse than no switch, because it is a promise.
108 alwayspeek: [{
109 file: 'js/daimond.js',
110 find: "\t\tif (cfg.chatPreview !== false) {",
111 with: '\t\tif (true) {',
112 }],
113 // The Diamond is created and the transcript never written into it. The act
114 // looks like it worked: a Diamond appears on the rail with the right name.
115 emptykeep: [{
116 file: 'js/daimond.js',
117 find: "\t\t\tawait Files.writeBytes(transcriptPath(id),\n"
118 + "\t\t\t\tnew TextEncoder().encode(transcriptDoc(chat, name)));\n"
119 + "\t\t\twrote = true;",
120 with: '\t\t\twrote = true;',
121 }],
122 // Nothing ever expires.
123 noexpiry: [{
124 file: 'js/daimond.js',
125 find: "\t\t\t\tif (now >= due && DaimondTrash.expire(c.id, 'chat', due)) moved++;",
126 with: '',
127 }],
128 // Everything expires, whether its window has passed or not — the failure
129 // that would take a chat somebody used an hour ago.
130 expireall: [{
131 file: 'js/daimond.js',
132 find: "\t\t\t\tif (now >= due && DaimondTrash.expire(c.id, 'chat', due)) moved++;",
133 with: "\t\t\t\tif (DaimondTrash.expire(c.id, 'chat', due)) moved++;",
134 }],
135 // The boot sweep runs without waiting to hear from the other devices —
136 // which is what the app did before this release, and which destroys, for
137 // good and with a tombstone, on records that may already have been restored
138 // somewhere else.
139 // The boot sweep stops waiting: the gate is still there in shape, but it
140 // releases at once instead of on the first pull.
141 //
142 // The wait is shortened rather than the block deleted. Deleting it made the
143 // boot destroy things before the surfaces they touch were built, and the
144 // harness died on a missing function instead of failing a check — which is
145 // the trap verify_chattiles names: a break that crashes the run proves no
146 // more than a break that does nothing.
147 sweepnow: [{
148 file: 'js/daimond.js',
149 find: '\tvar SWEEP_WAIT_MS = 20000;',
150 with: '\tvar SWEEP_WAIT_MS = 0;',
151 }],
152 // A chat expires even with work still running under it.
153 takeslive: [{
154 file: 'js/daimond.js',
155 find: "\t\tif (chatHasLiveWork(c.id)) return 0;",
156 with: '',
157 }],
158 // The guard is still called, and still counts SOMETHING, but it has lost the
159 // state the rule is mostly about: a worker actually running no longer holds
160 // its chat, though one still waiting to start does. The half-right predicate
161 // is the failure this check exists to catch — it looks alive from every
162 // surface, and takes the scratch out from under a worker mid-answer.
163 halflive: [{
164 file: 'js/daimond.js',
165 find: "\t\t\t\t\t&& (r.status === 'running' || r.status === 'queued');",
166 with: "\t\t\t\t\t&& r.status === 'queued';",
167 }],
168};
169
170if (BREAK && !BREAKS[BREAK]) {
171 console.error(`unknown break '${BREAK}'; one of: ${Object.keys(BREAKS).join(', ')}`);
172 process.exit(2);
173}
174
175function damaged(src, spec) {
176 const n = src.split(spec.find).length - 1;
177 if (n !== 1) {
178 console.error(`break '${BREAK}': the anchor appears ${n} times in ${spec.file}, `
179 + 'so nothing was broken and the run below would prove nothing.');
180 process.exit(2);
181 }
182 return src.replace(spec.find, spec.with);
183}
184
185/// The damaged files, ONE BODY PER FILE.
186///
187/// Every edit a break names for a file goes into the SAME body, in order, and
188/// that one body is what the route serves. A `page.route` per edit spec does not
189/// work and does not say so: Playwright hands a request to the LAST route
190/// registered for its URL, so a two-edit break shipped only its second edit --
191/// and still went red, for half the reason it claims, with nothing to notice it.
192function damagedFiles() {
193 const byFile = new Map();
194 for (const spec of (BREAKS[BREAK] || [])) {
195 const src = byFile.has(spec.file) ? byFile.get(spec.file)
196 : fs.readFileSync(path.join(WWW, spec.file), 'utf8');
197 byFile.set(spec.file, damaged(src, spec));
198 }
199 return byFile;
200}
201
202// ── The fixture ──────────────────────────────────────────────────────
203//
204// Three chats in three different calendar days, each with a distinctive opening
205// sentence so the preview check reads a real line rather than "some text
206// appeared".
207//
208// ANCHORED TO THE START OF THE DAY, NOT TO ELAPSED HOURS, and that distinction
209// is the whole of why this function exists. The first draft placed them "two
210// hours ago" and "twenty-six hours ago", which is Today and Yesterday for most
211// of the day and neither of those after midnight — so the verifier passed all
212// evening and failed at 00:43, reporting three faults in correct code. A day
213// heading is a claim about the reader's calendar, so the fixture has to be
214// built out of calendar arithmetic.
215//
216// `startOfDay` is computed IN THE PAGE, in the browser's own zone, for the same
217// reason: this script and the browser can disagree about the offset.
218function seedRecords(anchors) {
219 const mk = (id, at, opening) => ({
220 id,
221 name: '',
222 messages: [{ role: 'user', content: opening, mid: id + '-m1', ts: at }],
223 model: 'mock/fast', provider: 'mock', status: 'active',
224 promptTokens: 1, completionTokens: 1, cachedTokens: 0, costUsd: 0,
225 prevPrompt: 0, prevCompletion: 0, prevCached: 0, prevCost: 0, lastPrompt: 0,
226 updatedAt: at,
227 });
228 return [
229 mk('c-today', anchors.today, 'the pump seal on the boat'),
230 mk('c-yest', anchors.yest, 'a letter to the council about the verge'),
231 mk('c-old', anchors.old, 'quotes for the retaining wall'),
232 ];
233}
234
235async function seedChats(page, records) {
236 await page.evaluate((recs) => new Promise((resolve, reject) => {
237 const req = indexedDB.open('daimond-chats', 1);
238 req.onupgradeneeded = () => {
239 const d = req.result;
240 if (!d.objectStoreNames.contains('chats')) d.createObjectStore('chats', { keyPath: 'id' });
241 };
242 req.onsuccess = () => {
243 const db = req.result;
244 const t = db.transaction('chats', 'readwrite');
245 const store = t.objectStore('chats');
246 recs.forEach((r) => store.put(r));
247 t.oncomplete = () => resolve();
248 t.onerror = () => reject(t.error);
249 };
250 req.onerror = () => reject(req.error);
251 }), records);
252}
253
254/// What the rail actually reads, top to bottom: headings and tiles in the one
255/// list, so the GROUPING is visible to the check and not merely the membership.
256const railRows = (page) => page.evaluate(() => {
257 const out = [];
258 const list = document.getElementById('session-list');
259 if (!list) return out;
260 for (const el of list.children) {
261 if (el.classList.contains('rail-day')) { out.push({ kind: 'day', text: el.textContent.trim() }); continue; }
262 if (!el.classList.contains('session-box')) continue;
263 const when = el.querySelector('.tile-when');
264 const prev = el.querySelector('.tile-preview');
265 out.push({
266 kind: 'tile',
267 id: el.dataset.id || '',
268 when: when ? when.textContent.trim() : '',
269 preview: prev ? prev.textContent.trim() : null,
270 });
271 }
272 return out;
273});
274
275const s = await open({ name: 'chatlife', profile: PROFILE, connect: false, defaults: false });
276const { page } = s;
277
278if (BREAK) {
279 for (const [file, body] of damagedFiles()) {
280 await page.route('**/' + file, (r) => r.fulfill({
281 status: 200, contentType: 'application/javascript', body,
282 }));
283 }
284 await page.reload({ waitUntil: 'domcontentloaded' });
285 await signInAs(s, 'chatlife');
286}
287
288try {
289 // The three anchors, computed in the page's own zone. Each is stated as an
290 // offset from the START of a day, so every one lands in the calendar day it
291 // is named after whatever the hour of the run.
292 const anchors = await page.evaluate(() => {
293 const now = Date.now();
294 const d = new Date(now);
295 const startOfToday = new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
296 const HOUR = 3600 * 1000, DAY = 24 * HOUR;
297 return {
298 now,
299 startOfToday,
300 // Today: halfway through however much of today has elapsed, so it is
301 // always today and always at least a moment old.
302 today: startOfToday + Math.floor((now - startOfToday) / 2),
303 // Yesterday, mid-afternoon.
304 yest: startOfToday - DAY + 14 * HOUR,
305 // Four calendar days back, which is past a three-day window whatever
306 // the time of day the run starts.
307 old: startOfToday - 4 * DAY + 14 * HOUR,
308 };
309 });
310 await seedChats(page, seedRecords(anchors));
311 await page.reload({ waitUntil: 'domcontentloaded' });
312 await signInAs(s, 'chatlife');
313 await page.waitForTimeout(1200);
314
315 // ── 0. The gate ────────────────────────────────────────────────
316 let rows = await railRows(page);
317 let tiles = rows.filter((r) => r.kind === 'tile');
318 check('all three seeded chats reached the rail', tiles.length === 3,
319 `${tiles.length}: ${tiles.map((t) => t.id).join(', ')}`);
320 if (tiles.length !== 3) {
321 console.log('\nnothing to test against — refusing to report a vacuous pass.');
322 await s.close();
323 process.exit(1);
324 }
325 await shot(s, 'chatlife-rail' + (BREAK ? '-' + BREAK : ''));
326
327 // ── 1. No number, on a chat the APP makes ─────────────────────
328 //
329 // Pressed through the real "+ New chat" control rather than seeded, because
330 // the number came from `newChat` and seeding one would prove nothing about
331 // the code that used to mint it.
332 const madeOne = await page.evaluate(() => {
333 const b = document.getElementById('new-session-btn');
334 if (!b) return false;
335 b.click();
336 return true;
337 });
338 check('the "new chat" control is reachable', madeOne === true);
339 await page.waitForTimeout(500);
340
341 const fresh = await page.evaluate(() => new Promise((res) => {
342 const req = indexedDB.open('daimond-chats', 1);
343 req.onsuccess = () => {
344 const all = req.result.transaction('chats', 'readonly').objectStore('chats').getAll();
345 all.onsuccess = () => {
346 const seeded = ['c-today', 'c-yest', 'c-old'];
347 res((all.result || []).filter((c) => seeded.indexOf(c.id) === -1)
348 .map((c) => ({ id: c.id, name: c.name })));
349 };
350 all.onerror = () => res([]);
351 };
352 req.onerror = () => res([]);
353 }));
354 check('pressing "new chat" made exactly one chat', fresh.length === 1,
355 JSON.stringify(fresh));
356 check('and its stored name is EMPTY, not Chat-NNNN',
357 fresh.length === 1 && fresh[0].name === '',
358 fresh.length ? `name was "${fresh[0].name}"` : 'no chat to read');
359
360 // And nothing anywhere in the rail wears the old shape. Asserted on the
361 // rendered text, because the record being clean would not help if the tile
362 // invented a number of its own.
363 const railText = await page.evaluate(() => (document.getElementById('session-list') || {}).textContent || '');
364 check('no tile in the rail reads "Chat-NNNN"', !/Chat-\d{3,}/.test(railText),
365 (railText.match(/Chat-\d{3,}/g) || []).join(', '));
366
367 // ── 2. Grouped by day, and each tile says when ────────────────
368 rows = await railRows(page);
369 const days = rows.filter((r) => r.kind === 'day').map((r) => r.text);
370 check('the rail carries day headings', days.length >= 2, days.join(' | '));
371 check('and the first is Today, where the newest chat is',
372 days[0] === 'Today', days.join(' | '));
373 check('and there are three distinct groups for three distinct days',
374 new Set(days).size === 3, days.join(' | '));
375 check('the headings are in newest-first order, matching the sort',
376 JSON.stringify(days) === JSON.stringify(['Today', 'Yesterday', 'Earlier']),
377 days.join(' | '));
378
379 // Each seeded tile sits under the right heading. Read by walking the rows,
380 // so what is asserted is MEMBERSHIP of a group and not merely that both
381 // exist somewhere on the rail.
382 const groupOf = {};
383 let cur = '';
384 for (const r of rows) {
385 if (r.kind === 'day') { cur = r.text; continue; }
386 groupOf[r.id] = cur;
387 }
388 check('the two-hour-old chat is under Today', groupOf['c-today'] === 'Today', groupOf['c-today']);
389 check('the day-old chat is under Yesterday', groupOf['c-yest'] === 'Yesterday', groupOf['c-yest']);
390 check('the four-day-old chat is under Earlier', groupOf['c-old'] === 'Earlier', groupOf['c-old']);
391
392 // The tile's own line. Every tile must say SOMETHING — with no name and no
393 // number, a tile whose time was blank would be a row with nothing on it.
394 const byId = Object.fromEntries(rows.filter((r) => r.kind === 'tile').map((r) => [r.id, r]));
395 check('no tile is left with nothing to identify it',
396 Object.values(byId).every((r) => r.when.length > 0),
397 JSON.stringify(Object.values(byId).map((r) => r.when)));
398
399 // THE EXACT WORDING IS TESTED AS THE PURE FUNCTION IT IS, at a fixed
400 // instant, rather than through the DOM at whatever o'clock the suite runs.
401 // `tileWhen` and `dayBucket` both take `now` for this reason. Doing it
402 // through the rendered tile instead is what made this file report three
403 // faults in correct code at 00:43, when "two hours ago" is really yesterday.
404 const phrasing = await page.evaluate(() => {
405 const HOUR = 3600 * 1000, DAY = 24 * HOUR;
406 // Noon on a Wednesday, chosen so every offset below stays inside its own
407 // calendar day and none of the answers depends on the hour of the run.
408 const NOON = new Date(2026, 5, 10, 12, 0, 0).getTime();
409 const w = (ms) => window.DaimondCore.railWhen(ms, NOON);
410 const d = (ms) => window.DaimondCore.railDay(ms, NOON);
411 return {
412 justNow: w(NOON - 20 * 1000),
413 twelveMin: w(NOON - 12 * 60 * 1000),
414 twoHr: w(NOON - 2 * HOUR),
415 // 23:00 the previous night: eleven hours before this instant, and
416 // firmly YESTERDAY. An implementation counting elapsed hours would
417 // call it today.
418 lastNight: { day: d(NOON - 13 * HOUR), text: w(NOON - 13 * HOUR) },
419 // 00:30 THIS MORNING: eleven and a half hours ago and still today.
420 // The mirror of the case above, so a rule that simply moved the
421 // boundary cannot satisfy both.
422 smallHours: { day: d(NOON - 11.5 * HOUR), text: w(NOON - 11.5 * HOUR) },
423 fourDays: { day: d(NOON - 4 * DAY), text: w(NOON - 4 * DAY) },
424 };
425 });
426 check('under a minute reads as "just now"', phrasing.justNow === 'just now', phrasing.justNow);
427 check('twelve minutes reads as minutes ago', phrasing.twelveMin === '12 min ago', phrasing.twelveMin);
428 check('two hours reads as hours ago', phrasing.twoHr === '2 hr ago', phrasing.twoHr);
429 check('eleven pm last night is YESTERDAY, not "13 hr ago"',
430 phrasing.lastNight.day === 'yesterday' && !/ago/.test(phrasing.lastNight.text),
431 JSON.stringify(phrasing.lastNight));
432 check('half past midnight this morning is still TODAY, though it is further back in hours',
433 phrasing.smallHours.day === 'today' && /ago/.test(phrasing.smallHours.text),
434 JSON.stringify(phrasing.smallHours));
435 check('four days back is Earlier, and says a date rather than a time',
436 phrasing.fourDays.day === 'earlier' && !/ago/.test(phrasing.fourDays.text),
437 JSON.stringify(phrasing.fourDays));
438
439 // ── 3. The preview, and the switch that takes it away ─────────
440 check('the tile shows the first thing said in the chat',
441 byId['c-today'].preview === 'the pump seal on the boat',
442 JSON.stringify(byId['c-today'].preview));
443 check('each tile shows its OWN opening, not the newest chat\'s',
444 byId['c-old'].preview === 'quotes for the retaining wall',
445 JSON.stringify(byId['c-old'].preview));
446
447 // Turned off through the menu item the user would actually press.
448 const toggled = await page.evaluate(() => {
449 const btn = document.getElementById('chats-menu-btn');
450 if (!btn) return 'no menu button';
451 btn.click();
452 const item = [...document.querySelectorAll('.railhead-menu-item')]
453 .find((b) => b.getAttribute('role') === 'menuitemcheckbox');
454 if (!item) return 'no checkable item';
455 const was = item.getAttribute('aria-checked');
456 item.click();
457 return was;
458 });
459 check('the preview switch is in the Chats menu and reads as ON',
460 toggled === 'true', String(toggled));
461 await page.waitForTimeout(400);
462 rows = await railRows(page);
463 check('turning it off takes the first message off every tile',
464 rows.filter((r) => r.kind === 'tile').every((r) => r.preview === null),
465 JSON.stringify(rows.filter((r) => r.kind === 'tile').map((r) => r.preview)));
466 check('and the tiles still say when, so the rail is not left blank',
467 rows.filter((r) => r.kind === 'tile').every((r) => r.when.length > 0));
468 await shot(s, 'chatlife-nopreview' + (BREAK ? '-' + BREAK : ''));
469
470 // Back on, so the rest of the run sees the shipped state.
471 await page.evaluate(() => {
472 document.getElementById('chats-menu-btn').click();
473 const item = [...document.querySelectorAll('.railhead-menu-item')]
474 .find((b) => b.getAttribute('role') === 'menuitemcheckbox');
475 if (item) item.click();
476 });
477 await page.waitForTimeout(300);
478
479 // ── 4. Keep as a Diamond carries the transcript ───────────────
480 //
481 // Driven through `DaimondCore.keepAsDiamond`, which is what the tile's
482 // button and the Trash panel's row both call — one path, exercised once.
483 // The name dialog is answered by driving the real dialog, so a change that
484 // broke the prompt would be caught here rather than bypassed.
485 const KEEPNAME = 'The retaining wall';
486 const kept = await page.evaluate((name) => {
487 const p = window.DaimondCore.keepAsDiamond('c-old');
488 // The prompt is a real modal; fill it and press its OK.
489 return new Promise((res) => {
490 const t0 = Date.now();
491 (function tick() {
492 const card = [...document.querySelectorAll('.modal.dlg .dlg-card')]
493 .find((c) => c.getClientRects().length);
494 const input = card && card.querySelector('input');
495 if (input) {
496 input.value = name;
497 input.dispatchEvent(new Event('input', { bubbles: true }));
498 const okBtn = card.querySelector('.dlg-ok');
499 if (okBtn) { okBtn.click(); p.then(res, () => res('')); return; }
500 }
501 if (Date.now() - t0 > 8000) { res('timeout: no name dialog'); return; }
502 setTimeout(tick, 60);
503 })();
504 });
505 }, KEEPNAME);
506 check('Keep as a Diamond asked for a name and made one',
507 typeof kept === 'string' && kept.length > 0 && !kept.startsWith('timeout'),
508 String(kept));
509 await page.waitForTimeout(900);
510
511 const made = await page.evaluate(async (id) => {
512 const app = window.DaimondCore.diamondApp();
513 const list = JSON.parse(await app.list_diamonds());
514 const d = list.find((x) => x.id === id) || null;
515 let links = [];
516 try { links = JSON.parse(await app.links_touching('diamond:' + id) || '[]'); }
517 catch (e) { links = []; }
518 // Read through the WASM file reader, not `run_tool('file_read')`, which is
519 // the model-facing rendering: it numbers every line and wraps the path in
520 // an envelope, so a check on the transcript's text would be asserting
521 // against the envelope. Same trap mail.js records at its `readText`.
522 let body = '';
523 try { body = await window.DaimondCore.readFile('diamonds/' + id + '/transcript.md'); }
524 catch (e) { body = ''; }
525 return { name: d ? d.name : null, links: links.map((l) => ({ to: l.other || l.to, rel: l.rel })), body };
526 }, kept);
527
528 check('the Diamond exists and wears the name that was typed',
529 made.name === KEEPNAME, JSON.stringify(made.name));
530 check('the transcript was written into the Diamond\'s own directory',
531 made.body.length > 0, `${made.body.length} bytes`);
532 check('and it carries what was actually said in the chat, not a summary',
533 made.body.indexOf('quotes for the retaining wall') !== -1,
534 JSON.stringify(made.body.slice(0, 120)));
535 check('the transcript is linked as an artefact the Diamond HOLDS',
536 made.links.some((l) => /transcript\.md/.test(String(l.to)) && l.rel === 'holds'),
537 JSON.stringify(made.links));
538
539 // ── 5. Expiry, at the boundary, through the shipped tick ──────
540 //
541 // The window is set to three days and the fixture has a four-day-old chat
542 // and a two-hour-old one, so one must go and the other must stay. A check
543 // that only asserted the first would pass against code that trashed the lot.
544 const before = await page.evaluate(() => ({
545 old: DaimondTrash.has('c-old'),
546 today: DaimondTrash.has('c-today'),
547 }));
548 check('neither chat is in the trash before the tick', !before.old && !before.today,
549 JSON.stringify(before));
550
551 // The tick is published by `startExpiryClock`, which runs as one step of the
552 // boot. Waited for rather than assumed: the sections above open a Diamond
553 // and redraw, so how far the boot has got by the time the run reaches here
554 // varies — and a bare call turns "the app was still starting" into a harness
555 // crash, which reports nothing about the property under test.
556 const tickReady = await page.waitForFunction(
557 () => typeof window.DaimondExpiryTick === 'function', null, { timeout: 15000 },
558 ).then(() => true, () => false);
559 check('the expiry clock published its tick', tickReady === true,
560 'the app never finished booting, so nothing below could be measured');
561 await page.evaluate(async () => {
562 DaimondPolicy.set(3, 30);
563 await window.DaimondExpiryTick();
564 });
565 await page.waitForTimeout(600);
566
567 const after = await page.evaluate(() => ({
568 old: DaimondTrash.has('c-old'),
569 yest: DaimondTrash.has('c-yest'),
570 today: DaimondTrash.has('c-today'),
571 oldAuto: DaimondTrash.isAuto('c-old'),
572 }));
573 check('the four-day-old chat, past a three-day window, is in the trash',
574 after.old === true);
575 check('the two-hour-old chat is NOT', after.today === false);
576 check('nor is the day-old one, which is also inside the window',
577 after.yest === false);
578 check('and the trashed one is marked as the clock\'s doing, not a person\'s',
579 after.oldAuto === true);
580
581 // It has left the rail, which is the visible half of the same fact.
582 rows = await railRows(page);
583 check('the expired chat is off the rail', !rows.some((r) => r.id === 'c-old'),
584 rows.filter((r) => r.kind === 'tile').map((r) => r.id).join(', '));
585
586 // AND IT IS STILL PROMOTABLE. The Trash panel offers Keep beside Restore,
587 // because the trash is where somebody meets a chat they had forgotten.
588 const trashRow = await page.evaluate(async () => {
589 const items = await window.DaimondCore.trashList();
590 const it = items.find((x) => x.id === 'c-old');
591 return it ? { found: true, kind: it.kind, auto: !!it.auto } : { found: false };
592 });
593 check('the expired chat is listed in the trash, as a chat, marked automatic',
594 trashRow.found && trashRow.kind === 'chat' && trashRow.auto === true,
595 JSON.stringify(trashRow));
596 await shot(s, 'chatlife-expired' + (BREAK ? '-' + BREAK : ''));
597
598 // ── 6. A live run keeps its chat, however stale ───────────────
599 //
600 // A dispatched worker's scratch belongs to the RUN, not to the chat, so the
601 // chat has to outlive an untouched window while any of its workers is short
602 // of a terminal state. Different in kind from the turn-in-flight exemptions:
603 // a turn is over in seconds, a fan-out outlives the turn that started it,
604 // and a chat can be genuinely untouched for days with work underneath it.
605 //
606 // `c-yest` is aged past the window and a worker is dispatched under it, so the
607 // ONLY thing standing between it and the trash is this rule.
608 const guarded = await page.evaluate(async () => {
609 const rec = await new Promise((res) => {
610 const req = indexedDB.open('daimond-chats', 1);
611 req.onsuccess = () => {
612 const st = req.result.transaction('chats', 'readwrite').objectStore('chats');
613 const g = st.get('c-yest');
614 g.onsuccess = () => {
615 const c = g.result;
616 if (!c) { res(null); return; }
617 // TEN days: comfortably past the three-day window, and comfortably
618 // INSIDE the three-plus-thirty at which a chat is expired and
619 // destroyed in the same pass. Forty days did both at once — the
620 // chat expired, its thirty-day retention had also elapsed, and
621 // the sweep destroyed it on the spot — which is correct
622 // behaviour and made this check read as a failure of the
623 // worker rule it is actually about.
624 c.updatedAt = Date.now() - 10 * 24 * 3600 * 1000;
625 st.put(c);
626 res(c.id);
627 };
628 g.onerror = () => res(null);
629 };
630 req.onerror = () => res(null);
631 });
632 return rec;
633 });
634 check('the chat to guard was aged well past the window', guarded === 'c-yest', String(guarded));
635 await page.reload({ waitUntil: 'domcontentloaded' });
636 await signInAs(s, 'chatlife');
637 await page.waitForTimeout(1200);
638
639 // Work is running. THE RUN IS A REAL ONE, dispatched through the call a
640 // chat's turn makes — `Workers.dispatch` with no Diamond and the chat as
641 // owner, exactly as `runTurn` does when the conductor asks for agents. It is
642 // the chat id travelling on the run record that the predicate matches on, so
643 // a stand-in that only set a global counter would prove nothing about it: an
644 // earlier draft of this check did that, and went on passing after the
645 // per-chat predicate replaced the counter it was setting.
646 //
647 // The pump is held first, so the fan-out is real without being paid for: a
648 // held pump leaves the run `queued`, which is live by the same rule that
649 // makes `running` live — the work is still going to happen — and no model is
650 // called. Nothing here needs a provider, which is what lets this test go on
651 // running with `connect: false`.
652 const live = await page.evaluate(async () => {
653 DaimondPolicy.set(3, 30);
654 // Look at a DIFFERENT chat first. `chats` is rebuilt from IndexedDB in
655 // id order, not in the rail's order, so the chat the boot happens to
656 // open is not the top tile — and a chat on screen is exempt by design.
657 // Without this the check would be measuring that exemption instead of
658 // the worker one, and would read as a failure of the rule under test.
659 const other = document.querySelector('.session-box[data-id="c-today"]');
660 if (other) other.click();
661 await new Promise((r) => setTimeout(r, 400));
662 const W = window.DaimondCore.workers();
663 if (!W) return 'no workers module';
664 W.pauseAll(); // nothing spends while this runs
665 W.dispatch('', '', [{ name: 'guard', task: 'hold the chat open' }], false,
666 { provider: 'mock', model: 'mock/fast' }, 0,
667 { chatId: 'c-yest', chatName: '' });
668 const run = W.runs.find((r) => r.chatId === 'c-yest');
669 // PROVE THE INSTRUMENT, twice, before anything is concluded from it. A
670 // run this file cannot see, or one the predicate does not count, would
671 // make every reading below vacuous — and `chatDueAt` is published for
672 // exactly this: it says the chat is EXEMPT rather than leaving the
673 // exemption to be inferred from a chat that happened not to move.
674 const queuedLive = !!run && run.status === 'queued'
675 && W.liveFor('c-yest') === true
676 && window.DaimondCore.chatDueAt('c-yest') === 0;
677 // And the state the headline case is about. `running` is what `start`
678 // stamps the moment the pump releases; asserted separately because a
679 // predicate that counted one of the two states and not the other would
680 // sweep a chat out from under a worker mid-answer and still read green.
681 if (run) run.status = 'running';
682 const runningLive = !!run && W.liveFor('c-yest') === true
683 && window.DaimondCore.chatDueAt('c-yest') === 0;
684 await window.DaimondExpiryTick();
685 const held = !DaimondTrash.has('c-yest');
686 // And it ends. Through the kill switch on the run rather than by editing
687 // the record: `stopped` is terminal by the app's own reckoning, the same
688 // as `done` and `error`. A queued run stopped this way never reaches
689 // `start`, so no batch gathers and nothing writes a report back into the
690 // chat — which matters, because delivering a report TOUCHES the chat, and
691 // a chat touched a moment ago is not stale and would not be trashed for
692 // reasons that have nothing to do with this rule.
693 if (run) W.stop(run);
694 const stillLive = W.liveFor('c-yest');
695 await window.DaimondExpiryTick();
696 return { queuedLive, runningLive, held, stillLive,
697 status: run ? run.status : 'no run', afterDone: DaimondTrash.has('c-yest') };
698 });
699 check('a chat ten days untouched is NOT trashed while a run is live',
700 live && live.queuedLive === true && live.runningLive === true && live.held === true,
701 JSON.stringify(live));
702 check('and it IS trashed once the last worker has finished',
703 live && live.stillLive === false && live.afterDone === true, JSON.stringify(live));
704
705 // ── 7. The boot sweep waits to hear from the other devices ────
706 //
707 // THE ONE THAT LOSES DATA IF IT IS WRONG. Retention destroys for good and
708 // lays a tombstone, and a tombstone is honoured unconditionally by every
709 // merge. A device that swept on its own records BEFORE reading the mailbox
710 // could therefore destroy something the other device restored a month ago —
711 // its own record still says trashed, because the restore is sitting unread
712 // in a parcel it has not opened — and the restore would lose to a deletion
713 // decided on stale information. The app did exactly this until now.
714 //
715 // Proved by putting something long overdue in the trash, reloading, and
716 // asking whether it survived the first moment of the boot. It must; and it
717 // must then go when the pull is announced.
718 const gate = await page.evaluate(() => {
719 const raw = JSON.parse(localStorage.getItem('daimond-trash') || '{}');
720 if (!raw.items || !raw.items['c-old']) return 'no record to age';
721 raw.items['c-old'].at = Date.now() - 400 * 24 * 3600 * 1000;
722 localStorage.setItem('daimond-trash', JSON.stringify(raw));
723 return 'aged';
724 });
725 check('there is a trashed chat long past its retention to sweep', gate === 'aged', String(gate));
726 await page.reload({ waitUntil: 'domcontentloaded' });
727 await signInAs(s, 'chatlife');
728 await page.waitForTimeout(1500);
729 const early = await page.evaluate(() => DaimondTrash.has('c-old'));
730 check('it is STILL THERE moments after the boot — the sweep has not run yet',
731 early === true, 'it was destroyed before this device had heard from any other');
732 const afterPull = await page.evaluate(async () => {
733 window.dispatchEvent(new Event('daimond:pulled'));
734 await new Promise((r) => setTimeout(r, 1500));
735 return DaimondTrash.has('c-old');
736 });
737 check('and once a pull has landed, the sweep destroys it',
738 afterPull === false, 'the sweep never ran, so nothing is ever destroyed');
739} finally {
740 await s.close();
741}
742
743console.log(`\n${ok.length} ok, ${bad.length} failed`);
744if (bad.length) {
745 console.log('failed: ' + bad.join('; '));
746 if (BREAK) console.log(`\n(expected: --break ${BREAK} is meant to fail)`);
747 process.exit(1);
748}
749if (BREAK) {
750 console.log(`\nBREAK '${BREAK}' PASSED EVERYTHING — the checks above do not `
751 + 'actually test what they claim to.');
752 process.exit(1);
753}
754process.exit(0);