oxedyne/daimond/dev/verify_attachfocus.mjs
48.2 KiB, 1 run
created by r2519314175:241, 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_attachfocus.mjs — the paperclip: one control, two behaviours, decided |
| 2 | // by what is in focus and never by which row it sits on. |
| 3 | // |
| 4 | // ATTACH_CONTRACT.md is the design; dev/ATTACH_CONTRACT.md §10 lists seven |
| 5 | // properties, and this covers all seven — stage one's control and states |
| 6 | // (§4, §6, §7) and stage two's footer chrome (§5). |
| 7 | // |
| 8 | // 1. The control appears on a FILE row, a FOLDER row and in the Doc header, |
| 9 | // and is absent with nothing in focus. |
| 10 | // 2. With a Diamond in focus it writes a `holds` link carrying the workspace. |
| 11 | // 3. With a chat in focus it writes nothing to the DIAMOND store, and what it |
| 12 | // holds SURVIVES the turn. (§4 originally made a chat's attachment good for |
| 13 | // one turn only. A chat now carries persistent scope, as a Diamond does — |
| 14 | // two lifetimes, one meaning — so this asserts the reverse of what it first |
| 15 | // did, deliberately. See the section itself.) |
| 16 | // 4. Note generates the note prefix, Read generates the read prefix, and the |
| 17 | // text is in the composer where the user can edit it before sending. |
| 18 | // 5. Note is what an attachment starts as. |
| 19 | // 6. Past the cap the footer scrolls and the composer does not move. |
| 20 | // 7. An unreachable attachment is not in the generated prefix, is shown |
| 21 | // anyway, and SAYS WHY in words rather than in a hover title. |
| 22 | // |
| 23 | // And §5's other two additions, which arrive with the cap: the view toggle, |
| 24 | // remembered per user, and the `+` that attaches without leaving the page. |
| 25 | // |
| 26 | // WHAT AN UNCAPPED STACK DOES, measured under `--break nocap` rather than |
| 27 | // asserted: at 22 attachments the footer's box grows from 259px to 777px, what |
| 28 | // is left of the panel above it falls from 537px to 240px, the box stops |
| 29 | // scrolling at all (777px of tiles in a 777px box, and `scrollTop` will not |
| 30 | // move off zero), and the composer is pushed from y=841 to y=1444 — off the |
| 31 | // bottom of a 950px window. On the crystal face the same stack squeezes the |
| 32 | // crystal itself to 32px and is then clipped by `.crystal-view`, so the tiles |
| 33 | // past the fold cannot be reached at all. |
| 34 | // |
| 35 | // The composer is measured against UNREACHABLE attachments, which §7 keeps out |
| 36 | // of the generated prefix. With reachable ones the composer moves for a reason |
| 37 | // that is not the footer: each further path lengthens the prefix, the prefix is |
| 38 | // in the textarea, and the textarea grows. That movement is the user's own text |
| 39 | // and is theirs to edit; conflating it with the footer's would make the check |
| 40 | // unfailable in one direction and untrue in the other. |
| 41 | // |
| 42 | // EACH CHECK IS PROVED AGAINST BROKEN CODE FIRST. `--break <name>` serves a |
| 43 | // deliberately damaged copy of a file to the real page through `page.route`, |
| 44 | // and the run below is then expected to FAIL. A break whose anchor does not |
| 45 | // match aborts rather than passing quietly. |
| 46 | // |
| 47 | // node dev/verify_attachfocus.mjs --break hidden # 1 fails: never hides |
| 48 | // node dev/verify_attachfocus.mjs --break diamondlink # 2 fails: no link written |
| 49 | // node dev/verify_attachfocus.mjs --break chatclear # 3 fails: the scope is emptied |
| 50 | // node dev/verify_attachfocus.mjs --break prefix # 4 fails: no prefix generated |
| 51 | // node dev/verify_attachfocus.mjs --break notedefault # 5 fails: starts as Read |
| 52 | // node dev/verify_attachfocus.mjs --break unreachable # 7 fails: away path in prefix |
| 53 | // node dev/verify_attachfocus.mjs --break nocap # 6 fails: the stack has no cap |
| 54 | // node dev/verify_attachfocus.mjs --break viewforget # 5b fails: the view is not remembered |
| 55 | // node dev/verify_attachfocus.mjs --break silentaway # 7b fails: a shut tile says nothing |
| 56 | // node dev/verify_attachfocus.mjs --break pickdrops # 5c fails: `+` attaches nothing |
| 57 | // node dev/verify_attachfocus.mjs # and then, clean |
| 58 | // |
| 59 | // eval "$(bash dev/world.sh 7 --up)" |
| 60 | // eval "$(bash dev/world.sh 7 --env)" |
| 61 | // node dev/verify_attachfocus.mjs |
| 62 | import fs from 'node:fs'; |
| 63 | import path from 'node:path'; |
| 64 | import { fileURLToPath } from 'node:url'; |
| 65 | import { open, shot, newChat, errors } from './harness.mjs'; |
| 66 | |
| 67 | const HERE = path.dirname(fileURLToPath(import.meta.url)); |
| 68 | const WWW = path.join(HERE, '..', 'www'); |
| 69 | |
| 70 | const BREAK = (() => { |
| 71 | const i = process.argv.indexOf('--break'); |
| 72 | return i > 0 ? String(process.argv[i + 1] || '') : ''; |
| 73 | })(); |
| 74 | |
| 75 | const BREAKS = { |
| 76 | // The control is offered no matter what -- so it would sit on a row even |
| 77 | // with nothing in focus, which the contract says must never happen. |
| 78 | hidden: { |
| 79 | file: 'js/daimond.js', |
| 80 | find: ` function attachFocus() { |
| 81 | if (currentDiamond) return { kind: 'diamond', id: currentDiamond.id, name: currentDiamond.name }; |
| 82 | if (current && !current.diamondId) return { kind: 'chat', id: current.id }; |
| 83 | return null; |
| 84 | }`, |
| 85 | with: ` function attachFocus() { |
| 86 | if (currentDiamond) return { kind: 'diamond', id: currentDiamond.id, name: currentDiamond.name }; |
| 87 | if (current && !current.diamondId) return { kind: 'chat', id: current.id }; |
| 88 | return { kind: 'chat', id: 'ghost' }; |
| 89 | }`, |
| 90 | }, |
| 91 | // Pressing the paperclip on a Diamond in focus no longer writes the link. |
| 92 | diamondlink: { |
| 93 | file: 'js/daimond.js', |
| 94 | find: ` else await diamondApp().add_link(id, 'diamond:' + id, ref, 'holds', '', 'user');`, |
| 95 | with: ` else { /* the write silently does not happen */ }`, |
| 96 | }, |
| 97 | // A chat's scope is emptied when a turn is sent -- which is what the app used |
| 98 | // to do, and is now the bug: the user attaches a folder, asks a question, and |
| 99 | // the folder is gone before they can ask a second one. |
| 100 | chatclear: { |
| 101 | file: 'js/daimond.js', |
| 102 | find: ` text = conciseText(chat, text);`, |
| 103 | with: ` chat.holds = []; attachChanged(); persistChats(); |
| 104 | text = conciseText(chat, text);`, |
| 105 | }, |
| 106 | // No prefix is ever generated, so Note and Read stop doing anything a |
| 107 | // person can see. |
| 108 | prefix: { |
| 109 | file: 'js/daimond.js', |
| 110 | find: ` function attachPrefixText(list) { |
| 111 | var notes = [], reads = []; |
| 112 | (list || []).forEach(function (a) { |
| 113 | if (!refReachable(a.ref)) return; |
| 114 | (a.state === 'read' ? reads : notes).push(a.path); |
| 115 | }); |
| 116 | var out = []; |
| 117 | if (notes.length) out.push(t('attach.prefix_note', { paths: notes.join(', ') })); |
| 118 | if (reads.length) out.push(t('attach.prefix_read', { paths: reads.join(', ') })); |
| 119 | return out.length ? out.join('\\n') + '\\n' : ''; |
| 120 | }`, |
| 121 | with: ` function attachPrefixText(list) { |
| 122 | return ''; |
| 123 | }`, |
| 124 | }, |
| 125 | // A fresh attachment starts as Read, which spends the user's money without |
| 126 | // being asked -- the wrong default the contract exists to prevent. |
| 127 | notedefault: { |
| 128 | file: 'js/daimond.js', |
| 129 | find: ` list.push({ ref: ref, dir: !!dir, path: path, state: 'note' });`, |
| 130 | with: ` list.push({ ref: ref, dir: !!dir, path: path, state: 'read' });`, |
| 131 | }, |
| 132 | // The cap is taken off the stack, so the footer grows with every tile and |
| 133 | // eats the panel above it -- the state stage two exists to end. |
| 134 | nocap: { |
| 135 | file: 'css/app.css', |
| 136 | find: `.attach-body { max-height: calc(var(--attach-cap) * var(--attach-row-h) + var(--attach-peek)); |
| 137 | overflow-y: auto; scrollbar-gutter: stable; scrollbar-width: thin; }`, |
| 138 | with: `.attach-body { overflow-y: auto; }`, |
| 139 | }, |
| 140 | // The choice of view is never written down, so it lasts until the tab is |
| 141 | // reloaded and no longer. |
| 142 | viewforget: { |
| 143 | file: 'js/daimond.js', |
| 144 | find: ` try { localStorage.setItem(ATTACH_VIEW_KEY, attachViewNow); } |
| 145 | catch (e) { /* private mode: the choice holds for this session only */ }`, |
| 146 | with: ` /* the choice is never written down */`, |
| 147 | }, |
| 148 | // A tile that cannot be opened stops saying why, and is back to reading as |
| 149 | // an empty folder -- what §7 and §9 both exist to prevent. |
| 150 | // The anchor is the CHAT footer's line, which is the one 7b is about. It said |
| 151 | // `dws.not_here` until `daa66bf` (2026-08-12) moved the chat footer to its own |
| 152 | // wording, and after that the line it named appeared ZERO times in the tree, so |
| 153 | // this file exited 2 with "a break whose anchor does not match" -- carried for |
| 154 | // days as a red that was about a rename and not about the app. The surviving |
| 155 | // `dws.not_here` line is the CRYSTAL footer (a Diamond, its daimon), a |
| 156 | // different claim in a different place: do not point this at it. |
| 157 | silentaway: { |
| 158 | file: 'js/daimond.js', |
| 159 | find: ` reason: away ? t('attach.not_here', { where: refWhere(a.ref) }) : '',`, |
| 160 | with: ` reason: '',`, |
| 161 | }, |
| 162 | // The picker asks the Workspace panel's CACHE what is attached instead of |
| 163 | // asking the store. The cache is filled when the tree is listed, so |
| 164 | // anything attached since reads as unattached -- and a tick on one of those |
| 165 | // would have taken it off rather than put it on. |
| 166 | pickstale: { |
| 167 | file: 'js/daimond.js', |
| 168 | find: ` attached: attachedTruly,`, |
| 169 | with: ` attached: function (p, d) { return attachStateOf(p, !!d).on; },`, |
| 170 | }, |
| 171 | // The picker forgets what was ticked, so `+` opens, closes and attaches |
| 172 | // nothing. |
| 173 | pickdrops: { |
| 174 | file: 'js/daimond.js', |
| 175 | find: ` return { read: function () { return ticked; } };`, |
| 176 | with: ` return { read: function () { return {}; } };`, |
| 177 | }, |
| 178 | // The reachability guard is removed, so a path from a workspace that is not |
| 179 | // open goes into the prefix anyway -- a turn spent apologising. |
| 180 | unreachable: { |
| 181 | file: 'js/daimond.js', |
| 182 | find: ` (list || []).forEach(function (a) { |
| 183 | if (!refReachable(a.ref)) return; |
| 184 | (a.state === 'read' ? reads : notes).push(a.path); |
| 185 | });`, |
| 186 | with: ` (list || []).forEach(function (a) { |
| 187 | (a.state === 'read' ? reads : notes).push(a.path); |
| 188 | });`, |
| 189 | }, |
| 190 | }; |
| 191 | |
| 192 | if (BREAK && !BREAKS[BREAK]) { |
| 193 | console.error(`unknown break '${BREAK}'; one of: ${Object.keys(BREAKS).join(', ')}`); |
| 194 | process.exit(2); |
| 195 | } |
| 196 | |
| 197 | let ok = 0, bad = 0; |
| 198 | const check = (name, pass, detail) => { |
| 199 | if (pass) { ok++; console.log(` ok ${name}${detail ? ' — ' + detail : ''}`); } |
| 200 | else { bad++; console.log(` FAIL ${name}${detail ? ' — ' + detail : ''}`); } |
| 201 | }; |
| 202 | |
| 203 | // No fixed profile is wanted, and none is needed for the reload below: the |
| 204 | // harness opens a PERSISTENT context on a directory of its own, so |
| 205 | // localStorage survives a reload inside one run while still being this run's |
| 206 | // alone. |
| 207 | const s = await open({ name: 'attachfocus', signIn: false, connect: false }); |
| 208 | const { page } = s; |
| 209 | |
| 210 | /// Serve one deliberately damaged file in place of the real one. Installed |
| 211 | /// before the app is loaded, and again after any reload, because a route |
| 212 | /// only takes effect on a load that happens after it. |
| 213 | async function installBreak() { |
| 214 | if (!BREAK) return; |
| 215 | const spec = BREAKS[BREAK]; |
| 216 | const src = fs.readFileSync(path.join(WWW, spec.file), 'utf8'); |
| 217 | const n = src.split(spec.find).length - 1; |
| 218 | if (n !== 1) { |
| 219 | console.error(`break '${BREAK}': the anchor appears ${n} times in ${spec.file}, ` |
| 220 | + 'so nothing was broken and the run below would prove nothing.'); |
| 221 | process.exit(2); |
| 222 | } |
| 223 | const body = src.replace(spec.find, spec.with); |
| 224 | // A stylesheet served as JavaScript is dropped by the browser, and the run |
| 225 | // would then be testing an app with no CSS at all rather than the one break |
| 226 | // it names. |
| 227 | const type = /\.css$/.test(spec.file) ? 'text/css' : 'application/javascript'; |
| 228 | await page.route('**/' + spec.file, r => r.fulfill({ status: 200, contentType: type, body })); |
| 229 | } |
| 230 | await installBreak(); |
| 231 | |
| 232 | await page.goto(process.env.DAIMOND_APP || 'http://localhost:8777', { waitUntil: 'domcontentloaded' }); |
| 233 | const { signInAs, connectMock } = await import('./harness.mjs'); |
| 234 | await signInAs(s, 'attachfocus'); |
| 235 | await connectMock(s); |
| 236 | await page.waitForTimeout(1200); |
| 237 | |
| 238 | const T = (k, v) => page.evaluate(([k, v]) => DaimondI18n.t(k, v || undefined), [k, v || null]); |
| 239 | |
| 240 | // A file to point at, written the way a person's own would be. |
| 241 | await page.evaluate(async () => { |
| 242 | const m = await import('/pkg/oxedyne_daimond.js'); |
| 243 | const app = new m.DaimondApp('http://127.0.0.1/v1/chat/completions', '', 'none', 4096, '', true); |
| 244 | await app.run_tool('file_write', JSON.stringify({ path: 'notes/spec.md', content: '# Spec\n' })); |
| 245 | await app.run_tool('file_write', JSON.stringify({ path: 'docs/plan.md', content: '# Plan\n' })); |
| 246 | }); |
| 247 | await page.waitForTimeout(300); |
| 248 | |
| 249 | async function openWorkspace() { |
| 250 | await page.evaluate(() => window.DaimondPanels && DaimondPanels.show('work')); |
| 251 | await page.waitForTimeout(500); |
| 252 | await page.click('#panel-work [data-act="refresh"]', { force: true }).catch(() => {}); |
| 253 | await page.waitForTimeout(800); |
| 254 | } |
| 255 | |
| 256 | /// The row for a top-level entry by name, ignoring the leading glyph. |
| 257 | async function rowFor(name) { |
| 258 | for (const row of await page.$$('#panel-work .files-row')) { |
| 259 | const nm = await row.$eval('.files-name', e => e.textContent).catch(() => ''); |
| 260 | if (nm.replace(/^[^A-Za-z0-9._-]+/, '').trim() === name) return row; |
| 261 | } |
| 262 | return null; |
| 263 | } |
| 264 | |
| 265 | /// Back to the top of the tree and the file list, whatever a previous section |
| 266 | /// left it doing. "up" from a file view first closes the view; "up" from any |
| 267 | /// directory that is not root goes up one; both are no-ops once there, so a |
| 268 | /// few unconditional presses always land at the root. |
| 269 | async function gotoRoot() { |
| 270 | const back = await page.$('[data-act="back"]'); |
| 271 | if (back) { await back.click({ force: true }).catch(() => {}); await page.waitForTimeout(400); } |
| 272 | await page.fill('.files-filter-input', '').catch(() => {}); |
| 273 | // SHORT TIMEOUT, and it is not a nicety. Until 2026-08-24 "up" was a button in |
| 274 | // the panel header, always present, so a press at the root found a control and |
| 275 | // did nothing. It is a `..` row at the top of the listing now, and the root has |
| 276 | // no `..` -- so the last presses here find NOTHING, which is the correct state |
| 277 | // and is what this helper is driving towards. At Playwright's default that is |
| 278 | // thirty seconds of waiting per press before the `catch`, several times over. |
| 279 | for (let i = 0; i < 4; i++) { |
| 280 | await page.click('[data-act="up"]', { force: true, timeout: 1500 }).catch(() => {}); |
| 281 | await page.waitForTimeout(250); |
| 282 | } |
| 283 | } |
| 284 | |
| 285 | // ── 1. Nothing in focus: the control is offered nowhere ──────────────── |
| 286 | await openWorkspace(); |
| 287 | const docsRow0 = await rowFor('docs'); // a folder row, at the root |
| 288 | check('a folder row exists to check', !!docsRow0, docsRow0 ? '' : 'no "docs" row'); |
| 289 | const folderBtnHidden = docsRow0 |
| 290 | ? await docsRow0.$eval('.attach-btn', b => b.style.display === 'none').catch(() => 'no .attach-btn at all') |
| 291 | : 'no row'; |
| 292 | check('WITH NOTHING IN FOCUS, the folder row’s control is hidden', folderBtnHidden === true, |
| 293 | JSON.stringify(folderBtnHidden)); |
| 294 | |
| 295 | // Into "notes" for the file row -- the gap this work closes. |
| 296 | const notesRow0 = await rowFor('notes'); |
| 297 | if (notesRow0) { await notesRow0.$eval('.files-name', e => e.click()); await page.waitForTimeout(600); } |
| 298 | const specRow0 = await rowFor('spec.md'); |
| 299 | check('a file row exists to check', !!specRow0, specRow0 ? '' : 'no "spec.md" row'); |
| 300 | const fileBtnHidden = specRow0 |
| 301 | ? await specRow0.$eval('.attach-btn', b => b.style.display === 'none').catch(() => 'no .attach-btn at all') |
| 302 | : 'no row'; |
| 303 | check('WITH NOTHING IN FOCUS, the FILE row’s control is hidden too', fileBtnHidden === true, |
| 304 | JSON.stringify(fileBtnHidden)); |
| 305 | |
| 306 | // Open the file itself: the Doc header's control. |
| 307 | if (specRow0) { await specRow0.$eval('.files-name', e => e.click()); await page.waitForTimeout(700); } |
| 308 | const docBtnHidden = await page.$eval('[data-act="attach"]', b => b.style.display === 'none').catch(() => 'not found'); |
| 309 | check('WITH NOTHING IN FOCUS, the Doc header’s control is hidden', docBtnHidden === true, |
| 310 | JSON.stringify(docBtnHidden)); |
| 311 | await shot(s, 'attachfocus-1-nothing-in-focus'); |
| 312 | await gotoRoot(); |
| 313 | |
| 314 | // ── A Diamond, and a chat, to focus on in turn ────────────────────────── |
| 315 | await page.click('#new-diamond-btn', { force: true }); |
| 316 | await page.waitForSelector('.dlg-input', { timeout: 10000 }); |
| 317 | await page.fill('.dlg-input', 'Ship a CSV parser'); |
| 318 | await page.click('.dlg-ok', { force: true }); |
| 319 | await page.waitForTimeout(900); |
| 320 | const diamondId = await page.evaluate(async () => { |
| 321 | const m = await import('/pkg/oxedyne_daimond.js'); |
| 322 | const app = new m.DaimondApp('http://127.0.0.1/v1/chat/completions', '', 'none', 4096, '', true); |
| 323 | const rows = JSON.parse(await app.list_diamonds()); |
| 324 | return rows.find(d => d.name === 'Ship a CSV parser').id; |
| 325 | }); |
| 326 | check('a Diamond to focus on', !!diamondId, diamondId); |
| 327 | |
| 328 | // ── 2. A Diamond in focus: the FOLDER row's paperclip writes a `holds` ── |
| 329 | // link carrying the workspace -- the FILE case is already proved end to end |
| 330 | // by dev/verify_hold.mjs, so this covers the folder row through the same |
| 331 | // unified control (ATTACH_CONTRACT.md §4 says it is one control). |
| 332 | await openWorkspace(); |
| 333 | const docsRow = await rowFor('docs'); |
| 334 | check('the "docs" row is offered now a Diamond is in focus', !!docsRow, docsRow ? '' : 'no row'); |
| 335 | if (docsRow) { |
| 336 | const shown = await docsRow.$eval('.attach-btn', b => b.style.display !== 'none').catch(() => false); |
| 337 | check('and its control is no longer hidden', shown === true, String(shown)); |
| 338 | await docsRow.$eval('.attach-btn', b => b.click()); |
| 339 | await page.waitForTimeout(900); |
| 340 | } |
| 341 | const dLinks = await page.evaluate(async (id) => { |
| 342 | const m = await import('/pkg/oxedyne_daimond.js'); |
| 343 | const app = new m.DaimondApp('http://127.0.0.1/v1/chat/completions', '', 'none', 4096, '', true); |
| 344 | return JSON.parse(await app.links_touching('diamond:' + id) || '[]'); |
| 345 | }, diamondId); |
| 346 | const docsLink = dLinks.find(l => /docs$/.test(String(l.other)) && l.other.indexOf('dir:') === 0); |
| 347 | check('WITH A DIAMOND IN FOCUS the folder writes a `holds` link', !!docsLink && docsLink.rel === 'holds', |
| 348 | JSON.stringify(docsLink)); |
| 349 | check('carrying the workspace it was made in', !!docsLink && /^dir:\[browser\]docs$/.test(docsLink.other), |
| 350 | docsLink && docsLink.other); |
| 351 | await shot(s, 'attachfocus-2-diamond-folder-row'); |
| 352 | |
| 353 | // ── §5. The crystal footer is the SAME component as the chat's ───────── |
| 354 | // Not "looks like": the same chrome, drawn by the same function. A few more |
| 355 | // links so the stack is worth looking at, then the strip that opens it. |
| 356 | await page.evaluate(async ([id, paths]) => { |
| 357 | const m = await import('/pkg/oxedyne_daimond.js'); |
| 358 | const app = new m.DaimondApp('http://127.0.0.1/v1/chat/completions', '', 'none', 4096, '', true); |
| 359 | for (const p of paths) await app.add_link(id, 'diamond:' + id, 'file:[browser]' + p, 'holds', '', 'user'); |
| 360 | // One from the other workspace, so a shut tile is in the picture here too. |
| 361 | await app.add_link(id, 'diamond:' + id, 'dir:[machine:elsewhere]books/x', 'holds', '', 'user'); |
| 362 | }, [diamondId, ['notes/spec.md', 'docs/plan.md']]); |
| 363 | await page.evaluate(() => window.DaimondArtefacts.render()); |
| 364 | await page.waitForTimeout(400); |
| 365 | await page.evaluate(() => { const b = document.getElementById('arte-strip'); if (b) b.click(); }); |
| 366 | await page.waitForTimeout(600); |
| 367 | const crystalStack = await page.evaluate(() => ({ |
| 368 | head: !!document.querySelector('#arte-list .attach-head'), |
| 369 | view: (document.querySelector('#arte-list [data-act="attach-view"]') || {}).textContent || '', |
| 370 | add: !!document.querySelector('#arte-list [data-act="attach-add"]'), |
| 371 | box: Math.round((document.querySelector('#arte-list .attach-body') || { getBoundingClientRect: () => ({ height: 0 }) }).getBoundingClientRect().height), |
| 372 | tiles: [...document.querySelectorAll('#arte-list .arte-row')].map(r => ({ |
| 373 | path: (r.querySelector('.arte-open') || {}).textContent || '', |
| 374 | state: (r.querySelector('.attach-state') || {}).textContent || '', |
| 375 | why: (r.querySelector('.arte-why') || {}).textContent || '', |
| 376 | })), |
| 377 | })); |
| 378 | check('THE CRYSTAL FOOTER WEARS THE SAME CHROME: a view toggle and a `+`', |
| 379 | crystalStack.head && crystalStack.add && crystalStack.view === await T('attach.view_icons'), |
| 380 | JSON.stringify({ head: crystalStack.head, view: crystalStack.view, add: crystalStack.add })); |
| 381 | const wordNote = await T('attach.note'); |
| 382 | check('and its tile for notes/spec.md reads Note', |
| 383 | !!crystalStack.tiles.find(t => t.path === 'notes/spec.md' && t.state === wordNote), |
| 384 | JSON.stringify(crystalStack.tiles)); |
| 385 | check('and its tile for the folder that is elsewhere says so on the tile', |
| 386 | !!crystalStack.tiles.find(t => t.path === 'books/x' && /elsewhere/.test(t.why)), |
| 387 | JSON.stringify(crystalStack.tiles.map(t => [t.path, t.why.slice(0, 40)]))); |
| 388 | check('and it is capped by the same rule the chat footer is', |
| 389 | crystalStack.box > 0 && crystalStack.box <= 260, String(crystalStack.box)); |
| 390 | await shot(s, 'attachfocus-2b-crystal-footer-stack'); |
| 391 | await page.click('#arte-list [data-act="attach-view"]', { force: true }); |
| 392 | await page.waitForTimeout(500); |
| 393 | const crystalIcons = await page.evaluate(() => [...document.querySelectorAll('#arte-list .attach-icon')].map(c => ({ |
| 394 | name: (c.querySelector('.arte-open') || {}).textContent || '', |
| 395 | state: (c.querySelector('.attach-state') || {}).textContent || '', |
| 396 | shut: c.classList.contains('shut'), |
| 397 | }))); |
| 398 | check('AND THE ICON VIEW IS THE ONE CONTROL, not one per footer -- the crystal turns too', |
| 399 | !!crystalIcons.find(c => c.name === 'spec.md' && c.state === wordNote), |
| 400 | JSON.stringify(crystalIcons)); |
| 401 | check('with the unreachable one still marked as shut', !!crystalIcons.find(c => c.name === 'x' && c.shut), |
| 402 | JSON.stringify(crystalIcons)); |
| 403 | await shot(s, 'attachfocus-2c-crystal-footer-icons'); |
| 404 | await page.click('#arte-list [data-act="attach-view"]', { force: true }); |
| 405 | await page.waitForTimeout(400); |
| 406 | |
| 407 | // ── §5. The `+` never takes anything OFF ─────────────────────────────── |
| 408 | // The two links above were written straight to the store, after the Workspace |
| 409 | // panel had listed its tree — so the panel's cached idea of what this Diamond |
| 410 | // holds is out of date, which is exactly the state in which a picker that |
| 411 | // consults the cache shows an attached file as unattached. Ticking one would |
| 412 | // then have DETACHED it. The row must be ticked and fixed. |
| 413 | await page.click('#arte-list [data-act="attach-add"]', { force: true }); |
| 414 | await page.waitForSelector('.attach-pick-row', { timeout: 10000 }); |
| 415 | for (const b of await page.$$('.attach-pick-name.dir')) { |
| 416 | if (/notes/.test(await b.textContent())) { await b.click(); break; } |
| 417 | } |
| 418 | await page.waitForTimeout(700); |
| 419 | const stalePick = await page.$$eval('.attach-pick-row', rows => rows.map(r => ({ |
| 420 | name: r.querySelector('.attach-pick-name').textContent.replace(/^\S+\s/, ''), |
| 421 | ticked: r.querySelector('input').checked, |
| 422 | fixed: r.querySelector('input').disabled, |
| 423 | }))); |
| 424 | const specPick = stalePick.find(r => r.name === 'spec.md'); |
| 425 | check('THE PICKER KNOWS notes/spec.md IS ALREADY HELD, and will not offer to undo it', |
| 426 | !!specPick && specPick.ticked && specPick.fixed, JSON.stringify(stalePick)); |
| 427 | await page.click('.dlg-cancel', { force: true }); |
| 428 | await page.waitForTimeout(400); |
| 429 | |
| 430 | // Take it off again, so it does not linger into the chat scenario below. |
| 431 | await page.evaluate(async (id) => { |
| 432 | const m = await import('/pkg/oxedyne_daimond.js'); |
| 433 | const app = new m.DaimondApp('http://127.0.0.1/v1/chat/completions', '', 'none', 4096, '', true); |
| 434 | const links = JSON.parse(await app.links_touching('diamond:' + id) || '[]'); |
| 435 | for (const l of links) await app.remove_link(l.owner, l.id).catch(() => {}); |
| 436 | }, diamondId); |
| 437 | // Attaching a folder to a brand-new Diamond is ALSO "a new agent being |
| 438 | // initialised" (§6), so it seeded the ONE shared composer with its own |
| 439 | // prefix. That is correct behaviour -- an unsent draft is not this app's to |
| 440 | // discard on a mere switch -- but it is not what properties 3-5 below are |
| 441 | // about, so the draft is cleared explicitly here, the way a person would |
| 442 | // before starting a different conversation. |
| 443 | await page.fill('#chat-input', ''); |
| 444 | |
| 445 | // ── An ordinary chat, in focus from here on ───────────────────────────── |
| 446 | await newChat(s); |
| 447 | await page.waitForTimeout(400); |
| 448 | const chatFocus = await page.evaluate(() => window.DaimondAttach.focus()); |
| 449 | check('a chat is now the focus', chatFocus && chatFocus.kind === 'chat', JSON.stringify(chatFocus)); |
| 450 | const chatId = chatFocus && chatFocus.id; |
| 451 | |
| 452 | // ── 3. A chat in focus: the control writes NOTHING to the store ──────── |
| 453 | // Counted before and after, as a DELTA -- an account can carry seeded default |
| 454 | // Diamonds with links of their own, so "zero links anywhere" is the wrong |
| 455 | // oracle; "no MORE links than before" is the one this property actually |
| 456 | // makes. |
| 457 | const totalDiamondLinks = () => page.evaluate(async () => { |
| 458 | const m = await import('/pkg/oxedyne_daimond.js'); |
| 459 | const app = new m.DaimondApp('http://127.0.0.1/v1/chat/completions', '', 'none', 4096, '', true); |
| 460 | const rows = JSON.parse(await app.list_diamonds()); |
| 461 | let total = 0; |
| 462 | for (const d of rows) total += JSON.parse(await app.links_touching('diamond:' + d.id) || '[]').length; |
| 463 | return total; |
| 464 | }); |
| 465 | const linksBeforeChatAttach = await totalDiamondLinks(); |
| 466 | |
| 467 | await openWorkspace(); |
| 468 | const specRow = await rowFor('spec.md'); |
| 469 | if (!specRow) { |
| 470 | // The tree may still be at the top level; open "notes" first. |
| 471 | const notesRow = await rowFor('notes'); |
| 472 | if (notesRow) { await notesRow.$eval('.files-name', e => e.click()); await page.waitForTimeout(600); } |
| 473 | } |
| 474 | const specRow2 = (await rowFor('spec.md')) || specRow; |
| 475 | check('the file row is reachable to attach', !!specRow2, specRow2 ? '' : 'no row'); |
| 476 | if (specRow2) { await specRow2.$eval('.attach-btn', b => b.click()); await page.waitForTimeout(600); } |
| 477 | |
| 478 | const linksAfterChatAttach = await totalDiamondLinks(); |
| 479 | check('WITH A CHAT IN FOCUS, attaching writes NOTHING to any Diamond’s store', |
| 480 | linksAfterChatAttach === linksBeforeChatAttach, |
| 481 | `${linksBeforeChatAttach} link(s) before, ${linksAfterChatAttach} after`); |
| 482 | |
| 483 | const footerShown = await page.$eval('#chat-attachments', e => e.style.display !== 'none'); |
| 484 | check('and it shows in the chat footer instead', footerShown === true, String(footerShown)); |
| 485 | const tileText = await page.$eval('#chat-attachments .arte-open', e => e.textContent); |
| 486 | check('the tile names the path', /notes\/spec\.md/.test(tileText), tileText); |
| 487 | await shot(s, 'attachfocus-3-chat-footer'); |
| 488 | |
| 489 | // The Doc header's own paperclip, LIT this time -- shot 1 only proved it |
| 490 | // hidden. Same file, same chat still in focus, opened from its row. |
| 491 | if (specRow2) { await specRow2.$eval('.files-name', e => e.click()); await page.waitForTimeout(700); } |
| 492 | const docBtnLit = await page.$eval('[data-act="attach"]', b => ({ |
| 493 | shown: b.style.display !== 'none', on: b.classList.contains('on'), title: b.title, |
| 494 | })).catch(() => null); |
| 495 | check('the Doc header’s control is offered and lit with the chat in focus', |
| 496 | !!docBtnLit && docBtnLit.shown && docBtnLit.on, JSON.stringify(docBtnLit)); |
| 497 | check('and says the same thing as the row did', docBtnLit && docBtnLit.title === await T('attach.to_focus'), |
| 498 | docBtnLit && docBtnLit.title); |
| 499 | await shot(s, 'attachfocus-3b-doc-header-lit'); |
| 500 | if (await page.$('[data-act="back"]')) { await page.click('[data-act="back"]', { force: true }); await page.waitForTimeout(400); } |
| 501 | |
| 502 | // ── 5. Note is what an attachment starts as ───────────────────────────── |
| 503 | const startState = await page.$eval('#chat-attachments .attach-state', e => ({ |
| 504 | text: e.textContent, read: e.classList.contains('read'), |
| 505 | })); |
| 506 | check('THE TILE STARTS AS "Note"', startState.read === false, JSON.stringify(startState)); |
| 507 | check('in the app’s own word for it', startState.text === await T('attach.note'), startState.text); |
| 508 | |
| 509 | // ── 4. Note generates the note prefix, editable in the composer ──────── |
| 510 | const notePrefix = await T('attach.prefix_note', { paths: 'notes/spec.md' }); |
| 511 | let composerVal = await page.$eval('#chat-input', e => e.value); |
| 512 | check('NOTE PUTS THE NOTE PREFIX IN THE COMPOSER', composerVal.indexOf(notePrefix) === 0, |
| 513 | `want prefix "${notePrefix}", got "${composerVal}"`); |
| 514 | |
| 515 | // Flip it to Read on the tile. |
| 516 | await page.click('#chat-attachments .attach-state', { force: true }); |
| 517 | await page.waitForTimeout(400); |
| 518 | const readState = await page.$eval('#chat-attachments .attach-state', e => ({ |
| 519 | text: e.textContent, read: e.classList.contains('read'), |
| 520 | })); |
| 521 | check('the tile now reads Read', readState.read === true && readState.text === await T('attach.read'), |
| 522 | JSON.stringify(readState)); |
| 523 | const readPrefix = await T('attach.prefix_read', { paths: 'notes/spec.md' }); |
| 524 | composerVal = await page.$eval('#chat-input', e => e.value); |
| 525 | check('AND THE COMPOSER NOW CARRIES THE READ PREFIX', composerVal.indexOf(readPrefix) === 0, |
| 526 | `want prefix "${readPrefix}", got "${composerVal}"`); |
| 527 | |
| 528 | // It is ordinary, editable text: typing after it is kept. Control+End, not |
| 529 | // End -- the prefix carries its own trailing newline, so a plain End only |
| 530 | // reaches the end of the FIRST line of a two-line box. Compared against what |
| 531 | // was actually there a moment ago, not a re-derived string: the prefix's |
| 532 | // trailing newline is the generator's choice to keep or drop, not this |
| 533 | // check's to assume. |
| 534 | const beforeTyping = composerVal; |
| 535 | await page.click('#chat-input'); |
| 536 | await page.keyboard.press('Control+End'); |
| 537 | await page.keyboard.type('please'); |
| 538 | composerVal = await page.$eval('#chat-input', e => e.value); |
| 539 | check('IT IS VISIBLE, EDITABLE TEXT -- typing after it is kept, not overwritten', |
| 540 | composerVal === beforeTyping + 'please', JSON.stringify(composerVal)); |
| 541 | |
| 542 | // ── 7. An unreachable attachment is never put into the generated prefix ─ |
| 543 | // Written directly through the test surface, exactly as verify_attachroot.mjs |
| 544 | // writes a cross-root Diamond link directly: the native folder picker that |
| 545 | // makes one for real cannot be driven headless. |
| 546 | await page.evaluate((id) => window.DaimondAttach.chatToggle(id, 'dir:[machine:elsewhere]nope', true, 'nope'), chatId); |
| 547 | await page.waitForTimeout(300); |
| 548 | const awayTile = await page.$$eval('#chat-attachments .arte-row', rows => rows.map(r => ({ |
| 549 | text: (r.querySelector('.arte-open') || {}).textContent || '', |
| 550 | away: !!(r.querySelector('.arte-open.away')), |
| 551 | }))); |
| 552 | const stray = awayTile.find(t => /nope/.test(t.text)); |
| 553 | check('the unreachable one is still SHOWN, marked away', !!stray && stray.away === true, JSON.stringify(awayTile)); |
| 554 | composerVal = await page.$eval('#chat-input', e => e.value); |
| 555 | check('BUT IT IS NOT IN THE GENERATED PREFIX', !/nope/.test(composerVal), JSON.stringify(composerVal)); |
| 556 | |
| 557 | // ── 7b. And it SAYS WHY, in words on the tile ────────────────────────── |
| 558 | // A hover title is not available to a touch, and an item that is silently |
| 559 | // inert on a phone is the empty-folder failure this exists to end. §9 wants |
| 560 | // the same capability for a Diamond in the trash, so it is the tile that |
| 561 | // carries it, not the away case. |
| 562 | const shutTile = await page.$$eval('#chat-attachments .arte-row', rows => rows.map(r => ({ |
| 563 | text: (r.querySelector('.arte-open') || {}).textContent || '', |
| 564 | why: (r.querySelector('.arte-why') || {}).textContent || '', |
| 565 | // A `span`, not a `button`: there is nothing to press, and offering a |
| 566 | // press that cannot work is the distrust §4 warns about. |
| 567 | openable: !!r.querySelector('button.arte-open'), |
| 568 | }))).then(rows => rows.find(r => /nope/.test(r.text))); |
| 569 | // `attach.not_here`, and NOT `dws.not_here`. The app moved this footer to its own |
| 570 | // wording in `daa66bf` (2026-08-12), with the reason in the code beside it: |
| 571 | // `dws.not_here` says "kept with this Diamond" and "its daimon", which names the |
| 572 | // wrong thing entirely on a surface where there is no Diamond and no daimon. Both |
| 573 | // keys are alive and both are right where they are used -- the crystal footer |
| 574 | // still says the Diamond's words -- so this is the app being careful, not a |
| 575 | // rename to follow blindly. Comparing the chat footer against the Diamond wording |
| 576 | // failed this check every run since that commit. |
| 577 | const notHere = await T('attach.not_here', { where: await T('dws.in_machine', { name: 'elsewhere' }) }); |
| 578 | check('THE SHUT TILE SAYS WHY IN WORDS ON THE TILE, not only on hover', |
| 579 | !!shutTile && shutTile.why === notHere, JSON.stringify(shutTile && shutTile.why)); |
| 580 | check('and offers no way to open what cannot be opened', |
| 581 | !!shutTile && shutTile.openable === false, JSON.stringify(shutTile)); |
| 582 | await shot(s, 'attachfocus-5-shut-tile'); |
| 583 | // Take it off again so it does not confuse the checks below. |
| 584 | await page.evaluate((id) => window.DaimondAttach.chatToggle(id, 'dir:[machine:elsewhere]nope', true, 'nope'), chatId); |
| 585 | await page.waitForTimeout(300); |
| 586 | |
| 587 | // ── §5. The view toggle: the stack, or rows of sizeable icons ────────── |
| 588 | // One control, two positions, and its word is the view it will GIVE you. |
| 589 | const viewBtn = '#chat-attachments [data-act="attach-view"]'; |
| 590 | const toIcons = await page.$eval(viewBtn, b => ({ text: b.textContent, view: b.dataset.view })); |
| 591 | check('the footer offers ONE view control, which offers the icon view', |
| 592 | toIcons.view === 'icons' && toIcons.text === await T('attach.view_icons'), JSON.stringify(toIcons)); |
| 593 | await page.click(viewBtn, { force: true }); |
| 594 | await page.waitForTimeout(400); |
| 595 | // Assert MEANING: the tile for notes/spec.md is now an icon, its name is the |
| 596 | // leaf of that path, and it still reads Read -- the state survives the view, |
| 597 | // because the view is how it is drawn and not what it is. |
| 598 | const iconTile = await page.$$eval('#chat-attachments .attach-icon', cells => cells.map(c => ({ |
| 599 | name: (c.querySelector('.arte-open') || {}).textContent || '', |
| 600 | state: (c.querySelector('.attach-state') || {}).textContent || '', |
| 601 | w: Math.round(c.getBoundingClientRect().width), h: Math.round(c.getBoundingClientRect().height), |
| 602 | }))).then(cells => cells.find(c => c.name === 'spec.md')); |
| 603 | check('IN THE ICON VIEW, the tile for notes/spec.md is an icon named spec.md', |
| 604 | !!iconTile, JSON.stringify(iconTile)); |
| 605 | check('and it still reads Read', !!iconTile && iconTile.state === await T('attach.read'), |
| 606 | JSON.stringify(iconTile && iconTile.state)); |
| 607 | // "sizeable icons -- not button-sized" (§5). The buttons beside them on a tile |
| 608 | // are 26px, so anything in that neighbourhood has missed the point. |
| 609 | check('and the icon is SIZEABLE -- not button-sized', |
| 610 | !!iconTile && iconTile.w >= 64 && iconTile.h >= 64, JSON.stringify(iconTile && [iconTile.w, iconTile.h])); |
| 611 | const stackGone = await page.$$eval('#chat-attachments .arte-row', r => r.length); |
| 612 | check('the stack is not drawn underneath it as well', stackGone === 0, String(stackGone)); |
| 613 | await shot(s, 'attachfocus-6-icon-view'); |
| 614 | |
| 615 | // Back to the stack for the measurements below, which are in tiles. That the |
| 616 | // choice is REMEMBERED is proved at the end of this file, through a reload. |
| 617 | await page.click(viewBtn, { force: true }); |
| 618 | await page.waitForTimeout(400); |
| 619 | |
| 620 | // ── §5. The `+`: attach without leaving what you are reading ─────────── |
| 621 | // docs/plan.md is not attached and is in another folder, so the picker has to |
| 622 | // be walked into rather than merely opened. |
| 623 | await page.click('#chat-attachments [data-act="attach-add"]', { force: true }); |
| 624 | await page.waitForSelector('.attach-pick-row', { timeout: 10000 }); |
| 625 | for (const b of await page.$$('.attach-pick-name.dir')) { |
| 626 | if (/docs/.test(await b.textContent())) { await b.click(); break; } |
| 627 | } |
| 628 | await page.waitForTimeout(700); |
| 629 | const pickRows = await page.$$eval('.attach-pick-row', rows => rows.map(r => ({ |
| 630 | name: r.querySelector('.attach-pick-name').textContent.replace(/^\S+\s/, ''), |
| 631 | fixed: r.querySelector('input').disabled, |
| 632 | }))); |
| 633 | check('the picker walks into a folder and lists what is in it', |
| 634 | pickRows.some(r => r.name === 'plan.md'), JSON.stringify(pickRows)); |
| 635 | for (const r of await page.$$('.attach-pick-row')) { |
| 636 | if (/plan\.md/.test(await r.$eval('.attach-pick-name', e => e.textContent))) { |
| 637 | await r.$eval('input', i => i.click()); |
| 638 | break; |
| 639 | } |
| 640 | } |
| 641 | await shot(s, 'attachfocus-7-picker'); |
| 642 | await page.click('.dlg-ok', { force: true }); |
| 643 | await page.waitForTimeout(900); |
| 644 | const afterPick = await page.$$eval('#chat-attachments .arte-row', rows => rows.map(r => ({ |
| 645 | path: (r.querySelector('.arte-open') || {}).textContent || '', |
| 646 | state: (r.querySelector('.attach-state') || {}).textContent || '', |
| 647 | }))); |
| 648 | const planTile = afterPick.find(r => r.path === 'docs/plan.md'); |
| 649 | check('THE `+` ATTACHES WHAT WAS TICKED -- the tile for docs/plan.md is there', |
| 650 | !!planTile, JSON.stringify(afterPick)); |
| 651 | // Note is the default, and a route in that quietly chose Read would be the |
| 652 | // one that spends the user's money unasked. |
| 653 | check('and it reads Note, like anything else newly attached', |
| 654 | !!planTile && planTile.state === await T('attach.note'), JSON.stringify(planTile)); |
| 655 | const afterPickComposer = await page.$eval('#chat-input', e => e.value); |
| 656 | check('and the generated prefix names that path', |
| 657 | /docs\/plan\.md/.test(afterPickComposer), JSON.stringify(afterPickComposer)); |
| 658 | |
| 659 | // ── 6. Past the cap the footer scrolls, and the composer does not move ─ |
| 660 | // Twenty-two files, so the stack is well past six either way. They are |
| 661 | // REACHABLE, so every tile is an ordinary one tile tall and the cap is being |
| 662 | // measured against the thing it is expressed in. |
| 663 | const many = []; |
| 664 | for (let i = 0; i < 22; i++) many.push(`bulk/item-${String(i).padStart(2, '0')}.md`); |
| 665 | await page.evaluate(async (many) => { |
| 666 | const m = await import('/pkg/oxedyne_daimond.js'); |
| 667 | const app = new m.DaimondApp('http://127.0.0.1/v1/chat/completions', '', 'none', 4096, '', true); |
| 668 | for (const p of many) await app.run_tool('file_write', JSON.stringify({ path: p, content: '# x\n' })); |
| 669 | }, many); |
| 670 | |
| 671 | /// Everything the cap is about, in one read: the footer's own box, what is |
| 672 | /// left of the conversation above it, and where the composer sits. |
| 673 | // THE FOOTER'S SCROLLING BOX IS NAMED TWO WAYS in the three readers below, and |
| 674 | // the pair is spelled out in each rather than hoisted: these run inside |
| 675 | // `page.evaluate`, in the browser, where a constant declared in this process is |
| 676 | // not defined. It was `.attach-body` while the chat footer had two groups and |
| 677 | // the lower one was the ordinary list; since 2026-08-24 there is ONE list and it |
| 678 | // is the workspace box, `.ws-body`. What they measure -- the footer caps and |
| 679 | // scrolls rather than pushing the composer off the screen -- has not moved, so |
| 680 | // the selector follows it rather than a check being dropped. |
| 681 | const geom = () => page.evaluate(() => { |
| 682 | const body = document.querySelector( |
| 683 | '#chat-attachments .attach-body, #chat-attachments .ws-body'); |
| 684 | const bar = document.querySelector('.chat-input-bar'); |
| 685 | const out = document.querySelector('.chat-output'); |
| 686 | const r = el => el ? el.getBoundingClientRect() : { height: 0, top: 0 }; |
| 687 | return { |
| 688 | box: Math.round(r(body).height), |
| 689 | inside: body ? body.scrollHeight : 0, |
| 690 | tiles: document.querySelectorAll('#chat-attachments .arte-row').length, |
| 691 | talk: Math.round(r(out).height), |
| 692 | bar: Math.round(r(bar).top), |
| 693 | barH: Math.round(r(bar).height), |
| 694 | }; |
| 695 | }); |
| 696 | |
| 697 | /// Attach `many[from..to)` -- a RANGE, because the control is a toggle and |
| 698 | /// re-attaching what is already attached takes it off again. |
| 699 | const attachRange = async (from, to) => { |
| 700 | for (const p of many.slice(from, to)) { |
| 701 | await page.evaluate(([id, p]) => window.DaimondAttach.chatToggle(id, `file:[browser]${p}`, false, p), [chatId, p]); |
| 702 | } |
| 703 | await page.waitForTimeout(500); |
| 704 | return geom(); |
| 705 | }; |
| 706 | const atSix = await attachRange(0, 6); |
| 707 | const atAll = await attachRange(6, 22); |
| 708 | check('twenty-two tiles are in the footer', atAll.tiles >= 22, JSON.stringify(atAll)); |
| 709 | check('PAST THE CAP THE FOOTER STOPS GROWING', |
| 710 | atAll.box === atSix.box, `${atSix.box}px at 6 tiles, ${atAll.box}px at 22`); |
| 711 | // The conversation and the composer measured TOGETHER: everything the panel |
| 712 | // has that is not the footer. Separately, the conversation shrinks here for an |
| 713 | // honest reason -- each further attachment lengthens the generated prefix, the |
| 714 | // prefix is in the textarea, and the textarea grows into the space above it. |
| 715 | // The PAIR is what the footer is taking, and past the cap it must stop taking. |
| 716 | check('and it stops taking room from the rest of the panel', |
| 717 | atAll.talk + atAll.barH === atSix.talk + atSix.barH, |
| 718 | `${atSix.talk}+${atSix.barH} at 6 tiles, ${atAll.talk}+${atAll.barH} at 22`); |
| 719 | check('AND IT SCROLLS: there is more inside the box than the box shows', |
| 720 | atAll.inside > atAll.box + 10, `${atAll.inside}px of tiles in a ${atAll.box}px box`); |
| 721 | |
| 722 | // Not "there is a scrollbar" but "the last tile can be got to". Measured |
| 723 | // against the box, twice: below it before scrolling, inside it after. |
| 724 | const lastTilePos = () => page.evaluate(() => { |
| 725 | const body = document.querySelector( |
| 726 | '#chat-attachments .attach-body, #chat-attachments .ws-body'); |
| 727 | const rows = [...body.querySelectorAll('.arte-row')]; |
| 728 | const last = rows.find(r => /item-21\.md/.test(r.textContent)); |
| 729 | if (!last) return null; |
| 730 | const b = body.getBoundingClientRect(), t = last.getBoundingClientRect(); |
| 731 | return { below: Math.round(t.top - b.bottom), scrollTop: Math.round(body.scrollTop) }; |
| 732 | }); |
| 733 | const before = await lastTilePos(); |
| 734 | check('the last tile starts out below the fold', !!before && before.below > 0, JSON.stringify(before)); |
| 735 | await page.evaluate(() => { |
| 736 | const body = document.querySelector( |
| 737 | '#chat-attachments .attach-body, #chat-attachments .ws-body'); |
| 738 | body.scrollTop = body.scrollHeight; |
| 739 | }); |
| 740 | await page.waitForTimeout(300); |
| 741 | const after = await lastTilePos(); |
| 742 | check('AND SCROLLING BRINGS IT INTO VIEW', !!after && after.scrollTop > 0 && after.below <= 0, |
| 743 | JSON.stringify(after)); |
| 744 | await shot(s, 'attachfocus-8-capped-and-scrolled'); |
| 745 | |
| 746 | // Everything the cap section queued, off again: what follows is about the |
| 747 | // composer alone, and then about the footer clearing. |
| 748 | for (const p of many) { |
| 749 | await page.evaluate(([id, p]) => window.DaimondAttach.chatToggle(id, `file:[browser]${p}`, false, p), [chatId, p]); |
| 750 | } |
| 751 | await page.evaluate((id) => window.DaimondAttach.chatToggle(id, 'file:[browser]docs/plan.md', false, 'docs/plan.md'), chatId); |
| 752 | await page.waitForTimeout(400); |
| 753 | |
| 754 | // ── 6 (second half). And the composer does not move ──────────────────── |
| 755 | // Measured against UNREACHABLE attachments, which §7 keeps out of the |
| 756 | // generated prefix: the footer fills, the composer's own text does not change, |
| 757 | // and anything that moved is then the footer's doing and nothing else. Without |
| 758 | // the cap, measured, the composer goes from y=841 to y=1444 -- off the bottom |
| 759 | // of the window. |
| 760 | const awayRange = async (from, to) => { |
| 761 | for (let i = from; i < to; i++) { |
| 762 | await page.evaluate(([id, i]) => window.DaimondAttach.chatToggle( |
| 763 | id, `dir:[machine:elsewhere]far/${i}`, true, `far/${i}`), [chatId, i]); |
| 764 | } |
| 765 | await page.waitForTimeout(400); |
| 766 | return geom(); |
| 767 | }; |
| 768 | const awaySix = await awayRange(0, 6); |
| 769 | const awayAll = await awayRange(6, 22); |
| 770 | const composerText = await page.$eval('#chat-input', e => e.value); |
| 771 | check('with the composer’s own text unchanged, twenty-two shut tiles are queued', |
| 772 | awayAll.tiles >= 22 && !/far\//.test(composerText), |
| 773 | `${awayAll.tiles} tiles, composer ${JSON.stringify(composerText)}`); |
| 774 | check('THE COMPOSER HAS NOT MOVED', awayAll.bar === awaySix.bar, |
| 775 | `${awaySix.bar} at 6 tiles, ${awayAll.bar} at 22`); |
| 776 | // Off again, leaving notes/spec.md queued -- the send check below is only |
| 777 | // worth anything with something in the footer to clear. |
| 778 | for (let i = 0; i < 22; i++) { |
| 779 | await page.evaluate(([id, i]) => window.DaimondAttach.chatToggle( |
| 780 | id, `dir:[machine:elsewhere]far/${i}`, true, `far/${i}`), [chatId, i]); |
| 781 | } |
| 782 | await page.waitForTimeout(400); |
| 783 | check('and one ordinary attachment is still queued, for the send check below', |
| 784 | await page.evaluate((id) => window.DaimondAttach.chatList(id).length, chatId) === 1); |
| 785 | |
| 786 | // ── 3 (second half). The scope SURVIVES the turn ─────────────────────── |
| 787 | // |
| 788 | // THIS CHECK USED TO ASSERT THE OPPOSITE, and the reversal is a decision and not |
| 789 | // a regression. ATTACH_CONTRACT.md §4 originally made a chat's attachment good |
| 790 | // for one turn: the footer was emptied at the send, and `clearChatAttach` did |
| 791 | // it. A chat now carries PERSISTENT SCOPE, exactly as a Diamond does — the user |
| 792 | // brings files into a conversation by hand and they stay there — because the |
| 793 | // paperclip cannot mean two different things on two surfaces and be understood |
| 794 | // on either. |
| 795 | // |
| 796 | // Two lifetimes, one meaning: a Diamond's holdings persist because a Diamond |
| 797 | // does, a chat's expire with the chat. So what is asserted here is that sending |
| 798 | // a turn is not an event that empties anything, and the old wording is left |
| 799 | // above so a reader who remembers the contract can see which way it went. |
| 800 | await page.fill('#chat-input', 'go'); |
| 801 | await page.click('#chat-send', { force: true }); |
| 802 | await page.waitForTimeout(1500); |
| 803 | const listAfterSend = await page.evaluate((id) => window.DaimondAttach.chatList(id).length, chatId); |
| 804 | check('A CHAT KEEPS WHAT IT HOLDS WHEN A TURN IS SENT', listAfterSend === 1, String(listAfterSend)); |
| 805 | const footerAfterSend = await page.$eval('#chat-attachments', e => e.style.display).catch(() => 'gone'); |
| 806 | check('and the footer still shows it, because it is still true', |
| 807 | footerAfterSend !== 'none', footerAfterSend); |
| 808 | await shot(s, 'attachfocus-4-kept-after-send'); |
| 809 | |
| 810 | // ── §5. The view is remembered PER USER, across a reload ─────────────── |
| 811 | // Last, because it is the only thing here that needs the page to go away and |
| 812 | // come back. Read afterwards through the app's own answer and through what |
| 813 | // the footer DRAWS, not by peeking at a key: a string in a store that nothing |
| 814 | // reads is not a remembered preference. |
| 815 | // Whatever the section above left behind -- a break may well have left the |
| 816 | // footer as it was -- there must be exactly something in it for the toggle to |
| 817 | // be on screen at all. Asked, not assumed: a run that dies here reports |
| 818 | // nothing, and a break that reports nothing proves nothing. |
| 819 | if (!await page.evaluate((id) => window.DaimondAttach.chatList(id).length, chatId)) { |
| 820 | await page.evaluate((id) => window.DaimondAttach.chatToggle(id, 'file:[browser]notes/spec.md', false, 'notes/spec.md'), chatId); |
| 821 | await page.waitForTimeout(400); |
| 822 | } |
| 823 | check('a footer is on screen to turn', await page.$(viewBtn) !== null); |
| 824 | await page.click(viewBtn, { force: true }).catch(() => {}); |
| 825 | await page.waitForTimeout(400); |
| 826 | check('the icon view is chosen before the reload', |
| 827 | await page.evaluate(() => window.DaimondAttach.view()) === 'icons'); |
| 828 | await installBreak(); // a route only bites on a load made after it |
| 829 | await page.reload({ waitUntil: 'domcontentloaded' }); |
| 830 | await signInAs(s, 'attachfocus'); |
| 831 | await page.waitForTimeout(1500); |
| 832 | check('THE VIEW SURVIVES A RELOAD', |
| 833 | await page.evaluate(() => window.DaimondAttach.view()) === 'icons', |
| 834 | await page.evaluate(() => window.DaimondAttach.view())); |
| 835 | // A NEW chat, asserted to be one. |
| 836 | // |
| 837 | // The app restores the chat that was open when it comes back, so `newChat` |
| 838 | // found a composer on screen and returned without making anything: `reborn` WAS |
| 839 | // the pre-reload chat, which already holds notes/spec.md. `chatToggle` is a |
| 840 | // toggle, so the line below then took the attachment OFF and the check read zero |
| 841 | // rows -- `{"icons":0,"rows":0,"name":""}`, reported for five gates as a footer |
| 842 | // that would not draw, with the app innocent throughout. The harness now makes a |
| 843 | // new chat or throws (see dev/harness.mjs); this says so out loud as well, |
| 844 | // because the id is what the rest of this section rests on. |
| 845 | const reborn = await newChat(s); |
| 846 | await page.waitForTimeout(600); |
| 847 | check('the reload is followed by a chat of its OWN, which holds nothing yet', |
| 848 | !!reborn && reborn !== chatId, |
| 849 | `before ${chatId}, after ${reborn}`); |
| 850 | await page.evaluate((id) => window.DaimondAttach.chatToggle(id, 'file:[browser]notes/spec.md', false, 'notes/spec.md'), reborn); |
| 851 | await page.waitForTimeout(500); |
| 852 | const drawnAfterReload = await page.evaluate(() => ({ |
| 853 | icons: document.querySelectorAll('#chat-attachments .attach-icon').length, |
| 854 | rows: document.querySelectorAll('#chat-attachments .arte-row').length, |
| 855 | name: (document.querySelector('#chat-attachments .attach-icon .arte-open') || {}).textContent || '', |
| 856 | })); |
| 857 | check('and a footer drawn AFTER it comes back in the icon view, naming spec.md', |
| 858 | drawnAfterReload.icons === 1 && drawnAfterReload.rows === 0 && drawnAfterReload.name === 'spec.md', |
| 859 | JSON.stringify(drawnAfterReload)); |
| 860 | await shot(s, 'attachfocus-9-view-after-reload'); |
| 861 | |
| 862 | // 502s are the local gateway proxy (/api) not running in this world -- a world |
| 863 | // is the browser tiers only, as dev/world.sh says in as many words. |
| 864 | // |
| 865 | // 401 IS NOT EXCLUDED, AND WAS. On 2026-08-24 this check went red four runs out |
| 866 | // of six on an unchanged tree, naming a session the page had never had: every |
| 867 | // world's `/api` was proxied to a fixed :9002, so whether the answer was 502 or |
| 868 | // 401 depended on which OTHER lane had a gateway up at that moment. The 401 went |
| 869 | // into the filter, which fixed the symptom in this file and in three others, and |
| 870 | // left two files without it -- a per-file remedy for a fault in `dev/world.sh`. |
| 871 | // |
| 872 | // The gateway is a world's own now (9700 + N) and no other world's is reachable |
| 873 | // from here, so a 401 on this page can only be a gateway THIS world started. That |
| 874 | // is the app being refused by something it asked, which is exactly what "nothing |
| 875 | // threw along the way" is for. Excluding it would now be a lie about what a 401 |
| 876 | // means. |
| 877 | const GATEWAY_NOISE = /502 \(Bad Gateway\)/; |
| 878 | const errs = errors(s).filter(e => !GATEWAY_NOISE.test(e)); |
| 879 | check('nothing threw along the way', errs.length === 0, errs.slice(0, 3).join(' | ')); |
| 880 | |
| 881 | console.log(`\n${ok} ok, ${bad} failed`); |
| 882 | if (BREAK) { |
| 883 | console.log(bad ? `break '${BREAK}' correctly failed ${bad} check(s)` |
| 884 | : `break '${BREAK}': NOTHING FAILED, so the checks above prove nothing`); |
| 885 | await s.close(); |
| 886 | process.exit(bad ? 0 : 1); // a break MUST fail something |
| 887 | } |
| 888 | await s.close(); |
| 889 | process.exit(bad ? 1 : 0); |