oxedyne/daimond/dev/verify_fsa.mjs
24.5 KiB, 1 run
created by r2519314175:427, 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_fsa.mjs — real-folder mode: the root swap, and the state that must not follow it. |
| 2 | // |
| 3 | // FSA lets the owner point Daimond at a real directory, so the agents edit actual files instead |
| 4 | // of the OPFS sandbox. It has never been driven, because showDirectoryPicker() opens a native |
| 5 | // dialog: Chrome routes it through Page.setInterceptFileChooserDialog, which Playwright enables |
| 6 | // and cannot answer for a *directory*, so the call aborts. That blocks the picker -- it does not |
| 7 | // block the feature. |
| 8 | // |
| 9 | // The insight this test rests on: what the picker returns is a FileSystemDirectoryHandle, and OPFS |
| 10 | // hands out the very same type. An OPFS subdirectory handle is a directory handle, answers |
| 11 | // queryPermission with 'granted', and structured-clones into IndexedDB -- so it can stand in for a |
| 12 | // picked folder everywhere the code touches one. What is NOT covered is the one thing only a real |
| 13 | // folder can show: that the bytes land on the user's actual disk. Everything up to that boundary |
| 14 | // is covered here. |
| 15 | // |
| 16 | // The claim that matters most is the last one. Real-folder mode points the *file tools* at the |
| 17 | // user's directory, and Daimond's own state -- the Diamonds, their logs, the .daimond store -- pins |
| 18 | // OPFS on purpose (FileRoot::Opfs, src/wasm/opfs.rs). If that pin ever slipped, opening a folder |
| 19 | // would strew the app's internals through the user's repository. That is the test that earns its |
| 20 | // keep. |
| 21 | import { open, signInAs, shot } from './harness.mjs'; |
| 22 | |
| 23 | const ok = [], bad = []; |
| 24 | const check = (name, pass, detail) => { |
| 25 | (pass ? ok : bad).push(name + (detail ? ' — ' + detail : '')); |
| 26 | console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : '')); |
| 27 | }; |
| 28 | |
| 29 | const FOLDER = 'realfolder'; // an OPFS subdirectory standing in for a picked folder |
| 30 | |
| 31 | const s = await open({ name: 'fsa', connect: false }); |
| 32 | const p = s.page; |
| 33 | await p.waitForTimeout(1500); |
| 34 | |
| 35 | // ── 0. Where the switch lives ─────────────────────────────────────────── |
| 36 | // |
| 37 | // Opening a folder is not a file operation. It used to be an icon in the Workspace header, |
| 38 | // between New file, New folder and Upload — the only control there that acts on the workspace |
| 39 | // rather than on a file, which read as "make a folder" and left the mode chip looking like a |
| 40 | // label with no switch. It belongs beside the chip that says which files the agent is touching. |
| 41 | |
| 42 | // The switch is now the Machine CHIP itself: a chip states where things are, and |
| 43 | // clicking the one that is not current is what moves the agent there. So what has |
| 44 | // to be true is that the row carries an actionable Machine chip -- not that it |
| 45 | // carries a button reading "Open a folder…", which is what it was before the |
| 46 | // chips took over. |
| 47 | const where = await p.evaluate(() => { |
| 48 | const row = document.querySelector('.files-mode'); |
| 49 | const header = document.querySelector('.files-actions'); |
| 50 | const btns = [...(row ? row.querySelectorAll('.files-mode-btn') : [])].map(b => b.textContent); |
| 51 | const chips = [...(row ? row.querySelectorAll('.files-mode-chip') : [])]; |
| 52 | const machine = chips.find(c => /Machine|💻/.test(c.textContent)); |
| 53 | return { |
| 54 | inRow: !!(machine && machine.classList.contains('act')), |
| 55 | inHeader: !!(header && header.querySelector('[data-act="open-folder"]')), |
| 56 | chips: chips.map(c => c.textContent.trim()).join(' | '), |
| 57 | buttons: btns, |
| 58 | }; |
| 59 | }); |
| 60 | check('the switch to a real folder sits in the mode row, as the Machine chip', |
| 61 | where.inRow === true, `${where.chips} · ${where.buttons.join(' | ')}`); |
| 62 | check('and no longer hides among the file buttons in the header', |
| 63 | where.inHeader === false); |
| 64 | |
| 65 | // ── A. The root swap, at the wasm edge ────────────────────────────────── |
| 66 | |
| 67 | const swap = await p.evaluate(async ({ folder }) => { |
| 68 | const mod = await import('../pkg/oxedyne_daimond.js'); |
| 69 | const root = await navigator.storage.getDirectory(); |
| 70 | const dir = await root.getDirectoryHandle(folder, { create: true }); |
| 71 | |
| 72 | const before = mod.workspace_mode(); |
| 73 | mod.set_workspace_dir(dir); |
| 74 | const after = mod.workspace_mode(); |
| 75 | |
| 76 | // Write through the agent's own file tool, which is the thing real-folder mode redirects. |
| 77 | const app = new mod.DaimondApp('http://127.0.0.1/v1/chat/completions', '', 'none', 256, '', true); |
| 78 | const wrote = await app.run_tool('file_write', |
| 79 | JSON.stringify({ path: 'notes.md', content: 'written into the real folder' })); |
| 80 | const listed = await app.run_tool('file_list', JSON.stringify({ path: '.' })); |
| 81 | const read = await app.run_tool('file_read', JSON.stringify({ path: 'notes.md' })); |
| 82 | |
| 83 | // Where did the bytes actually land? Ask OPFS directly, not the tool that wrote them. |
| 84 | const at = async (d, name) => { |
| 85 | try { await d.getFileHandle(name); return true; } catch (e) { return false; } |
| 86 | }; |
| 87 | const inFolder = await at(dir, 'notes.md'); |
| 88 | const atRoot = await at(root, 'notes.md'); |
| 89 | |
| 90 | return { before, after, wrote, listed, read, inFolder, atRoot }; |
| 91 | }, { folder: FOLDER }); |
| 92 | |
| 93 | check('the workspace starts in the OPFS sandbox', swap.before === 'opfs', swap.before); |
| 94 | check('opening a folder swaps the root', swap.after === 'folder', swap.after); |
| 95 | check('a file tool writes into the folder, not the sandbox', |
| 96 | swap.inFolder === true && swap.atRoot === false, |
| 97 | `in folder: ${swap.inFolder}, at OPFS root: ${swap.atRoot}`); |
| 98 | check('and reads it back through the folder', /written into the real folder/.test(swap.read || '')); |
| 99 | check('and lists it there', /notes\.md/.test(swap.listed || '')); |
| 100 | |
| 101 | // ── B. The invariant: Daimond's own state must not follow the root ────── |
| 102 | // |
| 103 | // A Diamond keeps its crystal, its log and its deltas under FileRoot::Opfs. With a folder open, that |
| 104 | // state must still be in the sandbox — if it followed the swap, opening a repository would write |
| 105 | // Daimond's internals into it. |
| 106 | |
| 107 | const pinned = await p.evaluate(async ({ folder }) => { |
| 108 | const mod = await import('../pkg/oxedyne_daimond.js'); |
| 109 | const root = await navigator.storage.getDirectory(); |
| 110 | const dir = await root.getDirectoryHandle(folder, { create: true }); |
| 111 | |
| 112 | const app = new mod.DaimondApp('http://127.0.0.1/v1/chat/completions', '', 'none', 256, '', true); |
| 113 | const id = await app.create_diamond('A Diamond made while a folder is open'); |
| 114 | |
| 115 | const has = async (d, name) => { |
| 116 | try { await d.getDirectoryHandle(name); return true; } catch (e) { return false; } |
| 117 | }; |
| 118 | return { |
| 119 | id, |
| 120 | diamondsInSandbox: await has(root, 'diamonds'), |
| 121 | diamondsInFolder: await has(dir, 'diamonds'), |
| 122 | mode: mod.workspace_mode(), |
| 123 | listed: JSON.parse(await app.list_diamonds() || '[]').length, |
| 124 | }; |
| 125 | }, { folder: FOLDER }); |
| 126 | |
| 127 | check('a Diamond made with a folder open still lives in the sandbox', |
| 128 | pinned.diamondsInSandbox === true, 'diamonds/ in OPFS: ' + pinned.diamondsInSandbox); |
| 129 | check("Daimond's own state never lands in the user's folder", |
| 130 | pinned.diamondsInFolder === false, 'diamonds/ in the folder: ' + pinned.diamondsInFolder); |
| 131 | check('and the Diamond is readable while the folder is open', pinned.listed >= 1, |
| 132 | pinned.listed + ' diamonds'); |
| 133 | |
| 134 | // ── C. Switching back ─────────────────────────────────────────────────── |
| 135 | |
| 136 | const back = await p.evaluate(async () => { |
| 137 | const mod = await import('../pkg/oxedyne_daimond.js'); |
| 138 | mod.use_opfs_workspace(); |
| 139 | const app = new mod.DaimondApp('http://127.0.0.1/v1/chat/completions', '', 'none', 256, '', true); |
| 140 | // The folder's file is no longer in view: the tools are back on the sandbox root. |
| 141 | const read = await app.run_tool('file_read', JSON.stringify({ path: 'notes.md' })); |
| 142 | return { mode: mod.workspace_mode(), read }; |
| 143 | }); |
| 144 | check('switching back returns the tools to the sandbox', back.mode === 'opfs', back.mode); |
| 145 | check("and the folder's files are out of view", |
| 146 | /^\s*Error\b/i.test(back.read || ''), (back.read || '').slice(0, 40)); |
| 147 | |
| 148 | // ── D. Reconnect on boot, through the app's own path ──────────────────── |
| 149 | // |
| 150 | // tryReconnect() runs at boot: it loads the stored handle, checks queryPermission, and reactivates |
| 151 | // the folder. Seeding the handle where it looks drives that whole path -- FsaDB, activateFolder, |
| 152 | // the mode indicator -- without a picker. |
| 153 | |
| 154 | await p.evaluate(async ({ folder }) => { |
| 155 | const root = await navigator.storage.getDirectory(); |
| 156 | const dir = await root.getDirectoryHandle(folder, { create: true }); |
| 157 | const db = await new Promise((res, rej) => { |
| 158 | const q = indexedDB.open('daimond-fsa', 1); |
| 159 | q.onupgradeneeded = () => q.result.createObjectStore('handles'); |
| 160 | q.onsuccess = () => res(q.result); |
| 161 | q.onerror = () => rej(q.error); |
| 162 | }); |
| 163 | await new Promise((res, rej) => { |
| 164 | const t = db.transaction('handles', 'readwrite'); |
| 165 | t.objectStore('handles').put(dir, 'workspace'); |
| 166 | t.oncomplete = res; |
| 167 | t.onerror = () => rej(t.error); |
| 168 | }); |
| 169 | }, { folder: FOLDER }); |
| 170 | |
| 171 | await p.reload({ waitUntil: 'domcontentloaded' }); |
| 172 | await signInAs(s, 'fsa'); |
| 173 | await p.waitForTimeout(2500); |
| 174 | |
| 175 | /// The mode row carries THREE chips -- Browser, Machine and Cloud -- and the one |
| 176 | /// that answers "where is the agent working" is whichever carries `active`. |
| 177 | /// Reading the first chip in the row would always read "🗄 Browser" and say |
| 178 | /// nothing about the folder. |
| 179 | const chips = () => p.evaluate(() => { |
| 180 | const cs = [...document.querySelectorAll('.files-mode-chip')]; |
| 181 | const on = cs.find(c => c.classList.contains('active')); |
| 182 | return { |
| 183 | active: on ? on.textContent.trim() : '(none)', |
| 184 | all: cs.map(c => c.textContent.trim()).join(' | '), |
| 185 | }; |
| 186 | }); |
| 187 | |
| 188 | const reconnected = await p.evaluate(async () => { |
| 189 | const mod = await import('../pkg/oxedyne_daimond.js'); |
| 190 | return { mode: mod.workspace_mode() }; |
| 191 | }); |
| 192 | reconnected.chips = await chips(); |
| 193 | check('the folder is reconnected on the next visit, with no prompt', |
| 194 | reconnected.mode === 'folder', reconnected.mode); |
| 195 | check('and the panel says which folder the agent is touching', |
| 196 | reconnected.chips.active.includes(FOLDER), reconnected.chips.all); |
| 197 | |
| 198 | // ── D2. Opening a file in the folder shows it as what it is ───────────── |
| 199 | // |
| 200 | // Reported as "clicking a PDF in the Machine workspace displays it as raw text". |
| 201 | // It was not the folder's doing -- the Doc panel routed on whether the leading |
| 202 | // bytes decode as characters, which the front of a PDF with no binary comment |
| 203 | // does, and it did that whichever root was open. But the folder is where a |
| 204 | // person's real PDFs are, so it is where the bug was met, and this is the check |
| 205 | // that says the root is not what decides. `dev/verify_fileview.mjs` owns the |
| 206 | // routing itself and owns proving the page actually renders; this one owns |
| 207 | // "and the same is true through a real folder". |
| 208 | // |
| 209 | // ASCII from end to end, and no binary comment: that is the failing class. |
| 210 | const ASCII_PDF = '%PDF-1.4\n' |
| 211 | + '1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj\n' |
| 212 | + '2 0 obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj\n' |
| 213 | + '3 0 obj<</Type/Page/Parent 2 0 R/MediaBox[0 0 99 99]>>endobj\n' |
| 214 | + 'trailer<</Root 1 0 R>>\n%%EOF\n'; |
| 215 | |
| 216 | const pdfInFolder = await p.evaluate(async ({ folder, text }) => { |
| 217 | // Written through the FOLDER HANDLE, not through a file tool: the file is |
| 218 | // one the user already had, not one Daimond put there. |
| 219 | const root = await navigator.storage.getDirectory(); |
| 220 | const dir = await root.getDirectoryHandle(folder, { create: true }); |
| 221 | const fh = await dir.getFileHandle('paper.pdf', { create: true }); |
| 222 | const w = await fh.createWritable(); |
| 223 | await w.write(new TextEncoder().encode(text)); |
| 224 | await w.close(); |
| 225 | // The tree was drawn before that write, so it is relisted through the |
| 226 | // panel's own Refresh. |
| 227 | const r = document.querySelector('.files-actions [data-act="refresh"]'); |
| 228 | if (r) r.click(); |
| 229 | await new Promise((res) => setTimeout(res, 1200)); |
| 230 | const row = document.querySelector('.files-tree .files-row[data-path="paper.pdf"]'); |
| 231 | if (!row) return { listed: false }; |
| 232 | row.click(); |
| 233 | await new Promise((res) => setTimeout(res, 1800)); |
| 234 | // The PREVIEW panel for the rendering, the DOC panel for the editor: a file |
| 235 | // that is not characters is drawn in one and must not reach the other. |
| 236 | const fv = document.querySelector('#pv-view .fileview'); |
| 237 | const pre = document.querySelector('#doc-view .files-view-body'); |
| 238 | return { |
| 239 | listed: true, |
| 240 | viewer: fv ? fv.getAttribute('data-viewer') : null, |
| 241 | embed: !!document.querySelector('#pv-view .fileview embed'), |
| 242 | pre: pre ? pre.textContent.slice(0, 60) : null, |
| 243 | }; |
| 244 | }, { folder: FOLDER, text: ASCII_PDF }); |
| 245 | |
| 246 | check('a PDF in the open folder is listed where the user can click it', |
| 247 | pdfInFolder.listed === true, JSON.stringify(pdfInFolder)); |
| 248 | check('and clicking it shows the PDF, not its bytes as characters', |
| 249 | pdfInFolder.viewer === 'doc' && pdfInFolder.embed === true && pdfInFolder.pre === null, |
| 250 | JSON.stringify(pdfInFolder)); |
| 251 | |
| 252 | // ── E. A grant that is taken away ─────────────────────────────────────── |
| 253 | // |
| 254 | // The browser can withdraw a folder at any time, and every tool call that touches it then fails |
| 255 | // while the app goes on naming a folder the agent cannot reach. Two things have to be true: an |
| 256 | // ORDINARY failure must not tear the folder down, and a withdrawn grant must. |
| 257 | |
| 258 | // The negative case first, and it is the one that would do real damage if wrong: reading a file |
| 259 | // that is not there fails, as it should, and the folder must survive it. |
| 260 | const ordinary = await p.evaluate(async () => { |
| 261 | const mod = await import('../pkg/oxedyne_daimond.js'); |
| 262 | const app = new mod.DaimondApp('http://127.0.0.1/v1/chat/completions', '', 'none', 256, '', true); |
| 263 | const res = await app.run_tool('file_read', JSON.stringify({ path: 'no-such-file.md' })); |
| 264 | await new Promise(r => setTimeout(r, 300)); |
| 265 | return { res: (res || '').slice(0, 30), mode: mod.workspace_mode() }; |
| 266 | }); |
| 267 | ordinary.chips = await chips(); |
| 268 | check('an ordinary tool error does not tear the folder down', |
| 269 | ordinary.mode === 'folder' && ordinary.chips.active.includes(FOLDER), |
| 270 | `mode: ${ordinary.mode}, chips: ${ordinary.chips.all}`); |
| 271 | |
| 272 | // And the positive: the edge raises `daimond:folder-lost`, and the app must drop to the sandbox |
| 273 | // and say so rather than carry on against a folder it no longer has. |
| 274 | const lost = await p.evaluate(async () => { |
| 275 | window.dispatchEvent(new CustomEvent('daimond:folder-lost')); |
| 276 | await new Promise(r => setTimeout(r, 600)); |
| 277 | const mod = await import('../pkg/oxedyne_daimond.js'); |
| 278 | const msg = document.querySelector('.files-mode-msg'); |
| 279 | // The way back is the Machine chip itself, relabelled "💻 Reconnect <name>" |
| 280 | // by renderMode(lost) -- an offer that needs the user's gesture, never a |
| 281 | // prompt on load. It is not a separate button. |
| 282 | const back = [...document.querySelectorAll('.files-mode-chip')] |
| 283 | .some(c => /Reconnect/i.test(c.textContent)); |
| 284 | return { |
| 285 | mode: mod.workspace_mode(), |
| 286 | msg: msg ? msg.textContent.trim() : '(none)', |
| 287 | reconnect: back, |
| 288 | }; |
| 289 | }); |
| 290 | lost.chips = await chips(); |
| 291 | check('a withdrawn grant drops the agent back to the sandbox', |
| 292 | lost.mode === 'opfs', lost.mode); |
| 293 | // The row still NAMES the folder, in the reconnect offer -- what it must not do |
| 294 | // is go on claiming the agent is working there, so it is the ACTIVE chip that |
| 295 | // has to have moved back to the browser sandbox. |
| 296 | check('and the panel stops claiming a folder it cannot reach', |
| 297 | !lost.chips.active.includes(FOLDER), lost.chips.all); |
| 298 | check('and the user is told, and offered a way back', |
| 299 | /Lost access/i.test(lost.msg) && lost.reconnect === true, |
| 300 | `${lost.msg} · reconnect offered: ${lost.reconnect}`); |
| 301 | |
| 302 | // ── C2. Going to the sandbox and coming back ──────────────────────────── |
| 303 | // |
| 304 | // The round trip a user actually makes: point the agent at a folder, send it back to the browser |
| 305 | // sandbox for something that has to sync, then put it back on the folder. It cost a folder picker |
| 306 | // EVERY time, because the Machine chip called `showDirectoryPicker` unconditionally and switching |
| 307 | // to Browser had already deleted the stored handle -- so there was nothing left to go back to. |
| 308 | // Two bugs producing one symptom, and the symptom is a native dialog no test could answer. |
| 309 | // |
| 310 | // The picker is stubbed with a counter that aborts. That is honest to what a picker does when the |
| 311 | // user dismisses it, and it turns "was a dialog shown" -- otherwise unobservable -- into a number. |
| 312 | // Nothing below asserts that a picker WORKS; what is asserted is when one is asked for. |
| 313 | |
| 314 | await p.evaluate(() => { |
| 315 | window.__pick = 0; |
| 316 | window.showDirectoryPicker = function (opts) { |
| 317 | window.__pick++; |
| 318 | window.__pickOpts = opts || null; |
| 319 | var e = new Error('The user aborted a request.'); |
| 320 | e.name = 'AbortError'; // what Chrome throws on a dismissed picker |
| 321 | return Promise.reject(e); |
| 322 | }; |
| 323 | }); |
| 324 | |
| 325 | const mode = () => p.evaluate(async () => (await import('../pkg/oxedyne_daimond.js')).workspace_mode()); |
| 326 | const picks = () => p.evaluate(() => window.__pick); |
| 327 | const zero = () => p.evaluate(() => { window.__pick = 0; }); |
| 328 | const msg = () => p.evaluate(() => { |
| 329 | const m = document.querySelector('.files-mode-msg'); |
| 330 | return m ? m.textContent.trim() : '(none)'; |
| 331 | }); |
| 332 | /// Click a chip in the mode row by its visible text. The Machine chip is named for the folder |
| 333 | /// when there is one, so it is matched on either. |
| 334 | const clickChip = (pat) => p.evaluate((pat) => { |
| 335 | const c = [...document.querySelectorAll('.files-mode-chip')] |
| 336 | .find(x => new RegExp(pat, 'i').test(x.textContent)); |
| 337 | if (!c) return false; |
| 338 | c.click(); |
| 339 | return true; |
| 340 | }, pat); |
| 341 | const clickBtn = (pat) => p.evaluate((pat) => { |
| 342 | const b = [...document.querySelectorAll('.files-mode-btn')] |
| 343 | .find(x => new RegExp(pat, 'i').test(x.textContent)); |
| 344 | if (!b) return false; |
| 345 | b.click(); |
| 346 | return true; |
| 347 | }, pat); |
| 348 | /// Does IndexedDB still hold a real directory handle for the workspace? Read from the account's |
| 349 | /// own database name, exactly as FsaDB composes it. |
| 350 | const storedHandle = () => p.evaluate(async () => { |
| 351 | const name = 'daimond-fsa' |
| 352 | + (window.DaimondAccounts && DaimondAccounts.opfsNs() ? '-' + DaimondAccounts.opfsNs() : ''); |
| 353 | const db = await new Promise((res, rej) => { |
| 354 | const q = indexedDB.open(name, 1); |
| 355 | q.onupgradeneeded = () => q.result.createObjectStore('handles'); |
| 356 | q.onsuccess = () => res(q.result); |
| 357 | q.onerror = () => rej(q.error); |
| 358 | }); |
| 359 | const v = await new Promise((res) => { |
| 360 | const t = db.transaction('handles', 'readonly'); |
| 361 | const r = t.objectStore('handles').get('workspace'); |
| 362 | r.onsuccess = () => res(r.result); |
| 363 | r.onerror = () => res(undefined); |
| 364 | }); |
| 365 | db.close(); |
| 366 | return { held: !!v, isDir: !!(v && typeof v.getDirectoryHandle === 'function'), name: v ? v.name : '' }; |
| 367 | }); |
| 368 | |
| 369 | // Back onto the folder, through the reconnect offer section E left standing. That is the user's |
| 370 | // own way back and it needs no picker, so the counter must still be zero afterwards. |
| 371 | await clickChip('Reconnect'); |
| 372 | await p.waitForTimeout(900); |
| 373 | check('C2 setup: the reconnect offer puts the agent back on the folder', |
| 374 | (await mode()) === 'folder', await mode()); |
| 375 | |
| 376 | // The ACTIVE Machine chip states the root's SCOPE. A user is being asked to point an agent at a |
| 377 | // directory on their disk; the one thing they need told is how far it reaches. The chip used to |
| 378 | // re-open the picker instead, which is the least useful thing it could do while already there. |
| 379 | await zero(); |
| 380 | await clickChip('Machine|' + FOLDER); |
| 381 | await p.waitForTimeout(600); |
| 382 | const info = { msg: await msg(), picks: await picks() }; |
| 383 | check('the active Machine chip states what the agent can reach, and asks for no picker', |
| 384 | /works only inside/i.test(info.msg) && info.msg.includes(FOLDER) && info.picks === 0, |
| 385 | `"${info.msg}" · pickers: ${info.picks}`); |
| 386 | |
| 387 | // Changing the root is its own control. It is the ONE thing that should raise a picker. |
| 388 | await zero(); |
| 389 | const hasChange = await clickBtn('Change folder'); |
| 390 | await p.waitForTimeout(600); |
| 391 | check('a separate "Change folder…" control is what raises the picker', |
| 392 | hasChange === true && (await picks()) === 1, |
| 393 | `control present: ${hasChange}, pickers: ${await picks()}`); |
| 394 | // And it starts where the user already is, rather than in a default they have never used. |
| 395 | const opts = await p.evaluate(() => window.__pickOpts); |
| 396 | check('and it opens where the current root is', |
| 397 | !!opts && opts.mode === 'readwrite' && !!opts.startIn && opts.startIn !== 'documents', |
| 398 | JSON.stringify(opts)); |
| 399 | |
| 400 | // The round trip. Browser, then back -- and no picker anywhere in it. |
| 401 | await zero(); |
| 402 | await clickChip('Browser'); |
| 403 | await p.waitForTimeout(900); |
| 404 | const wentBrowser = await mode(); |
| 405 | const kept = await storedHandle(); |
| 406 | check('switching to the browser sandbox takes the agent there', |
| 407 | wentBrowser === 'opfs', wentBrowser); |
| 408 | check('and does NOT delete the folder it is coming back to', |
| 409 | kept.held === true && kept.isDir === true, `stored: ${JSON.stringify(kept)}`); |
| 410 | |
| 411 | await clickChip('Machine|' + FOLDER); |
| 412 | await p.waitForTimeout(1200); |
| 413 | const wentBack = await mode(); |
| 414 | const backChips = await chips(); |
| 415 | check('and the way back is one click on the chip, with no folder picker', |
| 416 | wentBack === 'folder' && (await picks()) === 0, |
| 417 | `mode: ${wentBack}, pickers: ${await picks()}`); |
| 418 | check('the chip names the folder the agent is back in', |
| 419 | backChips.active.includes(FOLDER), backChips.all); |
| 420 | |
| 421 | // The guard. Coming back is a ROOT SWAP, and a root swap while an agent is mid-turn leaves it |
| 422 | // reading and writing somewhere else entirely. `openFolder` and `switchToOpfs` have always |
| 423 | // refused; the reconnect path had no guard at all, and routing the chip through it is exactly |
| 424 | // what would have made that gap reachable by an ordinary click. |
| 425 | await clickChip('Browser'); |
| 426 | await p.waitForTimeout(700); |
| 427 | await p.evaluate(() => { window.__busyWas = DaimondCore.busy; DaimondCore.busy = function () { return true; }; }); |
| 428 | await zero(); |
| 429 | await clickChip('Machine|' + FOLDER); |
| 430 | await p.waitForTimeout(700); |
| 431 | const blocked = { mode: await mode(), msg: await msg() }; |
| 432 | // THE PROPERTY. Two halves, and the first is the one that matters: with an |
| 433 | // agent mid-turn the root DID NOT MOVE (`rootSwitchBlocked`, daimond.js, is |
| 434 | // what every entry to a swap goes through, including the chip's reconnect |
| 435 | // path). An agent resolves every path against one root, so a swap under it |
| 436 | // writes the user's work into a folder nobody told it about. The guard returns |
| 437 | // before the picker is ever raised, so nothing about the root is touched. |
| 438 | // |
| 439 | // The second half is that the person is told WHY it refused, which means the |
| 440 | // message has to blame the running agent -- name it, hold the change off on |
| 441 | // account of it, and say what is being held off. `/agent/` alone would pass for |
| 442 | // "an agent did something, carry on"; dropping the message clause entirely |
| 443 | // would pass for a silent refusal, which is what this used to be and is how it |
| 444 | // reads as a broken chip. |
| 445 | // |
| 446 | // This was `/agent is working/`, which the copy stopped saying in those words |
| 447 | // while saying the same thing: "Wait for the agent to finish before changing |
| 448 | // where it works." |
| 449 | const refusalBlamesTheAgent = (m) => ({ |
| 450 | // The agent is named as the reason... |
| 451 | agent: /\bagent\b/i.test(m), |
| 452 | // ...the change is held off rather than reported as done... |
| 453 | held: /\b(wait|not|never|cannot|can[’']t|won[’']t|refus\w*|block\w*|busy|until|while|before|first|try again)\b/i.test(m), |
| 454 | // ...and what is held off is where the agent works. |
| 455 | root: /\b(where it works|where the agent works|workspace|folder|root|mov(e|ing)|chang(e|ing)|switch(ing)?)\b/i.test(m), |
| 456 | // ...and it is not a success notice wearing a warning's clothes. |
| 457 | notDone: !/\b(is now|now in|now at|switched to|moved to|changed to|has changed|done)\b/i.test(m), |
| 458 | }); |
| 459 | const why = refusalBlamesTheAgent(blocked.msg); |
| 460 | check('a root swap is refused while an agent is working, and blames the agent for the refusal', |
| 461 | blocked.mode === 'opfs' && why.agent && why.held && why.root && why.notDone, |
| 462 | `mode: ${blocked.mode} · "${blocked.msg}"` |
| 463 | + (Object.values(why).every(Boolean) ? '' : ' · ' + Object.entries(why) |
| 464 | .filter(([, v]) => !v).map(([k]) => 'no ' + k).join(', '))); |
| 465 | await p.evaluate(() => { DaimondCore.busy = window.__busyWas; }); |
| 466 | |
| 467 | // And the way out. Remembering the folder across a switch removes the only thing that used to |
| 468 | // make Daimond forget it, so there has to be a deliberate way to say so. |
| 469 | await clickChip('Machine|' + FOLDER); |
| 470 | await p.waitForTimeout(900); |
| 471 | await clickChip('Machine|' + FOLDER); // the info context, where Forget lives |
| 472 | await p.waitForTimeout(400); |
| 473 | const hasForget = await clickBtn('Forget this folder'); |
| 474 | await p.waitForTimeout(800); |
| 475 | const forgotten = await storedHandle(); |
| 476 | check('and "Forget this folder" really does forget it', |
| 477 | hasForget === true && forgotten.held === false, |
| 478 | `control present: ${hasForget}, stored: ${JSON.stringify(forgotten)}`); |
| 479 | |
| 480 | await shot(s, 'fsa'); |
| 481 | const errs = s.errs.filter(e => !/favicon|404|401|net::ERR/.test(e)); |
| 482 | console.log('\nconsole errors:', errs.slice(0, 4)); |
| 483 | await s.close(); |
| 484 | |
| 485 | console.log(`\n${ok.length} passed, ${bad.length} failed`); |
| 486 | if (bad.length) console.log('FAILED:\n ' + bad.join('\n ')); |
| 487 | process.exit(bad.length ? 1 : 0); |