Oregami
Repositories/oxedyne/daimond

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
62import fs from 'node:fs';
63import path from 'node:path';
64import { fileURLToPath } from 'node:url';
65import { open, shot, newChat, errors } from './harness.mjs';
66
67const HERE = path.dirname(fileURLToPath(import.meta.url));
68const WWW = path.join(HERE, '..', 'www');
69
70const BREAK = (() => {
71 const i = process.argv.indexOf('--break');
72 return i > 0 ? String(process.argv[i + 1] || '') : '';
73})();
74
75const 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
192if (BREAK && !BREAKS[BREAK]) {
193 console.error(`unknown break '${BREAK}'; one of: ${Object.keys(BREAKS).join(', ')}`);
194 process.exit(2);
195}
196
197let ok = 0, bad = 0;
198const 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.
207const s = await open({ name: 'attachfocus', signIn: false, connect: false });
208const { 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.
213async 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}
230await installBreak();
231
232await page.goto(process.env.DAIMOND_APP || 'http://localhost:8777', { waitUntil: 'domcontentloaded' });
233const { signInAs, connectMock } = await import('./harness.mjs');
234await signInAs(s, 'attachfocus');
235await connectMock(s);
236await page.waitForTimeout(1200);
237
238const 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.
241await 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});
247await page.waitForTimeout(300);
248
249async 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.
257async 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.
269async 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 ────────────────
286await openWorkspace();
287const docsRow0 = await rowFor('docs'); // a folder row, at the root
288check('a folder row exists to check', !!docsRow0, docsRow0 ? '' : 'no "docs" row');
289const folderBtnHidden = docsRow0
290 ? await docsRow0.$eval('.attach-btn', b => b.style.display === 'none').catch(() => 'no .attach-btn at all')
291 : 'no row';
292check('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.
296const notesRow0 = await rowFor('notes');
297if (notesRow0) { await notesRow0.$eval('.files-name', e => e.click()); await page.waitForTimeout(600); }
298const specRow0 = await rowFor('spec.md');
299check('a file row exists to check', !!specRow0, specRow0 ? '' : 'no "spec.md" row');
300const fileBtnHidden = specRow0
301 ? await specRow0.$eval('.attach-btn', b => b.style.display === 'none').catch(() => 'no .attach-btn at all')
302 : 'no row';
303check('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.
307if (specRow0) { await specRow0.$eval('.files-name', e => e.click()); await page.waitForTimeout(700); }
308const docBtnHidden = await page.$eval('[data-act="attach"]', b => b.style.display === 'none').catch(() => 'not found');
309check('WITH NOTHING IN FOCUS, the Doc header’s control is hidden', docBtnHidden === true,
310 JSON.stringify(docBtnHidden));
311await shot(s, 'attachfocus-1-nothing-in-focus');
312await gotoRoot();
313
314// ── A Diamond, and a chat, to focus on in turn ──────────────────────────
315await page.click('#new-diamond-btn', { force: true });
316await page.waitForSelector('.dlg-input', { timeout: 10000 });
317await page.fill('.dlg-input', 'Ship a CSV parser');
318await page.click('.dlg-ok', { force: true });
319await page.waitForTimeout(900);
320const 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});
326check('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).
332await openWorkspace();
333const docsRow = await rowFor('docs');
334check('the "docs" row is offered now a Diamond is in focus', !!docsRow, docsRow ? '' : 'no row');
335if (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}
341const 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);
346const docsLink = dLinks.find(l => /docs$/.test(String(l.other)) && l.other.indexOf('dir:') === 0);
347check('WITH A DIAMOND IN FOCUS the folder writes a `holds` link', !!docsLink && docsLink.rel === 'holds',
348 JSON.stringify(docsLink));
349check('carrying the workspace it was made in', !!docsLink && /^dir:\[browser\]docs$/.test(docsLink.other),
350 docsLink && docsLink.other);
351await 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.
356await 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']]);
363await page.evaluate(() => window.DaimondArtefacts.render());
364await page.waitForTimeout(400);
365await page.evaluate(() => { const b = document.getElementById('arte-strip'); if (b) b.click(); });
366await page.waitForTimeout(600);
367const 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}));
378check('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 }));
381const wordNote = await T('attach.note');
382check('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));
385check('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)])));
388check('and it is capped by the same rule the chat footer is',
389 crystalStack.box > 0 && crystalStack.box <= 260, String(crystalStack.box));
390await shot(s, 'attachfocus-2b-crystal-footer-stack');
391await page.click('#arte-list [data-act="attach-view"]', { force: true });
392await page.waitForTimeout(500);
393const 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})));
398check('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));
401check('with the unreachable one still marked as shut', !!crystalIcons.find(c => c.name === 'x' && c.shut),
402 JSON.stringify(crystalIcons));
403await shot(s, 'attachfocus-2c-crystal-footer-icons');
404await page.click('#arte-list [data-act="attach-view"]', { force: true });
405await 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.
413await page.click('#arte-list [data-act="attach-add"]', { force: true });
414await page.waitForSelector('.attach-pick-row', { timeout: 10000 });
415for (const b of await page.$$('.attach-pick-name.dir')) {
416 if (/notes/.test(await b.textContent())) { await b.click(); break; }
417}
418await page.waitForTimeout(700);
419const 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})));
424const specPick = stalePick.find(r => r.name === 'spec.md');
425check('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));
427await page.click('.dlg-cancel', { force: true });
428await page.waitForTimeout(400);
429
430// Take it off again, so it does not linger into the chat scenario below.
431await 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.
443await page.fill('#chat-input', '');
444
445// ── An ordinary chat, in focus from here on ─────────────────────────────
446await newChat(s);
447await page.waitForTimeout(400);
448const chatFocus = await page.evaluate(() => window.DaimondAttach.focus());
449check('a chat is now the focus', chatFocus && chatFocus.kind === 'chat', JSON.stringify(chatFocus));
450const 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.
457const 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});
465const linksBeforeChatAttach = await totalDiamondLinks();
466
467await openWorkspace();
468const specRow = await rowFor('spec.md');
469if (!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}
474const specRow2 = (await rowFor('spec.md')) || specRow;
475check('the file row is reachable to attach', !!specRow2, specRow2 ? '' : 'no row');
476if (specRow2) { await specRow2.$eval('.attach-btn', b => b.click()); await page.waitForTimeout(600); }
477
478const linksAfterChatAttach = await totalDiamondLinks();
479check('WITH A CHAT IN FOCUS, attaching writes NOTHING to any Diamond’s store',
480 linksAfterChatAttach === linksBeforeChatAttach,
481 `${linksBeforeChatAttach} link(s) before, ${linksAfterChatAttach} after`);
482
483const footerShown = await page.$eval('#chat-attachments', e => e.style.display !== 'none');
484check('and it shows in the chat footer instead', footerShown === true, String(footerShown));
485const tileText = await page.$eval('#chat-attachments .arte-open', e => e.textContent);
486check('the tile names the path', /notes\/spec\.md/.test(tileText), tileText);
487await 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.
491if (specRow2) { await specRow2.$eval('.files-name', e => e.click()); await page.waitForTimeout(700); }
492const 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);
495check('the Doc header’s control is offered and lit with the chat in focus',
496 !!docBtnLit && docBtnLit.shown && docBtnLit.on, JSON.stringify(docBtnLit));
497check('and says the same thing as the row did', docBtnLit && docBtnLit.title === await T('attach.to_focus'),
498 docBtnLit && docBtnLit.title);
499await shot(s, 'attachfocus-3b-doc-header-lit');
500if (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 ─────────────────────────────
503const startState = await page.$eval('#chat-attachments .attach-state', e => ({
504 text: e.textContent, read: e.classList.contains('read'),
505}));
506check('THE TILE STARTS AS "Note"', startState.read === false, JSON.stringify(startState));
507check('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 ────────
510const notePrefix = await T('attach.prefix_note', { paths: 'notes/spec.md' });
511let composerVal = await page.$eval('#chat-input', e => e.value);
512check('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.
516await page.click('#chat-attachments .attach-state', { force: true });
517await page.waitForTimeout(400);
518const readState = await page.$eval('#chat-attachments .attach-state', e => ({
519 text: e.textContent, read: e.classList.contains('read'),
520}));
521check('the tile now reads Read', readState.read === true && readState.text === await T('attach.read'),
522 JSON.stringify(readState));
523const readPrefix = await T('attach.prefix_read', { paths: 'notes/spec.md' });
524composerVal = await page.$eval('#chat-input', e => e.value);
525check('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.
534const beforeTyping = composerVal;
535await page.click('#chat-input');
536await page.keyboard.press('Control+End');
537await page.keyboard.type('please');
538composerVal = await page.$eval('#chat-input', e => e.value);
539check('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.
546await page.evaluate((id) => window.DaimondAttach.chatToggle(id, 'dir:[machine:elsewhere]nope', true, 'nope'), chatId);
547await page.waitForTimeout(300);
548const 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})));
552const stray = awayTile.find(t => /nope/.test(t.text));
553check('the unreachable one is still SHOWN, marked away', !!stray && stray.away === true, JSON.stringify(awayTile));
554composerVal = await page.$eval('#chat-input', e => e.value);
555check('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.
562const 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.
577const notHere = await T('attach.not_here', { where: await T('dws.in_machine', { name: 'elsewhere' }) });
578check('THE SHUT TILE SAYS WHY IN WORDS ON THE TILE, not only on hover',
579 !!shutTile && shutTile.why === notHere, JSON.stringify(shutTile && shutTile.why));
580check('and offers no way to open what cannot be opened',
581 !!shutTile && shutTile.openable === false, JSON.stringify(shutTile));
582await shot(s, 'attachfocus-5-shut-tile');
583// Take it off again so it does not confuse the checks below.
584await page.evaluate((id) => window.DaimondAttach.chatToggle(id, 'dir:[machine:elsewhere]nope', true, 'nope'), chatId);
585await 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.
589const viewBtn = '#chat-attachments [data-act="attach-view"]';
590const toIcons = await page.$eval(viewBtn, b => ({ text: b.textContent, view: b.dataset.view }));
591check('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));
593await page.click(viewBtn, { force: true });
594await 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.
598const 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'));
603check('IN THE ICON VIEW, the tile for notes/spec.md is an icon named spec.md',
604 !!iconTile, JSON.stringify(iconTile));
605check('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.
609check('and the icon is SIZEABLE -- not button-sized',
610 !!iconTile && iconTile.w >= 64 && iconTile.h >= 64, JSON.stringify(iconTile && [iconTile.w, iconTile.h]));
611const stackGone = await page.$$eval('#chat-attachments .arte-row', r => r.length);
612check('the stack is not drawn underneath it as well', stackGone === 0, String(stackGone));
613await 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.
617await page.click(viewBtn, { force: true });
618await 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.
623await page.click('#chat-attachments [data-act="attach-add"]', { force: true });
624await page.waitForSelector('.attach-pick-row', { timeout: 10000 });
625for (const b of await page.$$('.attach-pick-name.dir')) {
626 if (/docs/.test(await b.textContent())) { await b.click(); break; }
627}
628await page.waitForTimeout(700);
629const 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})));
633check('the picker walks into a folder and lists what is in it',
634 pickRows.some(r => r.name === 'plan.md'), JSON.stringify(pickRows));
635for (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}
641await shot(s, 'attachfocus-7-picker');
642await page.click('.dlg-ok', { force: true });
643await page.waitForTimeout(900);
644const 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})));
648const planTile = afterPick.find(r => r.path === 'docs/plan.md');
649check('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.
653check('and it reads Note, like anything else newly attached',
654 !!planTile && planTile.state === await T('attach.note'), JSON.stringify(planTile));
655const afterPickComposer = await page.$eval('#chat-input', e => e.value);
656check('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.
663const many = [];
664for (let i = 0; i < 22; i++) many.push(`bulk/item-${String(i).padStart(2, '0')}.md`);
665await 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.
681const 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.
699const 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};
706const atSix = await attachRange(0, 6);
707const atAll = await attachRange(6, 22);
708check('twenty-two tiles are in the footer', atAll.tiles >= 22, JSON.stringify(atAll));
709check('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.
716check('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`);
719check('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.
724const 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});
733const before = await lastTilePos();
734check('the last tile starts out below the fold', !!before && before.below > 0, JSON.stringify(before));
735await page.evaluate(() => {
736 const body = document.querySelector(
737 '#chat-attachments .attach-body, #chat-attachments .ws-body');
738 body.scrollTop = body.scrollHeight;
739});
740await page.waitForTimeout(300);
741const after = await lastTilePos();
742check('AND SCROLLING BRINGS IT INTO VIEW', !!after && after.scrollTop > 0 && after.below <= 0,
743 JSON.stringify(after));
744await 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.
748for (const p of many) {
749 await page.evaluate(([id, p]) => window.DaimondAttach.chatToggle(id, `file:[browser]${p}`, false, p), [chatId, p]);
750}
751await page.evaluate((id) => window.DaimondAttach.chatToggle(id, 'file:[browser]docs/plan.md', false, 'docs/plan.md'), chatId);
752await 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.
760const 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};
768const awaySix = await awayRange(0, 6);
769const awayAll = await awayRange(6, 22);
770const composerText = await page.$eval('#chat-input', e => e.value);
771check('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)}`);
774check('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.
778for (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}
782await page.waitForTimeout(400);
783check('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.
800await page.fill('#chat-input', 'go');
801await page.click('#chat-send', { force: true });
802await page.waitForTimeout(1500);
803const listAfterSend = await page.evaluate((id) => window.DaimondAttach.chatList(id).length, chatId);
804check('A CHAT KEEPS WHAT IT HOLDS WHEN A TURN IS SENT', listAfterSend === 1, String(listAfterSend));
805const footerAfterSend = await page.$eval('#chat-attachments', e => e.style.display).catch(() => 'gone');
806check('and the footer still shows it, because it is still true',
807 footerAfterSend !== 'none', footerAfterSend);
808await 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.
819if (!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}
823check('a footer is on screen to turn', await page.$(viewBtn) !== null);
824await page.click(viewBtn, { force: true }).catch(() => {});
825await page.waitForTimeout(400);
826check('the icon view is chosen before the reload',
827 await page.evaluate(() => window.DaimondAttach.view()) === 'icons');
828await installBreak(); // a route only bites on a load made after it
829await page.reload({ waitUntil: 'domcontentloaded' });
830await signInAs(s, 'attachfocus');
831await page.waitForTimeout(1500);
832check('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.
845const reborn = await newChat(s);
846await page.waitForTimeout(600);
847check('the reload is followed by a chat of its OWN, which holds nothing yet',
848 !!reborn && reborn !== chatId,
849 `before ${chatId}, after ${reborn}`);
850await page.evaluate((id) => window.DaimondAttach.chatToggle(id, 'file:[browser]notes/spec.md', false, 'notes/spec.md'), reborn);
851await page.waitForTimeout(500);
852const 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}));
857check('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));
860await 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.
877const GATEWAY_NOISE = /502 \(Bad Gateway\)/;
878const errs = errors(s).filter(e => !GATEWAY_NOISE.test(e));
879check('nothing threw along the way', errs.length === 0, errs.slice(0, 3).join(' | '));
880
881console.log(`\n${ok} ok, ${bad} failed`);
882if (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}
888await s.close();
889process.exit(bad ? 1 : 0);