Oregami
Repositories/oxedyne/daimond

oxedyne/daimond/dev/verify_chatworkspace.mjs

34.3 KiB, 1 run

created by r2519314175:287, 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_chatworkspace.mjs — a chat's footer says two different things, and only
2// one of them is a permission.
3//
4// The paperclip used to carry Note and Read as if they were the whole story. They
5// are not, and they never were two alternatives to a third thing: an attachment
6// carries TWO INDEPENDENT marks.
7//
8// * NOTE | READ is a COST decision about what goes into the turn's prompt. Note
9// names a path so the user need not type it; Read quotes the contents. They
10// are mutually exclusive, they are both reading, and NEITHER GRANTS ANY REACH.
11// * IN THE WORKSPACE is the other one, and it is the blast radius: the folders
12// this chat may read and change. A path can be in the workspace AND Read.
13//
14// So the footer is two groups, workspace first, and the difference between how
15// they are DRAWN is part of the claim. The workspace is one bounded box with a
16// rail down its edge, because its meaning is collective -- take a folder out and
17// the fence shrinks. The attachments below are a plain list of individuals.
18//
19// THE EMPTY STATE IS THE POINT. The fence exists from the moment a folder is
20// marked, so the group is drawn when it holds nothing and says, in words, that the
21// chat can reach nothing of the user's. The crystal footer hid itself at zero
22// until 4216383 and took the `+` inside it out of reach in exactly the state that
23// control exists for; this asserts that lesson rather than rediscovering it.
24//
25// EACH CHECK IS PROVED AGAINST BROKEN CODE FIRST. `--break <name>` serves a
26// deliberately damaged file to the real page through `page.route`; the run is then
27// expected to FAIL, and a break whose anchor does not match aborts rather than
28// passing quietly.
29//
30// node dev/verify_chatworkspace.mjs --break hideatzero # 1 fails: the empty group hides
31// node dev/verify_chatworkspace.mjs --break attachmarks # 2 fails: attaching grants
32// node dev/verify_chatworkspace.mjs --break inertmark # 3 fails: the mark does nothing
33// node dev/verify_chatworkspace.mjs --break notegrants # 4 fails: Note becomes a grant
34// node dev/verify_chatworkspace.mjs --break costclears # 5 fails: the two marks are one
35// node dev/verify_chatworkspace.mjs --break bothgroups # 6 fails: a folder is drawn twice
36// node dev/verify_chatworkspace.mjs --break filemark # 7 fails: a file offers the mark
37// node dev/verify_chatworkspace.mjs --break nohydrate # 8 fails: the mark dies on reload
38// node dev/verify_chatworkspace.mjs --break nostamp # 8 fails: the mark never reaches disk
39// node dev/verify_chatworkspace.mjs --break wscapless # 9 fails: the box eats the composer
40// node dev/verify_chatworkspace.mjs --break readfetch # 10 fails: Read only asks
41// node dev/verify_chatworkspace.mjs --break wsrestamp # 11 fails: a read normalises the field
42// node dev/verify_chatworkspace.mjs --break rendertouch # 12 fails: a redraw restamps the parcel
43// node dev/verify_chatworkspace.mjs --break pickernomark # 13 fails: the group's + only attaches
44// node dev/verify_chatworkspace.mjs # and then, clean
45//
46// bash dev/world.sh 5 --up
47// eval "$(bash dev/world.sh 5 --env)"
48// node dev/verify_chatworkspace.mjs
49import fs from 'node:fs';
50import path from 'node:path';
51import { fileURLToPath } from 'node:url';
52import { open, shot, newChat, errors } from './harness.mjs';
53
54const HERE = path.dirname(fileURLToPath(import.meta.url));
55const WWW = path.join(HERE, '..', 'www');
56
57const BREAK = (() => {
58 const i = process.argv.indexOf('--break');
59 return i > 0 ? String(process.argv[i + 1] || '') : '';
60})();
61
62const BREAKS = {
63 // The footer hides itself while there is nothing in it -- which is what the
64 // crystal footer did until 4216383, and it takes the `+` down with it. The
65 // state that most needs saying becomes the one state nothing is said in.
66 hideatzero: {
67 file: 'js/daimond.js',
68 find: ` var held = chatAttachList(f.id);`,
69 with: ` var held = chatAttachList(f.id);
70 if (!held.length) { box.innerHTML = ''; box.style.display = 'none'; return; }`,
71 },
72 // Attaching a folder marks it into the workspace by itself. Plausible -- it is
73 // what the old code effectively did, since the fence was built from the whole
74 // attachment list -- and it carries the old meaning through under a new name.
75 attachmarks: {
76 file: 'js/daimond.js',
77 find: ` list[list.length - 1].ws = false;`,
78 with: ` list[list.length - 1].ws = !!dir;`,
79 },
80 // The control is drawn, is pressable, and does nothing. Two of lane 4's four
81 // defects on 2026-08-12 were exactly this, and no check on internal state can
82 // see it: this one goes through a real click.
83 inertmark: {
84 file: 'js/daimond.js',
85 find: ` on: function () { chatAttachSetWorkspace(f.id, a.ref, !a.ws); },`,
86 with: ` on: function () { /* the press does nothing */ },`,
87 },
88 // The fence is built from everything attached, so Note -- which costs a few
89 // tokens and is meant to grant nothing -- hands over read and write access to
90 // the path it names.
91 notegrants: {
92 file: 'js/daimond.js',
93 find: ` .filter(function (a) { return !!a.ws; })`,
94 with: ` .filter(function (a) { return true; })`,
95 },
96 // The two marks are one field after all: choosing the cost clears the
97 // permission. This is what a single three-valued state would do, and it is the
98 // design mistake the orthogonality exists to prevent.
99 costclears: {
100 file: 'js/daimond.js',
101 find: ` rec.state = state;
102 persistChats(); attachChanged();`,
103 with: ` rec.state = state; rec.ws = false;
104 persistChats(); attachChanged();`,
105 },
106 // The attachment group draws everything the chat holds rather than what is not
107 // already in the workspace, so a marked folder appears in both groups -- and
108 // the reader cannot tell whether that is one folder or two.
109 // The heading put back over the list. Nothing else changes and nothing breaks;
110 // what goes red is the one check that says the footer no longer restates in a
111 // heading what every row already says, which is the change the owner asked for.
112 headingback: {
113 file: 'js/daimond.js',
114 find: ` help: t('attach.ws_help'),`,
115 with: ` title: workspaceTitle(),
116 help: t('attach.ws_help'),`,
117 },
118 // RE-AIMED 2026-08-24 with the group it damaged. It used to append the whole
119 // held list to the SECOND group, so a marked folder was drawn in both; there is
120 // one list now, so it appends the marked rows a second time. Same fault, same
121 // check red: a folder is drawn twice.
122 bothgroups: {
123 file: 'js/daimond.js',
124 find: ` var body = attachBody(marked.concat(noted).map(toTile),`,
125 with: ` var body = attachBody(marked.concat(noted).concat(marked).map(toTile),`,
126 },
127 // A FILE is offered the workspace mark. The workspace is a union of folders; a
128 // fence around one file is a fence around its folder wearing a smaller name,
129 // and offering it invites a mark that means something else than it says.
130 filemark: {
131 file: 'js/daimond.js',
132 find: ` actions: a.dir ? [{`,
133 with: ` actions: true ? [{`,
134 },
135 // BOTH OF THESE WERE REAL, and both were found by the check they now break.
136 //
137 // `nohydrate`: the loader that turns a stored chat back into a live one never
138 // carried `holds`. It was written out faithfully and read back as nothing, and
139 // the first save after boot then wrote the empty list over the user's own.
140 nohydrate: {
141 file: 'js/daimond.js',
142 find: ` // workspace mark rides here, so it would have taken the fence with it.
143 holds: Array.isArray(c.holds) ? c.holds : [],`,
144 with: ` // the holdings are dropped on the way in`,
145 },
146 // `nostamp`: the store writes a record only when its stamp moves, and the stamp
147 // was `updatedAt : messages : session`. Attaching, flipping Note to Read and
148 // marking a folder in move none of those, so the put never happened -- while
149 // the in-memory mirror, which is what every reader reads, said it had.
150 nostamp: {
151 file: 'js/daimond.js',
152 find: ` + ':' + JSON.stringify(c.holds || []);`,
153 with: ` ;`,
154 },
155 // The workspace box has no cap, so it grows with every folder marked and
156 // pushes the composer down the screen -- the failure `--attach-cap` was
157 // measured to end, arriving again through the group beside it.
158 wscapless: {
159 file: 'css/app.css',
160 find: ` max-height: calc(var(--ws-cap) * var(--attach-row-h) + var(--attach-peek));
161 overflow-y: auto; scrollbar-gutter: stable; scrollbar-width: thin; }`,
162 with: ` overflow-y: auto; }`,
163 },
164 // Read goes back to TELLING THE MODEL to open the file. Now that a chat is
165 // fenced to its workspace, that is an instruction the app itself refuses
166 // unless the path is also marked in -- so the first Read a user presses
167 // produces a refusal the app caused.
168 readfetch: {
169 file: 'js/daimond.js',
170 find: ` if (text && f.kind === 'chat') text += await attachReadBodies(list);`,
171 with: ` if (text && f.kind === 'chat') text += '';`,
172 },
173 // The `+` in the workspace group's own header attaches the folder and does not
174 // mark it in -- so the sentence beside it, which says to mark a folder in with
175 // that control, is half true and the fence does not move.
176 pickernomark: {
177 file: 'js/daimond.js',
178 find: ` if (mark && picked[paths[i]].dir) {`,
179 with: ` if (false && picked[paths[i]].dir) {`,
180 },
181 // The render hangs a stamp on every holding it draws -- "when this was last
182 // shown", say. Plausible bookkeeping, and it is the exact shape of the bug
183 // that put a paired iPhone into an endless sync loop: two collects with a
184 // redraw between them no longer agree, so the device always has something to
185 // send and the far end always has something to send back.
186 rendertouch: {
187 file: 'js/daimond.js',
188 find: ` held.forEach(function (a) { (a.ws ? marked : noted).push(a); });`,
189 with: ` held.forEach(function (a) { a.seen = Date.now(); (a.ws ? marked : noted).push(a); });`,
190 },
191 // The render normalises `ws` back onto the record it just read. Harmless
192 // looking, and it is how the sync parcel stops being a fixed point: a record
193 // that arrives from another device without the field gains one here, so the
194 // next collect differs from what was applied and the two devices push at each
195 // other for ever.
196 wsrestamp: {
197 file: 'js/daimond.js',
198 find: ` held.forEach(function (a) { (a.ws ? marked : noted).push(a); });`,
199 with: ` held.forEach(function (a) { a.ws = !!a.ws; (a.ws ? marked : noted).push(a); });`,
200 },
201};
202
203if (BREAK && !BREAKS[BREAK]) {
204 console.error(`unknown break '${BREAK}'; one of: ${Object.keys(BREAKS).join(', ')}`);
205 process.exit(2);
206}
207
208let ok = 0, bad = 0;
209const check = (name, pass, detail) => {
210 if (pass) { ok++; console.log(` ok ${name}${detail ? ' — ' + detail : ''}`); }
211 else { bad++; console.log(` FAIL ${name}${detail ? ' — ' + detail : ''}`); }
212};
213/// Each section owns its failure. One shared `try` let lane 4's first defect
214/// throw and left three later checks never seen to fail at all.
215const section = async (name, fn) => {
216 try { await fn(); }
217 catch (e) { check(`${name} ran to the end`, false, String(e && e.message || e)); }
218};
219
220// Not signed in and not connected by `open` itself: the break has to be routed
221// before the app is ever loaded, and signing in afterwards is what gives the
222// composer -- and therefore the footer above it -- a page to be drawn on.
223const s = await open({ name: 'chatworkspace', signIn: false, connect: false });
224const { page } = s;
225
226/// Serve one deliberately damaged file in place of the real one, before the app
227/// is loaded. Routes outlive a reload, so this is installed once.
228async function installBreak() {
229 if (!BREAK) return;
230 const spec = BREAKS[BREAK];
231 const src = fs.readFileSync(path.join(WWW, spec.file), 'utf8');
232 const n = src.split(spec.find).length - 1;
233 if (n !== 1) {
234 console.error(`break '${BREAK}': the anchor appears ${n} times in ${spec.file}, `
235 + 'so nothing was broken and the run below would prove nothing.');
236 process.exit(2);
237 }
238 const body = src.replace(spec.find, spec.with);
239 const type = /\.css$/.test(spec.file) ? 'text/css' : 'application/javascript';
240 await page.route('**/' + spec.file, r => r.fulfill({ status: 200, contentType: type, body }));
241}
242await installBreak();
243await page.goto(process.env.DAIMOND_APP || 'http://localhost:8777', { waitUntil: 'domcontentloaded' });
244const { signInAs, connectMock } = await import('./harness.mjs');
245await signInAs(s, 'chatworkspace');
246await connectMock(s);
247await page.waitForTimeout(1500);
248
249const T = (k, v) => page.evaluate(([k, v]) => DaimondI18n.t(k, v || undefined), [k, v || null]);
250
251// The files and folders a person would have: two folders to mark, one file to
252// quote. Written through the tool door, which is how a turn would have made them.
253const FILE_BODY = '# Spec\nthe sentence that proves the quote is the FILE\n';
254await page.evaluate(async (body) => {
255 const m = await import('/pkg/oxedyne_daimond.js');
256 const app = new m.DaimondApp('http://127.0.0.1/v1/chat/completions', '', 'none', 4096, '', true);
257 await app.run_tool('file_write', JSON.stringify({ path: 'papers/spec.md', content: body }));
258 await app.run_tool('file_write', JSON.stringify({ path: 'books/draft.md', content: '# Draft\n' }));
259}, FILE_BODY);
260
261await newChat(s);
262await page.waitForTimeout(600);
263const focus = await page.evaluate(() => window.DaimondAttach.focus());
264const chatId = focus && focus.id;
265check('a chat is in focus', !!chatId && focus.kind === 'chat', JSON.stringify(focus));
266
267/// What the footer is showing, split by WHAT EACH ROW CLAIMS.
268///
269/// RE-AIMED 2026-08-24, when the two groups became one list. This used to read
270/// `.ws-group .arte-row` and `.at-group .arte-row` and take the section a row sat
271/// in as the statement of what it had granted. The sections are gone -- the owner
272/// objected to being charged a split list for what every row already says -- so
273/// the question is now put to the ROW, through the one control that answers it:
274/// the `Workspace` pill and its `aria-pressed`.
275///
276/// The property is unchanged and every check below still means what it said: a
277/// row that has widened the fence is told apart, in ink, from one merely put in
278/// front of the model. Only the place the answer is read from has moved, from
279/// the furniture around the row to the row itself.
280const footer = () => page.evaluate(() => {
281 const read = (r) => ({
282 path: (r.querySelector('.arte-open') || {}).textContent || '',
283 state: (r.querySelector('.attach-state') || {}).textContent || '',
284 ws: (r.querySelector('.attach-ws') || {}).getAttribute
285 ? r.querySelector('.attach-ws').getAttribute('aria-pressed') : null,
286 hasWsBtn: !!r.querySelector('.attach-ws'),
287 });
288 const all = [...document.querySelectorAll('#chat-attachments .arte-row')].map(read);
289 const box = document.getElementById('chat-attachments');
290 return {
291 shown: !!box && box.style.display !== 'none',
292 // No heading is drawn any more, and its absence is asserted rather than
293 // assumed: a heading creeping back is the change this file would want to
294 // catch, because it is the thing he asked to be rid of.
295 heads: [...(box || document).querySelectorAll('.attach-group-title')].length,
296 empty: ((box || document).querySelector('.ws-empty') || {}).textContent || '',
297 add: !!(box || document).querySelector('.ws-group [data-act="attach-add"]'),
298 all,
299 ws: all.filter(r => r.ws === 'true'),
300 at: all.filter(r => r.ws !== 'true'),
301 };
302});
303
304const scopeOf = () => page.evaluate((id) => window.DaimondAttach.chatScope(id), chatId);
305
306// ── 1. THE EMPTY STATE, which is the state that most needs saying ──────
307await section('the empty state', async () => {
308 const f = await footer();
309 check('WITH NOTHING MARKED, THE FOOTER IS ON SCREEN',
310 f.shown === true && f.add === true, JSON.stringify({ shown: f.shown, add: f.add }));
311 // RE-AIMED. This asked that the group be HEADED with `astat.workspace_browser`,
312 // and the heading is deliberately gone: it restated what each row says, which
313 // is what the owner objected to. What survives is the claim the heading was
314 // there to make -- that the footer says which workspace, in the app's own
315 // words -- and it is made by the empty sentence and by the tiles themselves.
316 // So this now asserts the absence, because a heading drifting back in is the
317 // regression a reader of this file would want caught.
318 check('and it carries no group heading, because every row says its own claim',
319 f.heads === 0, `${f.heads} heading(s) still drawn`);
320 const want = await T('attach.ws_empty');
321 check('and it says, in words, that the chat can reach nothing of the user’s',
322 f.empty === want && /reach nothing/.test(want), JSON.stringify(f.empty));
323 check('and the list itself is empty, because nothing is attached',
324 f.all.length === 0, JSON.stringify({ rows: f.all.length }));
325 // The lesson of 4216383: the control that ends this state must be reachable
326 // FROM this state.
327 check('and the control that marks the first folder is inside that group',
328 f.add === true, String(f.add));
329 const sc = await scopeOf();
330 check('and the fence is handed nothing at all', Array.isArray(sc) && sc.length === 0,
331 JSON.stringify(sc));
332});
333await shot(s, 'chatworkspace-1-empty');
334
335// ── 2. Attaching is not marking ────────────────────────────────────────
336await section('attaching', async () => {
337 await page.evaluate((id) => {
338 window.DaimondAttach.chatToggle(id, 'dir:[browser]papers', true, 'papers');
339 window.DaimondAttach.chatToggle(id, 'file:[browser]papers/spec.md', false, 'papers/spec.md');
340 }, chatId);
341 await page.waitForTimeout(600);
342 const f = await footer();
343 check('ATTACHING A FOLDER PUTS IT IN FRONT OF THE MODEL, not in the workspace',
344 f.ws.length === 0 && f.at.some(r => r.path === 'papers'), JSON.stringify(f.at));
345 // RE-AIMED from the group's heading to the row's own control. The claim is the
346 // same one: a thing merely put in front of the model SAYS SO, and does not have
347 // to be told apart by which half of the footer it landed in.
348 check('and each says so on itself: an unmarked folder offers the pill, unpressed',
349 f.at.some(r => r.path === 'papers' && r.hasWsBtn && r.ws === 'false'),
350 JSON.stringify(f.at.find(r => r.path === 'papers')));
351 check('the folder’s tile carries BOTH controls: a cost, and the mark',
352 f.at.some(r => r.path === 'papers' && r.state === 'Note' && r.ws === 'false'),
353 JSON.stringify(f.at.find(r => r.path === 'papers')));
354 check('a FILE is offered no workspace mark, because a workspace is folders',
355 f.at.some(r => r.path === 'papers/spec.md' && !r.hasWsBtn),
356 JSON.stringify(f.at.find(r => r.path === 'papers/spec.md')));
357 const sc = await scopeOf();
358 check('AND NOTHING ATTACHED HAS WIDENED THE FENCE', sc.length === 0, JSON.stringify(sc));
359});
360
361// ── 3. The mark, driven as a person drives it ──────────────────────────
362await section('the mark', async () => {
363 // A REAL CLICK on the pill of the row for `papers`. Found by its path, not by
364 // position: `.attach-ws` first-in-DOM would be whichever row happens to be
365 // drawn first.
366 const clicked = await page.evaluate(() => {
367 const row = [...document.querySelectorAll('#chat-attachments .arte-row')]
368 .find(r => (r.querySelector('.arte-open') || {}).textContent === 'papers');
369 const btn = row && row.querySelector('.attach-ws');
370 if (!btn) return 'no control';
371 btn.click();
372 return 'clicked';
373 });
374 check('the folder’s workspace control is there to press', clicked === 'clicked', clicked);
375 await page.waitForTimeout(700);
376 const f = await footer();
377 check('PRESSING IT MARKS THE FOLDER IN, and the row says so in ink',
378 f.ws.some(r => r.path === 'papers'), JSON.stringify({ ws: f.ws, at: f.at }));
379 // RE-AIMED. "Not in both groups" was a real property and there is only one
380 // list now, so it is counted rather than located: a folder drawn twice is
381 // still the fault, and `bothgroups` still reddens this.
382 check('and it appears ONCE in the list, not twice',
383 f.all.filter(r => r.path === 'papers').length === 1, JSON.stringify(f.all));
384 check('carrying both marks: the cost it had, and the mark it now has',
385 f.ws.some(r => r.path === 'papers' && r.state === 'Note' && r.ws === 'true'),
386 JSON.stringify(f.ws.find(r => r.path === 'papers')));
387 const sc = await scopeOf();
388 check('AND THE FENCE THE ENGINE IS HANDED IS NOW THAT FOLDER',
389 sc.length === 1 && sc[0] === 'papers', JSON.stringify(sc));
390});
391await shot(s, 'chatworkspace-2-marked');
392
393// ── 3b. The `+` in that group means what its position says ────────────
394await section('the group’s own +', async () => {
395 // The empty state tells the reader to mark a folder in "with the paperclip, or
396 // with + above". So the `+` that sits in the workspace group marks in what it
397 // adds -- driven here as a person drives it, through the dialog, because a
398 // control that opens and then quietly does something else is exactly what two
399 // of lane 4's defects were.
400 await page.click('#chat-attachments .ws-group [data-act="attach-add"]', { force: true });
401 await page.waitForSelector('.attach-pick-row', { timeout: 10000 });
402 const ticked = await page.evaluate(() => {
403 const row = [...document.querySelectorAll('.attach-pick-row')]
404 .find(r => /books/.test(r.querySelector('.attach-pick-name').textContent));
405 if (!row) return 'no books row';
406 row.querySelector('input').click();
407 return 'ticked';
408 });
409 check('the picker lists the folder to be marked in', ticked === 'ticked', ticked);
410 await page.click('.dlg-ok', { force: true });
411 await page.waitForTimeout(1000);
412 const f = await footer();
413 check('THE GROUP’S OWN `+` MARKS THE FOLDER IN, as the sentence beside it says',
414 f.ws.some(r => r.path === 'books' && r.ws === 'true'), JSON.stringify(f.ws));
415 const sc = await scopeOf();
416 check('and the fence is both folders now', sc.indexOf('books') >= 0 && sc.indexOf('papers') >= 0,
417 JSON.stringify(sc));
418});
419
420// ── 4. The two marks are independent ───────────────────────────────────
421await section('independence', async () => {
422 // Read on the marked folder: the cost changes, the permission must not.
423 await page.evaluate(() => {
424 const row = [...document.querySelectorAll('#chat-attachments .arte-row')]
425 .find(r => (r.querySelector('.arte-open') || {}).textContent === 'papers');
426 row.querySelector('.attach-state').click();
427 });
428 await page.waitForTimeout(600);
429 let f = await footer();
430 check('CHANGING THE COST LEAVES THE PERMISSION WHERE IT WAS',
431 f.ws.some(r => r.path === 'papers' && r.state === 'Read' && r.ws === 'true'),
432 JSON.stringify(f.ws.find(r => r.path === 'papers')));
433 let sc = await scopeOf();
434 // The MARKED SET, unchanged by a cost decision. Asserted as membership rather
435 // than as a list of one: the `+` above marked a second folder in, and a check
436 // that counted would be measuring that instead of what it says it measures.
437 check('and the fence has not moved', sc.indexOf('papers') >= 0, JSON.stringify(sc));
438 // And back, so the rest of the run reads a Note folder.
439 await page.evaluate(() => {
440 const row = [...document.querySelectorAll('#chat-attachments .arte-row')]
441 .find(r => (r.querySelector('.arte-open') || {}).textContent === 'papers');
442 row.querySelector('.attach-state').click();
443 });
444 await page.waitForTimeout(600);
445 f = await footer();
446 check('and the cost goes back without disturbing it either',
447 f.ws.some(r => r.path === 'papers' && r.state === 'Note' && r.ws === 'true'),
448 JSON.stringify(f.ws.find(r => r.path === 'papers')));
449});
450
451// ── 5. Read quotes the file rather than asking for it ──────────────────
452await section('read quotes', async () => {
453 // papers/spec.md is attached and NOT marked in, which is the case the fence
454 // makes interesting: the chat may quote it and may not open it.
455 await page.evaluate(() => {
456 const row = [...document.querySelectorAll('#chat-attachments .arte-row')]
457 .find(r => (r.querySelector('.arte-open') || {}).textContent === 'papers/spec.md');
458 row.querySelector('.attach-state').click();
459 });
460 await page.waitForTimeout(900);
461 const val = await page.$eval('#chat-input', e => e.value);
462 check('READ PUTS THE FILE’S OWN WORDS IN THE COMPOSER',
463 val.indexOf('the sentence that proves the quote is the FILE') >= 0,
464 JSON.stringify(val.slice(0, 160)));
465 // The tool door numbers every line; the raw door does not. Quoting the tool's
466 // answer would put a gutter down the user's file, which this app has shipped
467 // twice before.
468 check('and quotes the file, not the tool’s numbered view of it',
469 !/^\s*1\t/m.test(val), JSON.stringify(val.slice(0, 160)));
470 const sc = await scopeOf();
471 check('while READ STILL GRANTS NOTHING: the file is not in the fence',
472 sc.indexOf('papers/spec.md') < 0, JSON.stringify(sc));
473});
474await shot(s, 'chatworkspace-3-read-quoted');
475
476// ── 6. The mark is written down ────────────────────────────────────────
477await section('persistence', async () => {
478 await page.reload({ waitUntil: 'domcontentloaded' });
479 // A reload always locks -- identity.js holds the wrapping key in memory and
480 // nowhere else -- so the passphrase goes in again before anything can be read.
481 await signInAs(s, 'chatworkspace');
482 await page.waitForTimeout(1800);
483 const held = await page.evaluate((id) => (window.DaimondAttach.chatList(id) || [])
484 .map(a => ({ path: a.path, ws: !!a.ws, state: a.state })), chatId);
485 check('THE MARK SURVIVES A RELOAD, because it lives on the chat’s own record',
486 held.some(a => a.path === 'papers' && a.ws === true), JSON.stringify(held));
487 check('and so does the cost beside it',
488 held.some(a => a.path === 'papers/spec.md' && a.state === 'read' && a.ws === false),
489 JSON.stringify(held));
490});
491
492// ── 7. The parcel is still a fixed point ───────────────────────────────
493await section('the parcel', async () => {
494 // A parcel from ANOTHER DEVICE, whose records predate the field: apply it,
495 // collect, and the bytes must come back. A render that normalised `ws` onto
496 // what it read would fail here and nowhere else -- and that is the shape of
497 // the bug that put a paired iPhone into an endless sync loop.
498 // AWAITED, all of them. `collectSync` is async, and a check that compared two
499 // unawaited promises compared `undefined` with `undefined` and passed while
500 // proving nothing -- which is how five verifiers went green for the wrong
501 // reason in one night here.
502 //
503 // A HOLDING WITHOUT THE FIELD is what makes this bite: a record written before
504 // `ws` existed, or one that arrived from a device that has not been updated.
505 // A render that normalised the field onto what it read would gain a byte
506 // between two collects, and two devices would then push at each other for ever.
507 const same = await page.evaluate(async (id) => {
508 const one = window.DaimondAttach.chatList(id)[0];
509 delete one.ws;
510 // STRINGIFIED THE MOMENT IT IS COLLECTED. A parcel carries the holdings BY
511 // REFERENCE, so two parcels held as objects and compared at the end are two
512 // views of one array: every later mutation appears in both, and the
513 // comparison can only ever say they are equal. Measured -- a break that
514 // stamped every holding on every redraw passed this check until the
515 // stringify moved here.
516 const j = (p) => JSON.stringify(p.chats || []);
517 const p1 = await DaimondCore.collectSync();
518 const mine = (p1.chats || []).find(c => c.id === id) || {};
519 const fieldless = (mine.holds || []).some(h => !('ws' in h));
520 const holds = (p1.chats || []).reduce((n, c) => n + (c.holds || []).length, 0);
521 const s1 = j(p1);
522 // A redraw, which is the moment a normalising or stamping read would happen.
523 window.DaimondAttach.render();
524 await new Promise(r => setTimeout(r, 400));
525 const s2 = j(await DaimondCore.collectSync());
526 // And the fixed point itself: apply what this device would send, and it
527 // must still send exactly that.
528 await DaimondCore.applySync(JSON.parse(JSON.stringify(p1)));
529 await new Promise(r => setTimeout(r, 500));
530 const s3 = j(await DaimondCore.collectSync());
531 return {
532 holds: holds, fieldless: fieldless,
533 render: s1 === s2, fixed: s1 === s3,
534 a: s1.slice(-220), b: s2.slice(-220), c: s3.slice(-220),
535 };
536 }, chatId);
537 check('A HOLDING WITHOUT THE FIELD IS LEFT WITHOUT IT: nothing normalises on read',
538 same.holds > 0 && same.fieldless === true, JSON.stringify({ holds: same.holds, fieldless: same.fieldless }));
539 check('DRAWING THE FOOTER DOES NOT CHANGE WHAT THIS DEVICE WOULD SEND',
540 same.render === true, same.render ? '' : `before ${same.a}\n after ${same.b}`);
541 check('AND APPLYING ITS OWN PARCEL LEAVES IT THE SAME BYTES (the fixed point)',
542 same.fixed === true, same.fixed ? '' : `sent ${same.a}\n got ${same.c}`);
543});
544
545// ── 8. The composer stays put however much is marked ───────────────────
546await section('the cap', async () => {
547 const chat2 = await page.evaluate(() => window.DaimondAttach.focus());
548 const id = (chat2 && chat2.id) || chatId;
549 const geom = () => page.evaluate(() => {
550 const b = document.querySelector('#chat-attachments .ws-body');
551 const bar = document.querySelector('.chat-input-bar');
552 const r = el => el ? el.getBoundingClientRect() : { height: 0, top: 0 };
553 return {
554 box: Math.round(r(b).height), inside: b ? b.scrollHeight : 0,
555 rows: document.querySelectorAll('#chat-attachments .arte-row').length,
556 bar: Math.round(r(bar).top),
557 };
558 });
559 // Marked, and UNREACHABLE -- a folder on a machine whose workspace is not the
560 // open one. Two things at once: it proves a mark that cannot be reached is
561 // still shown in the workspace group rather than silently dropped (§7), and it
562 // keeps the composer's own text out of the measurement. A reachable path is
563 // named in the generated prefix, the prefix is in the textarea, and the
564 // textarea grows -- so the composer would move for a reason that is the user's
565 // own text rather than the footer's doing.
566 const mark = async (from, to) => {
567 for (let i = from; i < to; i++) {
568 await page.evaluate(([id, i]) => {
569 const ref = `dir:[machine:elsewhere]bulk-${i}`;
570 window.DaimondAttach.chatToggle(id, ref, true, `bulk-${i}`);
571 window.DaimondAttach.chatWs(id, ref, true);
572 }, [id, i]);
573 }
574 await page.waitForTimeout(500);
575 return geom();
576 };
577 // AT the cap and then well past it. Measuring from BELOW the cap would compare
578 // a box still growing with one that has stopped, and the difference would be
579 // the cap doing its job rather than the failure this is looking for.
580 const atTwo = await mark(0, 6);
581 const atMany = await mark(6, 15);
582 // MEASURED, not merely equal. Two absences are equal to each other, and a
583 // footer that was not drawn at all would otherwise pass both checks below
584 // with nothing on screen -- which is how a verifier passes for the wrong
585 // reason.
586 check('fifteen folders are marked into the workspace, in a box that is drawn',
587 atMany.rows >= 15 && atTwo.box > 0 && atTwo.bar > 0, JSON.stringify({ atTwo, atMany }));
588 check('and the box was already at its cap with six of them',
589 atTwo.inside > atTwo.box + 10, `${atTwo.inside}px of rows in a ${atTwo.box}px box`);
590 check('PAST THE CAP THE WORKSPACE BOX STOPS GROWING',
591 atMany.box > 0 && atMany.box === atTwo.box,
592 `${atTwo.box}px at 6 rows, ${atMany.box}px at 15`);
593 check('AND THE COMPOSER HAS NOT MOVED', atMany.bar > 0 && atMany.bar === atTwo.bar,
594 `${atTwo.bar} at 6 rows, ${atMany.bar} at 15`);
595 check('and the box scrolls, so the rows past the fold can still be reached',
596 atMany.inside > atMany.box + 10, `${atMany.inside}px of rows in a ${atMany.box}px box`);
597 // A mark made in another workspace is still a mark: it is shown, saying where
598 // it lives, rather than vanishing into an empty box the user cannot account for.
599 const strays = await page.evaluate(() => [...document.querySelectorAll(
600 '#chat-attachments .arte-row.shut .arte-why')].length);
601 check('and an unreachable mark is shown in the workspace, saying where it lives',
602 strays > 0, String(strays));
603 await shot(s, 'chatworkspace-4-capped');
604 // Off again, so the shots below are of a footer a person would recognise.
605 for (let i = 0; i < 15; i++) {
606 await page.evaluate(([id, i]) => window.DaimondAttach.chatToggle(
607 id, `dir:[machine:elsewhere]bulk-${i}`, true, `bulk-${i}`), [id, i]);
608 }
609 await page.waitForTimeout(400);
610});
611
612// ── The look, at both widths ───────────────────────────────────────────
613await section('the look', async () => {
614 await page.setViewportSize({ width: 360, height: 780 });
615 await page.waitForTimeout(900);
616 await shot(s, 'chatworkspace-5-phone');
617 const fits = await page.evaluate(() => {
618 const box = document.getElementById('chat-attachments');
619 const bar = document.querySelector('.chat-input-bar');
620 if (!box || !bar) return null;
621 const b = bar.getBoundingClientRect();
622 return { bar: Math.round(b.top), h: Math.round(b.height), win: window.innerHeight,
623 footer: Math.round(box.getBoundingClientRect().height) };
624 });
625 check('ON A PHONE THE COMPOSER IS STILL ON SCREEN under the two groups',
626 !!fits && fits.h > 0 && fits.footer > 0 && fits.bar + fits.h <= fits.win + 1,
627 JSON.stringify(fits));
628 await page.setViewportSize({ width: 1500, height: 950 });
629 await page.waitForTimeout(700);
630});
631
632// 502s are the local gateway proxy (/api) not running in this world -- a world
633// is the browser tiers only, as dev/world.sh says in as many words.
634//
635// 401 IS NOT EXCLUDED, AND WAS. On 2026-08-24 this check went red four runs out
636// of six on an unchanged tree, naming a session the page had never had: every
637// world's `/api` was proxied to a fixed :9002, so whether the answer was 502 or
638// 401 depended on which OTHER lane had a gateway up at that moment. The 401 went
639// into the filter, which fixed the symptom in this file and in three others, and
640// left two files without it -- a per-file remedy for a fault in `dev/world.sh`.
641//
642// The gateway is a world's own now (9700 + N) and no other world's is reachable
643// from here, so a 401 on this page can only be a gateway THIS world started. That
644// is the app being refused by something it asked, which is exactly what "nothing
645// threw along the way" is for. Excluding it would now be a lie about what a 401
646// means.
647const GATEWAY_NOISE = /502 \(Bad Gateway\)/;
648const errs = errors(s).filter(e => !GATEWAY_NOISE.test(e));
649check('nothing threw along the way', errs.length === 0, errs.slice(0, 3).join(' | '));
650console.log(`\n${ok} ok, ${bad} failed`);
651if (BREAK) {
652 console.log(bad ? `break '${BREAK}' correctly failed ${bad} check(s)`
653 : `break '${BREAK}': NOTHING FAILED, so the checks above prove nothing`);
654 await s.close();
655 process.exit(bad ? 0 : 1); // a break MUST fail something
656}
657await s.close();
658process.exit(bad ? 1 : 0);