oxedyne/daimond/dev/verify_savecopy.mjs
11.0 KiB, 1 run
created by r2519314175:657, 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_savecopy.mjs — the two folder transfers must read and write the root |
| 2 | // the agent is actually on. |
| 3 | // |
| 4 | // "Save a copy" LISTED the workspace through `file_list` — which resolves the |
| 5 | // real-folder override — and then fetched the bytes from OPFS. "Copy in a |
| 6 | // folder" did the mirror: it read the chosen folder and wrote into OPFS. In the |
| 7 | // browser sandbox the two roots are the same directory and nothing shows; with |
| 8 | // a real folder open they are different directories, and then: |
| 9 | // |
| 10 | // * the export lists the user's real files, finds nothing under those names in |
| 11 | // OPFS, copies nothing, and finishes with a status line rather than an |
| 12 | // error — a backup that is empty at the moment it is needed; |
| 13 | // * the import writes into the sandbox, where the agent — working in the real |
| 14 | // folder — cannot see any of it. |
| 15 | // |
| 16 | // The mode row hides both buttons while a folder is open, so the way to reach |
| 17 | // them in that state is a mode change WHILE the folder picker is up: the boot |
| 18 | // reconnect (`Files.tryReconnect`) activates a stored handle asynchronously, and |
| 19 | // a `daimond:folder-lost` event moves the mode the other way. That is modelled |
| 20 | // here exactly — the stubbed picker clicks the app's own Machine chip, which |
| 21 | // reconnects the folder through `reconnectFolder`/`activateFolder`, and only |
| 22 | // then resolves. Nothing about the transfer's own code path is faked. |
| 23 | // |
| 24 | // Needs dev/serve.mjs (DAIMOND_PORT, default 8777) and dev/mockllm.mjs |
| 25 | // (DAIMOND_MOCK_PORT, default 9099). No gateway. |
| 26 | import { open, errors } from './harness.mjs'; |
| 27 | |
| 28 | const ok = [], bad = []; |
| 29 | const check = (name, pass, detail) => { |
| 30 | (pass ? ok : bad).push(name + (detail ? ' — ' + detail : '')); |
| 31 | console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : '')); |
| 32 | }; |
| 33 | |
| 34 | const s = await open({ name: 'savecopy' }); |
| 35 | const p = s.page; |
| 36 | |
| 37 | /// Make an OPFS directory at `path` (slash-separated) and fill it. |
| 38 | /// |
| 39 | /// The two DESTINATIONS are dot-prefixed, because OPFS is the only place this |
| 40 | /// test can make a directory handle and both transfers skip dotfiles: a |
| 41 | /// destination the sandbox export can see is a destination it copies into |
| 42 | /// itself, for ever. |
| 43 | const mkdir = (path, files) => p.evaluate(async ([path, files]) => { |
| 44 | let dir = await navigator.storage.getDirectory(); |
| 45 | for (const seg of path.split('/')) dir = await dir.getDirectoryHandle(seg, { create: true }); |
| 46 | for (const [fn, body] of files) { |
| 47 | const fh = await dir.getFileHandle(fn, { create: true }); |
| 48 | const w = await fh.createWritable(); |
| 49 | await w.write(new TextEncoder().encode(body)); |
| 50 | await w.close(); |
| 51 | } |
| 52 | return true; |
| 53 | }, [path, files]); |
| 54 | |
| 55 | /// Everything an OPFS directory holds, as `path:content` pairs. |
| 56 | const listDir = (path) => p.evaluate(async (path) => { |
| 57 | let dir = await navigator.storage.getDirectory(); |
| 58 | try { for (const seg of path.split('/')) dir = await dir.getDirectoryHandle(seg); } |
| 59 | catch (e) { return ['(no such directory)']; } |
| 60 | const out = []; |
| 61 | const walk = async (d, pre) => { |
| 62 | for await (const ent of d.entries()) { |
| 63 | const at = pre ? pre + '/' + ent[0] : ent[0]; |
| 64 | if (ent[1].kind === 'directory') { await walk(ent[1], at); continue; } |
| 65 | out.push(at + ':' + (await (await ent[1].getFile()).text())); |
| 66 | } |
| 67 | }; |
| 68 | await walk(dir, ''); |
| 69 | return out.sort(); |
| 70 | }, path); |
| 71 | |
| 72 | /// A file as the AGENT sees it — through the tool door, so it answers about |
| 73 | /// whichever root the agent is on. |
| 74 | const agentRead = (path) => p.evaluate(async (path) => { |
| 75 | const mod = await import('../pkg/oxedyne_daimond.js'); |
| 76 | return await mod.read_file(path).catch(() => null); |
| 77 | }, path); |
| 78 | |
| 79 | const mode = () => p.evaluate(async () => { |
| 80 | const mod = await import('../pkg/oxedyne_daimond.js'); |
| 81 | return mod.workspace_mode(); |
| 82 | }); |
| 83 | |
| 84 | /// Click a chip in the mode row by its icon: 0 is Browser, 1 is Machine. |
| 85 | const chip = (i) => p.evaluate((i) => { |
| 86 | const chips = [...document.querySelectorAll('.files-mode-chip')]; |
| 87 | if (chips[i]) chips[i].click(); |
| 88 | return chips.length; |
| 89 | }, i); |
| 90 | |
| 91 | /// Click one of the two transfer buttons by its label. |
| 92 | const transfer = (re) => p.evaluate((re) => { |
| 93 | const btns = [...document.querySelectorAll('.files-mode-btn')]; |
| 94 | const b = btns.find(x => new RegExp(re, 'i').test(x.textContent || '')); |
| 95 | if (b) b.click(); |
| 96 | return btns.map(x => x.textContent); |
| 97 | }, re); |
| 98 | |
| 99 | /// The mode row's own status line, which is all the user is told. |
| 100 | const modeMsg = () => p.evaluate(() => { |
| 101 | const m = document.querySelector('.files-mode-msg'); |
| 102 | return m ? m.textContent : ''; |
| 103 | }); |
| 104 | |
| 105 | // ── The sandbox, a real folder, and a destination ──────────────────────── |
| 106 | |
| 107 | await p.evaluate(async () => { |
| 108 | const mod = await import('../pkg/oxedyne_daimond.js'); |
| 109 | await mod.write_file('sand.md', 'SANDBOX-PAYLOAD'); |
| 110 | }); |
| 111 | await mkdir('picked', [['machine-note.md', 'MACHINE-PAYLOAD']]); |
| 112 | await mkdir('.savedest', []); |
| 113 | await mkdir('.sanddest', []); |
| 114 | // The two sources sit INSIDE a container, so the directory each transfer writes |
| 115 | // under (`handle.name`) is not the directory it read from. Without that, an |
| 116 | // import that wrote to the wrong root would write the same bytes back over its |
| 117 | // own source and leave nothing to see. |
| 118 | await mkdir('srcbox/mach-src', [['from-machine.md', 'IMPORT-PAYLOAD']]); |
| 119 | await mkdir('srcbox/sand-src', [['from-sand.md', 'SAND-IMPORT-PAYLOAD']]); |
| 120 | |
| 121 | // Every directory the picker will ever return, granted the way a user grants a |
| 122 | // folder. |
| 123 | await p.evaluate(async () => { |
| 124 | const root = await navigator.storage.getDirectory(); |
| 125 | const box = await root.getDirectoryHandle('srcbox'); |
| 126 | window.__dirs = {}; |
| 127 | for (const [k, get] of [ |
| 128 | ['picked', () => root.getDirectoryHandle('picked')], |
| 129 | ['savedest', () => root.getDirectoryHandle('.savedest')], |
| 130 | ['sanddest', () => root.getDirectoryHandle('.sanddest')], |
| 131 | ['mach-src', () => box.getDirectoryHandle('mach-src')], |
| 132 | ['sand-src', () => box.getDirectoryHandle('sand-src')], |
| 133 | ]) { |
| 134 | const d = await get(); |
| 135 | d.queryPermission = async () => 'granted'; |
| 136 | d.requestPermission = async () => 'granted'; |
| 137 | window.__dirs[k] = d; |
| 138 | } |
| 139 | window.showDirectoryPicker = async () => window.__dirs.picked; |
| 140 | }); |
| 141 | |
| 142 | await p.evaluate(() => window.DaimondPanels && DaimondPanels.open && DaimondPanels.open('work')); |
| 143 | await p.waitForTimeout(700); |
| 144 | |
| 145 | // ── Into the real folder, then back to the sandbox ─────────────────────── |
| 146 | // The trip back is what leaves `rootHandle` set, so the Machine chip becomes a |
| 147 | // RECONNECT — no picker of its own, exactly like the boot path. |
| 148 | |
| 149 | await chip(1); |
| 150 | await p.waitForTimeout(1200); |
| 151 | check('the app is in real-folder mode', (await mode()) === 'folder', await mode()); |
| 152 | check('and the agent reads the folder\'s file, not the sandbox\'s', |
| 153 | (await agentRead('machine-note.md')) === 'MACHINE-PAYLOAD' && (await agentRead('sand.md')) === null); |
| 154 | |
| 155 | await chip(0); |
| 156 | await p.waitForTimeout(900); |
| 157 | check('back in the sandbox, both transfers are offered', |
| 158 | (await transfer('nothing-matches')).length === 2, (await transfer('nothing-matches')).join(' | ')); |
| 159 | |
| 160 | // ── Save a copy, with the folder reconnecting under it ─────────────────── |
| 161 | |
| 162 | // The picker takes a moment, and the app reconnects the folder in that moment — |
| 163 | // which is what the boot reconnect does. The transfer is already running. |
| 164 | await p.evaluate(() => { |
| 165 | window.__reconnectThenPick = (which) => async () => { |
| 166 | const chips = [...document.querySelectorAll('.files-mode-chip')]; |
| 167 | if (chips[1]) chips[1].click(); |
| 168 | const mod = await import('../pkg/oxedyne_daimond.js'); |
| 169 | for (let i = 0; i < 60 && mod.workspace_mode() !== 'folder'; i++) { |
| 170 | await new Promise(r => setTimeout(r, 50)); |
| 171 | } |
| 172 | return window.__dirs[which]; |
| 173 | }; |
| 174 | window.showDirectoryPicker = window.__reconnectThenPick('savedest'); |
| 175 | }); |
| 176 | await transfer('save a copy'); |
| 177 | await p.waitForTimeout(2500); |
| 178 | |
| 179 | check('the reconnect really did land mid-transfer', (await mode()) === 'folder', await mode()); |
| 180 | const saved = await listDir('.savedest'); |
| 181 | check('a save-a-copy writes the files of the root the agent is actually on', |
| 182 | saved.includes('machine-note.md:MACHINE-PAYLOAD'), saved.join(' ') || '(nothing written)'); |
| 183 | check('and ONLY those files — not the sandbox it is no longer on', |
| 184 | saved.length === 1 && !saved.some(x => x.indexOf('sand.md:') === 0), saved.join(' ')); |
| 185 | check('and what it says it saved is what it saved', |
| 186 | /\b1 file/.test(await modeMsg()) || /Saved 1\b/.test(await modeMsg()), await modeMsg()); |
| 187 | |
| 188 | // ── Copy a folder in, with the same reconnect under it ─────────────────── |
| 189 | |
| 190 | await chip(0); |
| 191 | await p.waitForTimeout(900); |
| 192 | await p.evaluate(() => { window.showDirectoryPicker = window.__reconnectThenPick('mach-src'); }); |
| 193 | await transfer('import a folder|copy in a folder'); |
| 194 | await p.waitForSelector('.dlg-ok', { timeout: 15000 }); |
| 195 | await p.click('.dlg-ok'); |
| 196 | await p.waitForTimeout(2500); |
| 197 | |
| 198 | check('the reconnect landed mid-import too', (await mode()) === 'folder', await mode()); |
| 199 | check('an imported folder lands where the agent can read it', |
| 200 | (await agentRead('mach-src/from-machine.md')) === 'IMPORT-PAYLOAD', |
| 201 | String(await agentRead('mach-src/from-machine.md'))); |
| 202 | const inPicked = await listDir('picked'); |
| 203 | check('which is inside the open folder', |
| 204 | inPicked.includes('mach-src/from-machine.md:IMPORT-PAYLOAD'), inPicked.join(' ')); |
| 205 | const strayed = await p.evaluate(async () => { |
| 206 | const root = await navigator.storage.getDirectory(); |
| 207 | // The sandbox is not the workspace here. A copy at the sandbox root is the |
| 208 | // import having written to the root the agent is NOT on. |
| 209 | return await root.getDirectoryHandle('mach-src').then(() => 'STRAYED').catch(() => 'clean'); |
| 210 | }); |
| 211 | check('and nothing was written into the sandbox instead', strayed === 'clean', strayed); |
| 212 | |
| 213 | // ── The sandbox case, which was never broken and must stay that way ────── |
| 214 | |
| 215 | await chip(0); |
| 216 | await p.waitForTimeout(900); |
| 217 | await p.evaluate(() => { window.showDirectoryPicker = async () => window.__dirs.sanddest; }); |
| 218 | await transfer('save a copy'); |
| 219 | await p.waitForTimeout(3000); |
| 220 | const sandSaved = await listDir('.sanddest'); |
| 221 | check('in the sandbox, a save-a-copy still copies the sandbox out', |
| 222 | sandSaved.includes('sand.md:SANDBOX-PAYLOAD'), sandSaved.slice(0, 4).join(' ')); |
| 223 | |
| 224 | await p.evaluate(() => { window.showDirectoryPicker = async () => window.__dirs['sand-src']; }); |
| 225 | await transfer('import a folder|copy in a folder'); |
| 226 | await p.waitForSelector('.dlg-ok', { timeout: 15000 }); |
| 227 | await p.click('.dlg-ok'); |
| 228 | await p.waitForTimeout(2500); |
| 229 | check('and an import still lands in the sandbox the agent is on', |
| 230 | (await agentRead('sand-src/from-sand.md')) === 'SAND-IMPORT-PAYLOAD', |
| 231 | String(await agentRead('sand-src/from-sand.md'))); |
| 232 | check('the app is back in the sandbox for that', (await mode()) === 'opfs', await mode()); |
| 233 | |
| 234 | // A gateway is not part of this; its calls answer 401 when one is up and 502 |
| 235 | // when it is not, and neither is the app. |
| 236 | const errs = errors(s).filter(e => |
| 237 | !/Failed to load resource.*\b(401|402|404|426|502|503)\b/.test(e) |
| 238 | && !/favicon|net::ERR|api\/sync/.test(e)); |
| 239 | check('nothing threw', errs.length === 0, errs.slice(0, 2).join(' | ')); |
| 240 | |
| 241 | await s.close(); |
| 242 | console.log(`\n${ok.length} passed, ${bad.length} failed`); |
| 243 | if (bad.length) { bad.forEach(b => console.log(' FAILED: ' + b)); process.exit(1); } |