Oregami
Repositories/oxedyne/daimond

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.
26import { open, errors } from './harness.mjs';
27
28const ok = [], bad = [];
29const check = (name, pass, detail) => {
30 (pass ? ok : bad).push(name + (detail ? ' — ' + detail : ''));
31 console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : ''));
32};
33
34const s = await open({ name: 'savecopy' });
35const 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.
43const 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.
56const 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.
74const 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
79const 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.
85const 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.
92const 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.
100const 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
107await p.evaluate(async () => {
108 const mod = await import('../pkg/oxedyne_daimond.js');
109 await mod.write_file('sand.md', 'SANDBOX-PAYLOAD');
110});
111await mkdir('picked', [['machine-note.md', 'MACHINE-PAYLOAD']]);
112await mkdir('.savedest', []);
113await 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.
118await mkdir('srcbox/mach-src', [['from-machine.md', 'IMPORT-PAYLOAD']]);
119await 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.
123await 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
142await p.evaluate(() => window.DaimondPanels && DaimondPanels.open && DaimondPanels.open('work'));
143await 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
149await chip(1);
150await p.waitForTimeout(1200);
151check('the app is in real-folder mode', (await mode()) === 'folder', await mode());
152check('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
155await chip(0);
156await p.waitForTimeout(900);
157check('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.
164await 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});
176await transfer('save a copy');
177await p.waitForTimeout(2500);
178
179check('the reconnect really did land mid-transfer', (await mode()) === 'folder', await mode());
180const saved = await listDir('.savedest');
181check('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)');
183check('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(' '));
185check('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
190await chip(0);
191await p.waitForTimeout(900);
192await p.evaluate(() => { window.showDirectoryPicker = window.__reconnectThenPick('mach-src'); });
193await transfer('import a folder|copy in a folder');
194await p.waitForSelector('.dlg-ok', { timeout: 15000 });
195await p.click('.dlg-ok');
196await p.waitForTimeout(2500);
197
198check('the reconnect landed mid-import too', (await mode()) === 'folder', await mode());
199check('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')));
202const inPicked = await listDir('picked');
203check('which is inside the open folder',
204 inPicked.includes('mach-src/from-machine.md:IMPORT-PAYLOAD'), inPicked.join(' '));
205const 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});
211check('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
215await chip(0);
216await p.waitForTimeout(900);
217await p.evaluate(() => { window.showDirectoryPicker = async () => window.__dirs.sanddest; });
218await transfer('save a copy');
219await p.waitForTimeout(3000);
220const sandSaved = await listDir('.sanddest');
221check('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
224await p.evaluate(() => { window.showDirectoryPicker = async () => window.__dirs['sand-src']; });
225await transfer('import a folder|copy in a folder');
226await p.waitForSelector('.dlg-ok', { timeout: 15000 });
227await p.click('.dlg-ok');
228await p.waitForTimeout(2500);
229check('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')));
232check('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.
236const 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));
239check('nothing threw', errs.length === 0, errs.slice(0, 2).join(' | '));
240
241await s.close();
242console.log(`\n${ok.length} passed, ${bad.length} failed`);
243if (bad.length) { bad.forEach(b => console.log(' FAILED: ' + b)); process.exit(1); }