Oregami
Repositories/oxedyne/daimond

oxedyne/daimond/dev/verify_refusedpath.mjs

28.0 KiB, 1 run

created by r2519314175:635, 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_refusedpath.mjs — a refused tool call is not a call that did nothing wrong.
2//
3// THE DEFECT, ONE DOOR ALONG FROM `dev/CONTRACT_OUTCOME.md`. That contract put the
4// outcome on `AgentEvent::ToolResult`, so the five consumers of a MODEL's tool call
5// stopped guessing. `DaimondApp::run_tool` — the app's own door to the same
6// registry, taken by the sync census, the Files panels, the skill seeder and mail —
7// still returned a bare String. Nine callers read that string to decide what had
8// happened, every one of them testing for `Error:` alone, while `refusal_line`
9// (src/tools.rs) opens every refusal with `Refused:`.
10//
11// The one that matters is `collectFiles` (www/js/daimond.js). A `file_list` the
12// scope fence refused sailed past the `Error` test into `parseSyncListing`, which
13// reads a sentence ending "is empty." as NO ENTRIES — so a fenced directory read as
14// an empty one, and the census still reported `filesComplete`. Completeness is the
15// single thing that entitles the other device to read an absent path as a deletion
16// (see `applyFiles`). A Diamond could therefore sync a file set with a whole folder
17// silently missing from it, and nothing anywhere said so.
18//
19// `run_tool_outcome` answers `{ text, outcome }` from `call_outcome` — the same
20// classifier, the same three words, `dev/CONTRACT_OUTCOME.md` §1 — and the callers
21// ask it.
22//
23// THREE PROPERTIES:
24//
25// 0. THE ENGINE ITSELF STATES THE OUTCOME. Not simulated: three real calls
26// through the real registry — a write that lands, a write the fence refuses,
27// a read of a file that is not there — must come back done / refused / failed.
28// 1. A REFUSED LISTING IS NOT AN EMPTY DIRECTORY. The census must report
29// `filesComplete: false`, so the far side deletes nothing by absence.
30// 2. A REFUSED WRITE IS NOT AN AGREEMENT. `applyFiles` records a pulled file in
31// the fork point as held by both devices; a write the fence stopped must not
32// be entered there, or the next complete census from the other side will read
33// its absence here as a deletion made here.
34// 3. A REFUSED CREATE SAYS SO. `newFile` threw the answer away and went straight
35// on to open the new file, so a create the fence stopped was a button that did
36// nothing and said nothing. (The editor half of that is defended twice over --
37// see the note at the check -- so the message is what the break moves.)
38// 4. A REFUSED SAVE DOES NOT SAY "SAVED", which is the worst of the four. The
39// Doc panel's save resolved with the refusal text and took the success
40// branch: the message read "Saved.", the editor was torn down, and the edit
41// existed nowhere but in the textarea that had just been removed. So the
42// check is not only the message: the EDITOR MUST STILL BE STANDING, holding
43// what was typed.
44//
45// WHY THE FIXTURE LIES ON PURPOSE, as `dev/verify_outcome.mjs` does. Its refusal
46// carries the TEXT of an empty directory — `'notes' is empty.` — and its refused
47// write carries no error wording at all. Every check would pass on truthful text
48// with the old sniffing still in place; a consumer still reading the words is
49// caught here and nowhere else. The stamp is not derived from anything the app can
50// see: it is the test's arranged truth, which is what the engine's field is.
51//
52// EACH CHECK PROVED AGAINST BROKEN CODE FIRST:
53//
54// node dev/verify_refusedpath.mjs --break sniff # 1 fails: the text wins again
55// node dev/verify_refusedpath.mjs --break wrote # 2 fails: a refused write returns true
56// node dev/verify_refusedpath.mjs --break agreed # 2 fails: agreement is recorded unasked
57// node dev/verify_refusedpath.mjs --break created # 3 fails: the create says nothing at all
58// node dev/verify_refusedpath.mjs --break saved # 4 fails: "Saved.", and the editor goes
59// node dev/verify_refusedpath.mjs --break bare # 0 and the control fail: the door
60// # hands back text again, as it used to
61// node dev/verify_refusedpath.mjs # and then, clean
62//
63// eval "$(bash dev/world.sh 6 --up)"
64// node dev/verify_refusedpath.mjs
65//
66// Needs dev/serve.mjs. No mock and no gateway: `collectSync` and `applySync` are
67// driven directly, which is what lets a census be measured without a push.
68import fs from 'node:fs';
69import path from 'node:path';
70import { fileURLToPath } from 'node:url';
71import { open, scratch } from './harness.mjs';
72
73const HERE = path.dirname(fileURLToPath(import.meta.url));
74const WWW = path.join(HERE, '..', 'www');
75
76const BREAK = (() => {
77 const i = process.argv.indexOf('--break');
78 return i > 0 ? String(process.argv[i + 1] || '') : '';
79})();
80
81// Each break is [find, replace, howManyTimes] over www/js/daimond.js, asserted
82// before the browser opens so an anchor that has moved stops the run rather than
83// patching nothing and reporting green. `bare` is not here: it damages the wasm
84// GLUE, not the app, and is applied to the shim below.
85const BREAKS = {
86 // The rule exactly as it was: read the sentence for `Error`. The fixture's
87 // refusal does not carry that word, so the census walks on and calls itself
88 // complete — which is the whole defect, reproduced.
89 sniff: [[
90 ` if (!res || res.outcome !== 'done') {\n console.warn('sync: '`,
91 ` if (typeof res.text !== 'string' || /^\\s*Error\\b/i.test(res.text)) {\n console.warn('sync: '`, 1]],
92 // The best-effort writer says "written" for anything that did not throw.
93 wrote: [[
94 ` var r = await app.run_tool_outcome('file_write',\n JSON.stringify({ path: path, content: content }));\n return !!r && r.outcome === 'done';`,
95 ` await app.run_tool_outcome('file_write',\n JSON.stringify({ path: path, content: content }));\n return true;`, 1]],
96 // The caller stops asking, so the answer above no longer matters.
97 agreed: [[
98 `if (l == null) { if (await writeSyncFile(app, p, r)) agreed[p] = fileHash(r); continue; }`,
99 `if (l == null) { await writeSyncFile(app, p, r); agreed[p] = fileHash(r); continue; }`, 1]],
100 // The Doc panel's save stops asking, so a refusal takes the success branch: the
101 // message says "Saved.", the textarea is replaced by a <pre>, and what the person
102 // typed is gone with it.
103 saved: [[
104 ` writeOpenFile(path, content).then(function (wr) {\n if (!wr || wr.outcome !== 'done') {`,
105 ` writeOpenFile(path, content).then(function (wr) {\n if (false) {`, 1]],
106 // And `newFile` stops asking, so it opens an editor over a file that was refused.
107 created: [[
108 ` // The result used to be thrown away, so a refused create opened an editor\n // on a file that was never made.\n if (!w || w.outcome !== 'done') {`,
109 ` // The result used to be thrown away, so a refused create opened an editor\n // on a file that was never made.\n if (false) {`, 1]],
110 // The empty-listing reader anchored at the END of the text, as it was before
111 // `two_places_note` put a second line under the answer. 1c goes red: the note becomes
112 // a file, in a census that still calls itself complete.
113 // TWO occurrences, and that is the honest count: the census reader and the Work panel's
114 // reader are the same line at two indents, so a substring anchor finds both -- and both
115 // were anchored at the end of the text before this change, so damaging both IS the world
116 // before it.
117 endanchor: [[
118 ` if (/ is empty\\.$/.test(String(text).split('\\n')[0].trim())) return out;`,
119 ` if (/ is empty\\.$/.test(String(text).trim())) return out;`, 2]],
120 bare: [],
121};
122if (BREAK && !BREAKS[BREAK]) {
123 console.error(`unknown break '${BREAK}'; one of: ${Object.keys(BREAKS).join(', ')}`);
124 process.exit(2);
125}
126
127const APP_SRC = fs.readFileSync(path.join(WWW, 'js/daimond.js'), 'utf8');
128const GLUE_SRC = fs.readFileSync(path.join(WWW, 'pkg/oxedyne_daimond.js'), 'utf8');
129
130let damaged = APP_SRC;
131for (const [find, repl, want] of (BREAKS[BREAK] || [])) {
132 const got = damaged.split(find).length - 1;
133 if (got !== want) {
134 console.error(`--break ${BREAK}: expected ${want} occurrence(s) of\n ${find}\nbut found ${got}; `
135 + 'the anchor has moved and this break would patch nothing');
136 process.exit(2);
137 }
138 damaged = damaged.split(find).join(repl);
139}
140
141// The fence, stood in for. A real scope refusal needs a real fence and a real
142// machine hand; what is being tested is what the app DOES with a refusal, so the
143// refusal is arranged. `__stamp` names a tool and a fragment of its arguments; a
144// match answers with the stamped pair and the call is NOT dispatched, so a refused
145// write really does leave nothing behind. Everything else goes through untouched
146// and its true outcome is recorded, which is what check 0 reads.
147const SHIM = `
148/* ── dev/verify_refusedpath.mjs: stands in for a door that refuses ── */
149const __rp_real = DaimondApp.prototype.run_tool_outcome;
150DaimondApp.prototype.run_tool_outcome = async function (name, argsJson) {
151 try {
152 for (const s of (globalThis.__stamp || [])) {
153 if (s.name === name && String(argsJson).indexOf(s.match) >= 0) {
154 (globalThis.__stamped = globalThis.__stamped || []).push(name + ' ' + s.match);
155 return { text: s.text, outcome: s.outcome };
156 }
157 }
158 } catch (e) { /* the shim may never break the run it observes */ }
159 const real = await __rp_real.call(this, name, argsJson);
160 try {
161 (globalThis.__seen = globalThis.__seen || []).push(
162 name + ':' + (real && real.outcome === undefined ? 'none' : (real && real.outcome)));
163 } catch (e) { /* nor may the recorder */ }
164 return real;
165};
166`;
167// The door as it was before this landed: the text alone, with the outcome thrown
168// away. Everything downstream is then reading prose again, whether it means to or
169// not, and the checks below must notice.
170const BARE = `
171DaimondApp.prototype.run_tool_outcome = async function (name, argsJson) {
172 return await this.run_tool(name, argsJson);
173};
174`;
175
176// The document the third fixture edits: what is on disk, and what gets typed over it.
177const DOC_WAS = 'the words that are already saved\n';
178const DOC_TYPED = 'the words that exist only on screen, and must survive being refused\n';
179
180let bad = 0;
181const check = (pass, name, detail) => {
182 if (!pass) bad++;
183 console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : ''));
184};
185
186const PROFILE = scratch('pw', 'refusedpath' + (BREAK ? '-' + BREAK : ''));
187fs.rmSync(PROFILE, { recursive: true, force: true });
188
189const s = await open({
190 name: 'refusedpath',
191 profile: PROFILE,
192 connect: false,
193 route: async (page) => {
194 await page.route('**/pkg/oxedyne_daimond.js', (r) => r.fulfill({
195 status: 200, contentType: 'application/javascript',
196 body: GLUE_SRC + SHIM + (BREAK === 'bare' ? BARE : ''),
197 }));
198 if (BREAK && BREAK !== 'bare') await page.route('**/js/daimond.js', (r) => r.fulfill({
199 status: 200, contentType: 'application/javascript', body: damaged,
200 }));
201 },
202});
203const { page: p } = s;
204if (BREAK) console.log(`\n*** RUNNING UNDER --break ${BREAK}: failures below are the point ***\n`);
205
206/// A file into the OPFS workspace, through the text-only door — which is the honest
207/// use of it: this is setup, and nothing here reads the answer.
208const write = (path, content) => p.evaluate(async (a) => {
209 const mod = await import('../pkg/oxedyne_daimond.js');
210 const app = new mod.DaimondApp('http://127.0.0.1/v1/chat/completions', '', 'none', 256, '', true);
211 return String(await app.run_tool('file_write', JSON.stringify({ path: a.path, content: a.content })));
212}, { path, content });
213
214const exists = (path) => p.evaluate(async (f) => {
215 try {
216 const dir = await DaimondCloud.opfsRoot();
217 await dir.getFileHandle(f);
218 return true;
219 } catch (e) { return false; }
220}, path);
221
222/// Arm the stand-in, and clear what it recorded last time.
223const stamp = (plan) => p.evaluate((pl) => {
224 globalThis.__stamp = pl;
225 globalThis.__stamped = [];
226 globalThis.__seen = [];
227 return true;
228}, plan);
229
230/// The census, as the parcel would carry it.
231const census = () => p.evaluate(async () => {
232 const c = await DaimondCore.collectSync();
233 return { complete: c.filesComplete === true, paths: Object.keys(c.files || {}).sort() };
234});
235
236const baseline = () => p.evaluate(() => {
237 try { return JSON.parse(localStorage.getItem('daimond-sync-filebase') || '{}'); }
238 catch (e) { return {}; }
239});
240
241const stamped = () => p.evaluate(() => (globalThis.__stamped || []).slice());
242
243/// Every call the door was asked that the stand-in did NOT answer, with the outcome
244/// the engine gave it. Only ever a detail line: a check that read this would be
245/// asking the recorder rather than the app.
246const seen = () => p.evaluate(() => (globalThis.__seen || []).slice(-8));
247
248try {
249 await p.waitForFunction(() => !!(window.DaimondCore && DaimondCore.collectSync && window.DaimondTools),
250 null, { timeout: 20000 });
251 await p.waitForTimeout(900);
252
253 // ── 0. The engine's own word, unsimulated ────────────────────
254 //
255 // The stand-in is not armed here, so these three go to the real registry and
256 // come back with whatever it says. If this fails, nothing below means anything:
257 // every other check is about what the app does with an outcome, and this is the
258 // only one about where the outcome comes from.
259 await stamp([]);
260 const eng = 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 call = async (n, a) => {
264 const r = await app.run_tool_outcome(n, JSON.stringify(a));
265 return { outcome: r && r.outcome, head: String((r && r.text) || '').slice(0, 44) };
266 };
267 return {
268 done: await call('file_write', { path: 'rp/landed.txt', content: 'landed' }),
269 refused: await call('file_write', { path: '/etc/daimond-must-not-write.txt', content: 'nope' }),
270 failed: await call('file_read', { path: 'rp/not-here.txt' }),
271 };
272 });
273 check(eng.done.outcome === 'done' && eng.refused.outcome === 'refused' && eng.failed.outcome === 'failed',
274 'THE ENGINE STATES THE OUTCOME — done, refused and failed come back as themselves',
275 `${eng.done.outcome} / ${eng.refused.outcome} / ${eng.failed.outcome}`);
276 check(/^Refused\b/.test(eng.refused.head) && /^Error\b/.test(eng.failed.head)
277 && !/^(Refused|Error)\b/.test(eng.done.head),
278 'and the text still says what it always said, so the outcome is a second fact and not a rewrite',
279 JSON.stringify([eng.done.head, eng.refused.head, eng.failed.head]));
280
281 // ── The workspace this census is of ──────────────────────────
282 await write('rp-top.md', 'at the root, and readable\n');
283 await write('notes/keep.md', 'inside the folder that will be refused\n');
284 await p.waitForTimeout(300);
285
286 // ── 1a. The control ──────────────────────────────────────────
287 //
288 // Nothing stamped: the whole workspace lists, so the census is complete and
289 // carries both files. Without this, check 1b passes for a build that reports
290 // every census incomplete, which would be useless in a different way.
291 const clean = await census();
292 check(clean.complete === true && clean.paths.indexOf('notes/keep.md') >= 0
293 && clean.paths.indexOf('rp-top.md') >= 0,
294 'A WALK THAT SAW EVERYTHING REPORTS COMPLETE, and carries what it saw',
295 `complete=${clean.complete}, ${JSON.stringify(clean.paths.slice(0, 6))}`);
296
297 // ── 1b. The load-bearing one ─────────────────────────────────
298 //
299 // `notes` is refused, in the words of an empty directory. The old rule tested
300 // those words for `Error`, found none, and let `parseSyncListing` read them as
301 // no entries at all — an empty folder, indistinguishable from a folder the user
302 // really had emptied. Only the outcome tells the two apart.
303 await stamp([{
304 name: 'file_list', match: '"path":"notes"',
305 outcome: 'refused', text: "'notes' is empty.",
306 }]);
307 const fenced = await census();
308 const hit = await stamped();
309 check(hit.length > 0, 'THE FIXTURE RAN: the census asked to list the refused folder',
310 JSON.stringify(hit.slice(0, 3)));
311 check(fenced.complete === false,
312 'A REFUSED LISTING IS NOT AN EMPTY DIRECTORY — the census reports itself INCOMPLETE, '
313 + 'so the other device deletes nothing by absence',
314 `filesComplete=${fenced.complete}, paths ${JSON.stringify(fenced.paths.slice(0, 6))}`);
315 check(fenced.paths.indexOf('notes/keep.md') === -1 && fenced.paths.indexOf('rp-top.md') >= 0,
316 'and it still carries what it COULD read: the refused folder is missing, the rest is not',
317 JSON.stringify(fenced.paths.slice(0, 6)));
318
319 // ── 1c. An empty directory that also SAYS something ──────────
320 //
321 // `file_list` puts a second line under the empty answer when the file tools and a
322 // command are looking at two different filesystems (`two_places_note`, src/tools.rs,
323 // 2026-08-24). The reader here tested the END of the text, so the answer stopped being
324 // recognised — and an unrecognised listing is not read as "unknown", it is read as ONE
325 // FILE whose name is that sentence. In a census that calls itself COMPLETE, a phantom
326 // file is what the other device syncs, and the fix for §1b would have been undone by
327 // the fix for a different fault entirely.
328 await stamp([{
329 name: 'file_list', match: '"path":"notes"',
330 outcome: 'done',
331 text: "'notes' is empty.\n'notes' is empty in this browser's own storage, which is the "
332 + 'only filesystem the file tools reach while no folder is open. The granted folder '
333 + 'on this computer is a second one, which the run tool reaches and a file tool '
334 + 'cannot.',
335 }]);
336 const noted = await census();
337 const hitN = await stamped();
338 check(hitN.length > 0, 'THE FIXTURE RAN: the census asked to list the folder that answers with a note',
339 JSON.stringify(hitN.slice(0, 3)));
340 // THE HEADLINE IS COMPLETENESS, and the ordering is what the break taught: with the reader
341 // anchored at the end of the text the note became an entry, the census then tried to READ
342 // that entry, the read failed, and the census went INCOMPLETE. So the phantom is caught one
343 // step upstream of where it would show, and a check that only looked at the file list would
344 // have stayed green while sync stopped working for every empty folder in the workspace.
345 check(noted.complete === true,
346 'AN EMPTY DIRECTORY THAT EXPLAINS ITSELF IS STILL A COMPLETE ANSWER — a note under the '
347 + 'empty line does not cost the census its completeness, which is the one word that '
348 + 'entitles the other device to act on absence',
349 `filesComplete=${noted.complete}`);
350 // The corollary, which the break above does NOT red on its own. Kept because it is the
351 // property a future reader will look for, and said to be a corollary rather than left to
352 // read as the evidence.
353 check(!noted.paths.some((x) => /browser|filesystem|is empty/.test(x)),
354 'and no sentence was carried as a file name',
355 JSON.stringify(noted.paths.slice(0, 6)));
356 check(!noted.paths.some((x) => x.indexOf('notes/') === 0),
357 'and nothing under it was invented either',
358 JSON.stringify(noted.paths.filter((x) => x.indexOf('notes/') === 0).slice(0, 4)));
359
360 // ── 2. A refused write is not an agreement ───────────────────
361 //
362 // The other device sends a file this one has never seen. `applyFiles` adopts it
363 // and records the path in the fork point as held by BOTH devices. The write is
364 // refused here, so the file does not exist — and a fork point that says it does
365 // is one the next complete census will read as a deletion made on this side.
366 await stamp([{
367 name: 'file_write', match: '"path":"adopted.md"',
368 outcome: 'refused', text: 'Refused: adopted.md is outside what this Diamond may write.',
369 }]);
370 await p.evaluate(async () => {
371 await DaimondCore.applySync({
372 v: 2, chats: [], tombs: {}, msgTombs: {},
373 files: { 'adopted.md': 'sent by the other device\n' },
374 filesComplete: true, diamonds: [], diamondTombs: {}, chunked: {},
375 });
376 });
377 await p.waitForTimeout(400);
378 const hit2 = await stamped();
379 const base = await baseline();
380 check(hit2.length > 0, 'THE SECOND FIXTURE RAN: the pull tried to write the adopted file',
381 JSON.stringify(hit2.slice(0, 3)));
382 check(!(await exists('adopted.md')),
383 'the refused write really left nothing behind, which is what makes the next check about anything');
384 check(!Object.prototype.hasOwnProperty.call(base, 'adopted.md'),
385 'A REFUSED WRITE IS NOT AN AGREEMENT — the fork point does not claim a file this device '
386 + 'was stopped from writing',
387 `baseline ${JSON.stringify(Object.keys(base).slice(0, 6))}`);
388
389 // ── 3. A refused create does not open an editor ──────────────
390 //
391 // `newFile` threw the answer away and opened the file it had just been stopped
392 // from making. The editor then sat over nothing, and the first save met the same
393 // fence — by which time the person had typed into it.
394 await stamp([{
395 name: 'file_write', match: 'rp-never.md',
396 outcome: 'refused', text: 'Refused: rp-never.md is outside what this Diamond may write.',
397 }]);
398 await p.evaluate(() => {
399 const b = document.querySelector('#panel-work [data-act="new-file"]');
400 if (b) b.click();
401 });
402 await p.waitForSelector('.dlg-card', { timeout: 5000 });
403 await p.fill('.dlg-input', 'rp-never.md');
404 await p.evaluate(() => {
405 const b = document.querySelector('.dlg-ok');
406 if (b) b.click();
407 });
408 await p.waitForTimeout(1500);
409
410 const made = await p.evaluate(() => {
411 // The message lands in the open document's header when there is one, and in
412 // the mode row when there is not, so both are read.
413 const a = document.querySelector('#doc-view .files-view-msg');
414 const b = document.querySelector('#panel-work .files-mode-msg');
415 // Read whether or not the surface is on screen: which of the two `fileMsg`
416 // picks depends on a document being open, and this check is about what was
417 // SAID, not about which header it landed in.
418 const shown = (el) => (el ? (el.textContent || '') : '');
419 return {
420 msg: (shown(a) + ' ' + shown(b)).trim(),
421 err: !!((a && a.classList.contains('err')) || (b && b.classList.contains('err'))),
422 name: (document.getElementById('doc-name') || {}).textContent || '',
423 editing: !!document.querySelector('#doc-view .files-edit'),
424 };
425 });
426 check((await stamped()).length > 0, 'THE THIRD FIXTURE RAN: the create reached the fence',
427 JSON.stringify((await stamped()).slice(0, 2)));
428 check(made.err && /Could not create file/i.test(made.msg),
429 'A REFUSED CREATE SAYS SO', JSON.stringify(made.msg.slice(0, 70)));
430 // SAID OUT LOUD: the editor half is defended TWICE — `newFile` asks the outcome,
431 // and `openFile`'s own read of a file that is not there fails anyway — so
432 // `--break created` reddens the message above and NOT this one. No break in this
433 // file can falsify it, because a refused create leaves nothing to open. It is
434 // asserted regardless, because the day something seeds the file before writing it
435 // this becomes the only check between a person and an editor over nothing; it is
436 // labelled a corollary so nobody reads it as proven.
437 check(made.name.indexOf('rp-never.md') === -1 && !made.editing,
438 'and no editor is left open over it (corollary: there is nothing to open)',
439 `doc-name ${JSON.stringify(made.name)}, editing=${made.editing}`);
440 check(!(await exists('rp-never.md')),
441 'and the file really was never made, so the editor would have been over nothing');
442
443 // ── 4. A refused save does not say "Saved" ───────────────────
444 //
445 // LAST, on purpose: this one ends with an editor left standing over words that
446 // were never written, which is the state being asserted — so nothing may run
447 // after it that needs a clean panel.
448 //
449 // The person's own words are in the textarea and nowhere else. The save resolved
450 // with the refusal, the success branch ran, the message said "Saved." and the
451 // textarea was replaced by a <pre> built from the string that had NOT been
452 // written. So the message is only half the check: the editor must still be
453 // standing, holding what they typed.
454 await stamp([]);
455 await write('rp-doc.md', DOC_WAS);
456 await p.waitForTimeout(300);
457
458 // Open it the way a person does. The tree races the write that made the file, so
459 // the panel is reopened until the row is there rather than once with a hopeful
460 // pause — a verifier that flakes on its own fixture says nothing.
461 //
462 // Asking for the tree and reading it are two steps, not one: the panel relists
463 // asynchronously, so a query in the same evaluate as the refresh always reads a
464 // tree that is still being built and the hunt never finds anything.
465 let opened = false;
466 for (let i = 0; i < 10 && !opened; i++) {
467 await p.evaluate(() => {
468 try { DaimondPanels.hide('work'); DaimondPanels.show('work'); } catch (e) { /* no panels */ }
469 });
470 await p.waitForTimeout(700);
471 opened = await p.evaluate(() => {
472 // NOT `.sys-row`. The System section lists the store, whose root is this
473 // same OPFS root, so the fixture appears in BOTH trees — and a store row
474 // opens the file through the STORE door, which is not the one under test.
475 // Hunting `.files-row` alone found the store's copy first, and the save
476 // then went to `Wasm.store_write` and never reached the fence.
477 const row = [...document.querySelectorAll('#panel-work .files-row:not(.sys-row)')]
478 .find((r) => /rp-doc\.md/.test(r.textContent || ''));
479 if (!row) return false;
480 row.click();
481 return true;
482 });
483 }
484 await p.waitForTimeout(1200);
485 check(opened, 'THE FOURTH FIXTURE IS OPEN: the document is in the Doc panel',
486 JSON.stringify(await p.evaluate(() => ({
487 doc: (document.getElementById('doc-name') || {}).textContent || '',
488 rows: [...document.querySelectorAll('#panel-work .files-row')]
489 .map((r) => (r.className.indexOf('sys-row') >= 0 ? 'SYS ' : '') + (r.textContent || '').trim()),
490 crumb: (document.querySelector('#panel-work .files-path') || {}).textContent || '',
491 }))));
492
493 // Into the editor, and type something that must not be lost.
494 await p.evaluate(() => {
495 const b = document.querySelector('#doc-view [data-act="edit"]');
496 if (b) b.click();
497 });
498 await p.waitForTimeout(600);
499 await p.evaluate((typed) => {
500 const ta = document.querySelector('#doc-view .files-edit');
501 if (ta) {
502 ta.value = typed;
503 ta.dispatchEvent(new Event('input', { bubbles: true }));
504 }
505 }, DOC_TYPED);
506
507 // The fence closes between the typing and the save, which is exactly when it is
508 // worst: the only copy of these words is on screen.
509 await stamp([{
510 name: 'file_write', match: 'rp-doc.md',
511 outcome: 'refused', text: 'Refused: rp-doc.md is outside what this Diamond may write.',
512 }]);
513 await p.evaluate(() => {
514 const b = document.querySelector('#doc-view [data-act="edit"]'); // now "✔ Save"
515 if (b) b.click();
516 });
517 await p.waitForTimeout(1500);
518
519 const saved = await p.evaluate(() => {
520 const msg = document.querySelector('#doc-view .files-view-msg');
521 const ta = document.querySelector('#doc-view .files-edit');
522 return {
523 msg: msg && msg.style.display !== 'none' ? (msg.textContent || '') : '',
524 err: !!(msg && msg.classList.contains('err')),
525 editing: !!ta,
526 held: ta ? ta.value : '',
527 };
528 });
529 const onDisk = await p.evaluate(async () => {
530 const mod = await import('../pkg/oxedyne_daimond.js');
531 try { return await mod.read_file('rp-doc.md'); } catch (e) { return 'ERR'; }
532 });
533 check((await stamped()).length > 0, 'THE SAVE REACHED THE FENCE',
534 `stamped ${JSON.stringify((await stamped()).slice(0, 2))}, `
535 + `door asked ${JSON.stringify(await seen())}`);
536 check(saved.err && /Save failed/i.test(saved.msg) && !/^Saved\./i.test(saved.msg),
537 'A REFUSED SAVE DOES NOT SAY "SAVED" — it says the save failed, and says it as an error',
538 JSON.stringify(saved.msg.slice(0, 70)));
539 check(saved.editing && saved.held === DOC_TYPED,
540 'AND THE EDITOR IS STILL STANDING, holding what was typed — the words are not lost '
541 + 'to a message that said they were safe',
542 `editing=${saved.editing}, held ${JSON.stringify(saved.held.slice(0, 40))}`);
543 check(onDisk === DOC_WAS,
544 'and the file on disk is untouched, which is what makes the message a lie or not',
545 JSON.stringify(String(onDisk).slice(0, 40)));
546
547} finally {
548 await s.close();
549}
550
551console.log(bad ? `\n${bad} check(s) FAILED` : '\nall checks passed');
552process.exit(bad ? 1 : 0);