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 | |
| 55 | import fs from 'node:fs'; |
| 56 | import path from 'node:path'; |
| 57 | import { fileURLToPath } from 'node:url'; |
| 58 | import { open, signInAs, shot, errors } from './harness.mjs'; |
| 59 | |
| 60 | const HERE = path.dirname(fileURLToPath(import.meta.url)); |
| 61 | const WWW = path.join(HERE, '..', 'www'); |
| 62 | |
| 63 | const ok = [], bad = []; |
| 64 | const check = (name, pass, detail) => { |
| 65 | (pass ? ok : bad).push(name + (detail ? ' — ' + detail : '')); |
| 66 | console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : '')); |
| 67 | }; |
| 68 | const sleep = ms => new Promise(r => setTimeout(r, ms)); |
| 69 | |
| 70 | // ── The breaks ─────────────────────────────────────────────────────────────── |
| 71 | const BREAK = (() => { const i = process.argv.indexOf('--break'); return i > 0 ? process.argv[i + 1] : ''; })(); |
| 72 | const 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 | |
| 89 | function 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 | |
| 102 | async 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 | |
| 111 | const s = await open({ name: 'machine', route: serveBreaks, connect: false }); |
| 112 | const 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. |
| 120 | const 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. |
| 125 | const 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. |
| 134 | const sandboxBefore = await listRoot(); |
| 135 | check('0a the engine answers a listing at all', Array.isArray(sandboxBefore), |
| 136 | JSON.stringify(sandboxBefore)); |
| 137 | |
| 138 | const 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 | |
| 153 | check('0b a real FileSystemDirectoryHandle stands in for the dialog', |
| 154 | setup.madeHandle === true); |
| 155 | check('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 ────────────────────────────────── |
| 160 | const 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 | }); |
| 169 | check('1a the Machine chip is on screen and was pressed', switched.clicked === true, |
| 170 | switched.clicked ? '' : 'chips: ' + JSON.stringify(switched.chips || [])); |
| 171 | await sleep(1500); |
| 172 | |
| 173 | const onMachine = await p.evaluate(() => ({ |
| 174 | folder: !!(window.DaimondFiles && window.DaimondFiles.folder()), |
| 175 | same: !!(window.DaimondFiles && window.DaimondFiles.folder() === window.__machineHandle), |
| 176 | })); |
| 177 | check('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 ─────────────────────────────── |
| 181 | const namesM = await listRoot(); |
| 182 | check('2a the ENGINE lists the folder\'s own file', !!namesM && namesM.includes('nonce.txt'), |
| 183 | namesM ? JSON.stringify(namesM.slice(0, 8)) : 'no listing'); |
| 184 | check('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)}`); |
| 187 | await shot(s, 'machine-on'); |
| 188 | |
| 189 | // ── 3. Back to the sandbox, and the fence holds the other way ──────────────── |
| 190 | const 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 | }); |
| 197 | check('3a the Browser chip is on screen and was pressed', back === true); |
| 198 | await sleep(1500); |
| 199 | |
| 200 | const 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. |
| 205 | check('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 ──────────── |
| 211 | const 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 | }); |
| 228 | check('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. |
| 233 | const errs = errors(s).filter(e => !/502|401|Account service|favicon/.test(e)); |
| 234 | check('5 the switch raised nothing in the console', errs.length === 0, |
| 235 | errs.slice(0, 2).join(' | ')); |
| 236 | |
| 237 | await s.close(); |
| 238 | |
| 239 | console.log(`\n${ok.length} passed, ${bad.length} failed`); |
| 240 | if (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 | } |
| 245 | process.exit(bad.length ? 1 : 0); |