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 |
| 61 | import fs from 'node:fs'; |
| 62 | import path from 'node:path'; |
| 63 | import { fileURLToPath } from 'node:url'; |
| 64 | import { open, shot, scratch, signInAs } from './harness.mjs'; |
| 65 | |
| 66 | const HERE = path.dirname(fileURLToPath(import.meta.url)); |
| 67 | const WWW = path.join(HERE, '..', 'www'); |
| 68 | |
| 69 | const BREAK = (() => { |
| 70 | const i = process.argv.indexOf('--break'); |
| 71 | return i > 0 ? String(process.argv[i + 1] || '') : ''; |
| 72 | })(); |
| 73 | |
| 74 | const PROFILE = scratch('pw', 'chatlife' + (BREAK ? '-' + BREAK : '')); |
| 75 | fs.rmSync(PROFILE, { recursive: true, force: true }); |
| 76 | |
| 77 | const DAY = 24 * 3600 * 1000; |
| 78 | |
| 79 | const ok = [], bad = []; |
| 80 | const check = (name, pass, detail) => { |
| 81 | (pass ? ok : bad).push(name); |
| 82 | console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : '')); |
| 83 | }; |
| 84 | |
| 85 | // ── The breaks ─────────────────────────────────────────────────────── |
| 86 | const 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 | |
| 170 | if (BREAK && !BREAKS[BREAK]) { |
| 171 | console.error(`unknown break '${BREAK}'; one of: ${Object.keys(BREAKS).join(', ')}`); |
| 172 | process.exit(2); |
| 173 | } |
| 174 | |
| 175 | function 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. |
| 192 | function 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. |
| 218 | function 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 | |
| 235 | async 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. |
| 256 | const 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 | |
| 275 | const s = await open({ name: 'chatlife', profile: PROFILE, connect: false, defaults: false }); |
| 276 | const { page } = s; |
| 277 | |
| 278 | if (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 | |
| 288 | try { |
| 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 | |
| 743 | console.log(`\n${ok.length} ok, ${bad.length} failed`); |
| 744 | if (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 | } |
| 749 | if (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 | } |
| 754 | process.exit(0); |