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 |
| 49 | import fs from 'node:fs'; |
| 50 | import path from 'node:path'; |
| 51 | import { fileURLToPath } from 'node:url'; |
| 52 | import { open, shot, newChat, errors } from './harness.mjs'; |
| 53 | |
| 54 | const HERE = path.dirname(fileURLToPath(import.meta.url)); |
| 55 | const WWW = path.join(HERE, '..', 'www'); |
| 56 | |
| 57 | const BREAK = (() => { |
| 58 | const i = process.argv.indexOf('--break'); |
| 59 | return i > 0 ? String(process.argv[i + 1] || '') : ''; |
| 60 | })(); |
| 61 | |
| 62 | const 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 | |
| 203 | if (BREAK && !BREAKS[BREAK]) { |
| 204 | console.error(`unknown break '${BREAK}'; one of: ${Object.keys(BREAKS).join(', ')}`); |
| 205 | process.exit(2); |
| 206 | } |
| 207 | |
| 208 | let ok = 0, bad = 0; |
| 209 | const 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. |
| 215 | const 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. |
| 223 | const s = await open({ name: 'chatworkspace', signIn: false, connect: false }); |
| 224 | const { 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. |
| 228 | async 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 | } |
| 242 | await installBreak(); |
| 243 | await page.goto(process.env.DAIMOND_APP || 'http://localhost:8777', { waitUntil: 'domcontentloaded' }); |
| 244 | const { signInAs, connectMock } = await import('./harness.mjs'); |
| 245 | await signInAs(s, 'chatworkspace'); |
| 246 | await connectMock(s); |
| 247 | await page.waitForTimeout(1500); |
| 248 | |
| 249 | const 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. |
| 253 | const FILE_BODY = '# Spec\nthe sentence that proves the quote is the FILE\n'; |
| 254 | await 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 | |
| 261 | await newChat(s); |
| 262 | await page.waitForTimeout(600); |
| 263 | const focus = await page.evaluate(() => window.DaimondAttach.focus()); |
| 264 | const chatId = focus && focus.id; |
| 265 | check('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. |
| 280 | const 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 | |
| 304 | const scopeOf = () => page.evaluate((id) => window.DaimondAttach.chatScope(id), chatId); |
| 305 | |
| 306 | // ── 1. THE EMPTY STATE, which is the state that most needs saying ────── |
| 307 | await 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 | }); |
| 333 | await shot(s, 'chatworkspace-1-empty'); |
| 334 | |
| 335 | // ── 2. Attaching is not marking ──────────────────────────────────────── |
| 336 | await 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 ────────────────────────── |
| 362 | await 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 | }); |
| 391 | await shot(s, 'chatworkspace-2-marked'); |
| 392 | |
| 393 | // ── 3b. The `+` in that group means what its position says ──────────── |
| 394 | await 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 ─────────────────────────────────── |
| 421 | await 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 ────────────────── |
| 452 | await 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 | }); |
| 474 | await shot(s, 'chatworkspace-3-read-quoted'); |
| 475 | |
| 476 | // ── 6. The mark is written down ──────────────────────────────────────── |
| 477 | await 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 ─────────────────────────────── |
| 493 | await 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 ─────────────────── |
| 546 | await 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 ─────────────────────────────────────────── |
| 613 | await 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. |
| 647 | const GATEWAY_NOISE = /502 \(Bad Gateway\)/; |
| 648 | const errs = errors(s).filter(e => !GATEWAY_NOISE.test(e)); |
| 649 | check('nothing threw along the way', errs.length === 0, errs.slice(0, 3).join(' | ')); |
| 650 | console.log(`\n${ok} ok, ${bad} failed`); |
| 651 | if (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 | } |
| 657 | await s.close(); |
| 658 | process.exit(bad ? 1 : 0); |