Oregami
Repositories/oxedyne/daimond

oxedyne/daimond/dev/verify_machine.mjs

11.8 KiB, 1 run

created by r2519314175:517, 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_machine.mjs — Machine mode: the file tools really move to the folder.
2//
3// ── WHY, AND WHAT NOBODY HAD WATCHED HAPPEN ──────────────────────────────────
4//
5// Daimond has two roots and exactly one is active: the browser sandbox (OPFS),
6// and MACHINE — a real folder, held as a `FileSystemDirectoryHandle`. Every
7// development surface rests on the second: point Daimond at its own source, and
8// the file tools read and write there rather than in a sandbox nobody's compiler
9// can see.
10//
11// Nothing exercised it. `openFolder` (`www/js/daimond.js:19443`) begins with
12// `window.showDirectoryPicker()`, a NATIVE dialog no automated browser can
13// answer — `www/js/hand.js:837` says so, and `dev/verify_handreal.mjs` step 5
14// stands the same step in for the same reason. So the whole of what happens
15// AFTER the handle arrives went unchecked: `set_workspace_dir`, the chip that
16// claims which root is live, the rules re-read from the new root, and — the one
17// that matters — whether the wasm file tools actually moved.
18//
19// ── WHAT IS PROVED HERE, AND WHAT IS NOT, EXACTLY ────────────────────────────
20//
21// PROVED: every line of Daimond's own path, from a handle to a file tool
22// reading through it. The handle is a REAL `FileSystemDirectoryHandle` — the
23// same interface, the same class, the same permission model — obtained from
24// `navigator.storage.getDirectory()` rather than from the dialog, and the check
25// asserts through the ENGINE's own `file_list` and `file_read`, never through
26// the screen.
27//
28// NOT PROVED: that Chrome raises its dialog and hands back a handle. That is
29// Chrome's code and not Daimond's, and it is the one step no harness can drive
30// without a window manager to type into. It is named here rather than left to
31// be discovered, and it is the whole of the gap.
32//
33// The distinction is the point of the file. A check that swapped in a plain
34// object with a `getFileHandle` method would prove that the code calls methods;
35// swapping in a real handle proves it works with the type the browser gives it.
36//
37// ── THE PROPERTY, WHICH IS NOT "IT SWITCHED" ─────────────────────────────────
38//
39// The root is EXCLUSIVE: the agent has exactly one. So the check is not that
40// the machine folder became visible, but that the sandbox stopped being — a
41// nonce written into the folder must be readable through the tools on Machine
42// and unreadable on Browser, and a nonce written into the sandbox the other way
43// round. A switch that merely ADDED the folder would pass the first half and
44// fail the second, and would be a fence with a hole in it.
45//
46// node dev/verify_machine.mjs
47// node dev/verify_machine.mjs --break noswitch # the tools never move
48// node dev/verify_machine.mjs --break nopersist # the handle is not kept
49//
50// A `--break` run EXPECTS to fail: exit 0 when something reddened, 1 when
51// nothing did.
52//
53// Needs dev/serve.mjs (DAIMOND_PORT, default 8777). No gateway, no model.
54
55import fs from 'node:fs';
56import path from 'node:path';
57import { fileURLToPath } from 'node:url';
58import { open, signInAs, shot, errors } from './harness.mjs';
59
60const HERE = path.dirname(fileURLToPath(import.meta.url));
61const WWW = path.join(HERE, '..', 'www');
62
63const ok = [], bad = [];
64const check = (name, pass, detail) => {
65 (pass ? ok : bad).push(name + (detail ? ' — ' + detail : ''));
66 console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : ''));
67};
68const sleep = ms => new Promise(r => setTimeout(r, ms));
69
70// ── The breaks ───────────────────────────────────────────────────────────────
71const BREAK = (() => { const i = process.argv.indexOf('--break'); return i > 0 ? process.argv[i + 1] : ''; })();
72const BREAKS = {
73 // The chip goes active and the handle is remembered; the ENGINE is never
74 // repointed. This is the failure the screen cannot show you.
75 noswitch: [{
76 file: 'js/daimond.js',
77 find: '\t\t\t\tset_workspace_dir(handle);',
78 with: '\t\t\t\tvoid handle; // --break noswitch: the engine is never repointed',
79 }],
80 // The switch works and nothing is kept, so the next boot is back in the
81 // sandbox with no offer to reconnect.
82 nopersist: [{
83 file: 'js/daimond.js',
84 find: '\t\t\tif (persist) { try { await FsaDB.save(handle); } catch (e) { /* non-fatal */ } }',
85 with: '\t\t\tif (false) { try { await FsaDB.save(handle); } catch (e) {} }',
86 }],
87};
88
89function damagedFiles() {
90 const byFile = new Map();
91 for (const spec of (BREAKS[BREAK] || [])) {
92 const src = fs.readFileSync(path.join(WWW, spec.file), 'utf8');
93 if (!src.includes(spec.find)) {
94 console.error(`--break ${BREAK}: anchor not found in ${spec.file}. The break is stale.`);
95 process.exit(1);
96 }
97 byFile.set(spec.file, src.replace(spec.find, spec.with));
98 }
99 return byFile;
100}
101
102async function serveBreaks(page) {
103 if (!BREAK) return;
104 for (const [file, body] of damagedFiles()) {
105 await page.route('**/' + file, r => r.fulfill({
106 status: 200, contentType: 'application/javascript', body,
107 }));
108 }
109}
110
111const s = await open({ name: 'machine', route: serveBreaks, connect: false });
112const p = s.page;
113
114// ── 0. Two nonces, in two roots, written before anything switches ────────────
115//
116// The folder's nonce goes in through the HANDLE, so it is there whatever the
117// app later believes; the sandbox's goes in through the tools, which is the
118// only writer the sandbox has. Both are read back through the tools, so a tool
119// that never moved is caught by content and not by a flag it sets itself.
120const NONCE_M = 'machine-' + Math.random().toString(36).slice(2, 10);
121
122// The engine's own door. `DaimondFiles.entries` runs a real `file_list` through
123// the wasm tools, so what it answers is where the ENGINE is — not what the chip
124// claims, which is the distinction `--break noswitch` exists to expose.
125const listRoot = () => p.evaluate(async () => {
126 if (!window.DaimondFiles) return null;
127 const es = await window.DaimondFiles.entries('');
128 return es.map(e => e.name).sort();
129});
130
131// The sandbox as it stands, before anything moves. Taken by construction rather
132// than asserted against a fixture: what matters is that it comes BACK, and a
133// list this run did not write cannot be one this run invented.
134const sandboxBefore = await listRoot();
135check('0a the engine answers a listing at all', Array.isArray(sandboxBefore),
136 JSON.stringify(sandboxBefore));
137
138const setup = await p.evaluate(async (nm) => {
139 // A REAL FileSystemDirectoryHandle — same interface, same class, same
140 // permission model as the dialog returns — and a SIBLING of the app's root
141 // rather than the root itself. A stand-in that WAS the sandbox would make
142 // "the tools moved" and "the tools did not" indistinguishable.
143 const opfs = await navigator.storage.getDirectory();
144 const dir = await opfs.getDirectoryHandle('machine-test-root', { create: true });
145 const fh = await dir.getFileHandle('nonce.txt', { create: true });
146 const w = await fh.createWritable();
147 await w.write(nm);
148 await w.close();
149 window.__machineHandle = dir;
150 return { madeHandle: !!dir && typeof dir.getFileHandle === 'function' };
151}, NONCE_M);
152
153check('0b a real FileSystemDirectoryHandle stands in for the dialog',
154 setup.madeHandle === true);
155check('0c and the sandbox does not already hold the folder\'s file',
156 !!sandboxBefore && !sandboxBefore.includes('nonce.txt'),
157 JSON.stringify(sandboxBefore));
158
159// ── 1. Switch, with the dialog stood in for ──────────────────────────────────
160const switched = await p.evaluate(async () => {
161 window.showDirectoryPicker = async () => window.__machineHandle;
162 // The chip the user presses, found by its label rather than by position.
163 const chips = [...document.querySelectorAll('.files-mode-chip')];
164 const machine = chips.find(c => /machine/i.test(c.textContent || ''));
165 if (!machine) return { clicked: false, chips: chips.map(c => (c.textContent || '').trim()) };
166 machine.click();
167 return { clicked: true };
168});
169check('1a the Machine chip is on screen and was pressed', switched.clicked === true,
170 switched.clicked ? '' : 'chips: ' + JSON.stringify(switched.chips || []));
171await sleep(1500);
172
173const onMachine = await p.evaluate(() => ({
174 folder: !!(window.DaimondFiles && window.DaimondFiles.folder()),
175 same: !!(window.DaimondFiles && window.DaimondFiles.folder() === window.__machineHandle),
176}));
177check('1b the app holds the folder it was handed', onMachine.same === true,
178 `folder ${onMachine.folder ? 'set' : 'null'}`);
179
180// ── 2. THE FILE TOOLS MOVED, proved by content ───────────────────────────────
181const namesM = await listRoot();
182check('2a the ENGINE lists the folder\'s own file', !!namesM && namesM.includes('nonce.txt'),
183 namesM ? JSON.stringify(namesM.slice(0, 8)) : 'no listing');
184check('2b and it is a DIFFERENT root, not the sandbox with a folder added',
185 !!namesM && JSON.stringify(namesM) !== JSON.stringify(sandboxBefore),
186 `machine ${JSON.stringify(namesM)} vs sandbox ${JSON.stringify(sandboxBefore)}`);
187await shot(s, 'machine-on');
188
189// ── 3. Back to the sandbox, and the fence holds the other way ────────────────
190const back = await p.evaluate(async () => {
191 const chips = [...document.querySelectorAll('.files-mode-chip')];
192 const browser = chips.find(c => /browser/i.test(c.textContent || ''));
193 if (!browser) return false;
194 browser.click();
195 return true;
196});
197check('3a the Browser chip is on screen and was pressed', back === true);
198await sleep(1500);
199
200const namesB = await listRoot();
201// Everything the sandbox held is back, and the folder's file is not. NOT an
202// equality: this run's own stand-in folder was created under the OPFS root, so
203// it legitimately appears in the listing afterwards. Asserting equality would
204// have made the FIXTURE the thing that failed, which is a check testing itself.
205check('3b the ENGINE is back in the sandbox, and the folder is gone from it',
206 !!namesB && !namesB.includes('nonce.txt')
207 && sandboxBefore.every(n => namesB.includes(n)),
208 `back ${JSON.stringify(namesB)} vs before ${JSON.stringify(sandboxBefore)}`);
209
210// ── 4. The handle was kept, so a reconnect has something to offer ────────────
211const kept = await p.evaluate(async () => {
212 try {
213 const db = await new Promise((res, rej) => {
214 const r = indexedDB.open('daimond-fsa');
215 r.onsuccess = () => res(r.result); r.onerror = () => rej(r.error);
216 r.onupgradeneeded = () => { /* absent is an answer */ };
217 });
218 const names = [...db.objectStoreNames];
219 if (!names.length) return { stores: names, count: 0 };
220 const tx = db.transaction(names[0], 'readonly');
221 const n = await new Promise((res) => {
222 const rq = tx.objectStore(names[0]).count();
223 rq.onsuccess = () => res(rq.result); rq.onerror = () => res(-1);
224 });
225 return { stores: names, count: n };
226 } catch (e) { return { stores: [], count: -1, err: String(e) }; }
227});
228check('4 the folder was remembered, so a reconnect can be offered',
229 kept.count > 0, JSON.stringify(kept));
230
231// The 401s are the gateway's absence, not the switch's doing: this session is
232// deliberately unconnected, so `/api/tools` has nobody to ask.
233const errs = errors(s).filter(e => !/502|401|Account service|favicon/.test(e));
234check('5 the switch raised nothing in the console', errs.length === 0,
235 errs.slice(0, 2).join(' | '));
236
237await s.close();
238
239console.log(`\n${ok.length} passed, ${bad.length} failed`);
240if (BREAK) {
241 console.log(bad.length ? `--break ${BREAK}: reddened ${bad.length} check(s), as it must`
242 : `--break ${BREAK}: CHANGED NOTHING — the check it names is not testing what it says`);
243 process.exit(bad.length ? 0 : 1);
244}
245process.exit(bad.length ? 1 : 0);