oxedyne/daimond/dev/verify_ownfiles.mjs
16.9 KiB, 1 run
created by r2519314175:559, 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_ownfiles.mjs — the Workspace panel conceals nothing, and is still tidy. |
| 2 | // |
| 3 | // ── WHY ────────────────────────────────────────────────────────────────────── |
| 4 | // |
| 5 | // The tree dropped every dotfile on the floor. The owner opened his own |
| 6 | // workspace, saw `mail/`, `prompts/` and `test.md`, and could not see -- let |
| 7 | // alone edit -- the `.daimond` directory holding his own rules and skills. Nor |
| 8 | // `.env`, nor `.gitignore`, nor anything else he had put there: a file the panel |
| 9 | // silently drops is a file he cannot see he has. |
| 10 | // |
| 11 | // The filter was there for clutter, and clutter is a design problem. So the |
| 12 | // answer is one shut row at the foot of the tree, carrying a count, with |
| 13 | // everything the panel used to withhold inside it -- and his own files exactly |
| 14 | // where they were, at the top. |
| 15 | // |
| 16 | // ── WHAT IT ASSERTS, WHICH IS THE PROPERTY AND NOT THE FIX ─────────────────── |
| 17 | // |
| 18 | // Not "the row exists". Three properties, and each can hold while another |
| 19 | // breaks: |
| 20 | // |
| 21 | // * NOTHING IS CONCEALED. Every dotfile at a level is reachable from that |
| 22 | // level, and a file inside one opens and reads like any other. |
| 23 | // * HIS OWN FILES ARE NOT MOVED. The tidying is not paid for by burying his |
| 24 | // work one click deeper. |
| 25 | // * THE ROW IS HONEST ABOUT ITS SIZE. The count says what opening it costs, |
| 26 | // and a `.git` of three hundred objects is ONE row here, not three hundred |
| 27 | // -- the tree lists one directory at a time and opening the row lists |
| 28 | // nothing at all. |
| 29 | // |
| 30 | // node dev/verify_ownfiles.mjs |
| 31 | // node dev/verify_ownfiles.mjs --break nogroup # the defect, restored |
| 32 | // node dev/verify_ownfiles.mjs --break openbydefault # tidy claim, untidy panel |
| 33 | // node dev/verify_ownfiles.mjs --break nocount # a triangle with no size on it |
| 34 | // node dev/verify_ownfiles.mjs --break nopersist # shut again on every reload |
| 35 | // |
| 36 | // A `--break` run EXPECTS to fail: exit 0 when something reddened, 1 when |
| 37 | // nothing did, because a break that changes nothing is itself a failing run. |
| 38 | // |
| 39 | // Needs dev/serve.mjs (DAIMOND_PORT, default 8777). No gateway, no model. |
| 40 | |
| 41 | import fs from 'node:fs'; |
| 42 | import path from 'node:path'; |
| 43 | import { fileURLToPath } from 'node:url'; |
| 44 | import { open, signInAs, shot, errors } from './harness.mjs'; |
| 45 | |
| 46 | const HERE = path.dirname(fileURLToPath(import.meta.url)); |
| 47 | const WWW = path.join(HERE, '..', 'www'); |
| 48 | |
| 49 | const ok = [], bad = []; |
| 50 | const check = (name, pass, detail) => { |
| 51 | (pass ? ok : bad).push(name + (detail ? ' — ' + detail : '')); |
| 52 | console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : '')); |
| 53 | }; |
| 54 | const sleep = ms => new Promise(r => setTimeout(r, ms)); |
| 55 | |
| 56 | // ── The breaks ─────────────────────────────────────────────────────────────── |
| 57 | // |
| 58 | // Each is scoped to survive every check but the ones it proves. `nogroup` puts |
| 59 | // the original blanket filter back, which is the defect exactly. The other three |
| 60 | // leave the row in place and damage one claim it makes about itself. |
| 61 | const BREAK = (() => { const i = process.argv.indexOf('--break'); return i > 0 ? process.argv[i + 1] : ''; })(); |
| 62 | const BREAKS = { |
| 63 | nogroup: [{ |
| 64 | file: 'js/daimond.js', |
| 65 | find: "\t\t\t\tif (e.name.charAt(0) === '.' || (atRoot && e.dir && APP_DIRS[e.name])) { rest.push(e); return; }", |
| 66 | with: "\t\t\t\tif (e.name.charAt(0) === '.') return; // --break nogroup", |
| 67 | }], |
| 68 | openbydefault: [{ |
| 69 | file: 'js/daimond.js', |
| 70 | find: "\t\t\ttry { return localStorage.getItem(LS_RESTGROUP) === '1'; }", |
| 71 | with: "\t\t\ttry { return true; } // --break openbydefault", |
| 72 | }], |
| 73 | nocount: [{ |
| 74 | file: 'js/daimond.js', |
| 75 | find: "\t\t\tn.textContent = tn('files.rest_count', rest.length);", |
| 76 | with: "\t\t\tn.textContent = ''; // --break nocount", |
| 77 | }], |
| 78 | nopersist: [{ |
| 79 | file: 'js/daimond.js', |
| 80 | find: "\t\t\t\ttry { localStorage.setItem(LS_RESTGROUP, restOpen ? '1' : '0'); }", |
| 81 | with: "\t\t\t\ttry { /* --break nopersist */ }", |
| 82 | }], |
| 83 | }; |
| 84 | |
| 85 | function damagedFiles() { |
| 86 | const byFile = new Map(); |
| 87 | for (const spec of (BREAKS[BREAK] || [])) { |
| 88 | const src = fs.readFileSync(path.join(WWW, spec.file), 'utf8'); |
| 89 | if (!src.includes(spec.find)) { |
| 90 | // A break whose anchor is not there patches nothing and launders a |
| 91 | // plain run as proof. Loud, and fatal. |
| 92 | console.error(`--break ${BREAK}: anchor not found in ${spec.file}. The break is stale.`); |
| 93 | process.exit(1); |
| 94 | } |
| 95 | byFile.set(spec.file, src.replace(spec.find, spec.with)); |
| 96 | } |
| 97 | return byFile; |
| 98 | } |
| 99 | |
| 100 | async function serveBreaks(page) { |
| 101 | if (!BREAK) return; |
| 102 | for (const [file, body] of damagedFiles()) { |
| 103 | await page.route('**/' + file, r => r.fulfill({ |
| 104 | status: 200, contentType: 'application/javascript', body, |
| 105 | })); |
| 106 | } |
| 107 | } |
| 108 | |
| 109 | const s = await open({ name: 'ownfiles', route: serveBreaks, connect: false }); |
| 110 | const p = s.page; |
| 111 | |
| 112 | // ── The fixture ────────────────────────────────────────────────────────────── |
| 113 | // |
| 114 | // Written through the engine's own door, not through a chat: `write_file` is not |
| 115 | // fenced because it is not a turn, and a chat could not put a byte outside |
| 116 | // `chats/<id>/work` anyway (see the header of dev/harness.mjs). |
| 117 | // |
| 118 | // `.git` is deliberately FAT. Three hundred loose objects is what makes the |
| 119 | // laziness question answerable rather than assumed: if the panel walks what it |
| 120 | // shows, the row's count and the rows under it would be in the hundreds. |
| 121 | const GIT_OBJECTS = 300; |
| 122 | const seeded = await p.evaluate(async (n) => { |
| 123 | const M = await import('/pkg/oxedyne_daimond.js'); |
| 124 | const w = (path, body) => M.write_file(path, body); |
| 125 | // His own. |
| 126 | await w('notes.md', '# Notes\n'); |
| 127 | await w('myproject/plan.md', '# Plan\n'); |
| 128 | // Daimond's own working directories, at the root among his files. |
| 129 | await w('prompts/chat.md', 'You are helpful.\n'); |
| 130 | await w('mail/a@b.example/INBOX/index.md', '# INBOX\n'); |
| 131 | // His, and previously invisible. |
| 132 | await w('.daimond/skills/mine.md', '# my own skill\nthe body of it\n'); |
| 133 | await w('.gitignore', 'target/\n'); |
| 134 | await w('.env', 'NOTHING=here\n'); |
| 135 | // And a repository, at the size a real one is. |
| 136 | await w('.git/HEAD', 'ref: refs/heads/main\n'); |
| 137 | await w('.git/config', '[core]\n'); |
| 138 | for (let i = 0; i < n; i++) { |
| 139 | await w('.git/objects/ab/' + String(i).padStart(4, '0'), 'x'); |
| 140 | } |
| 141 | return true; |
| 142 | }, GIT_OBJECTS); |
| 143 | check('0 the fixture is written', seeded === true); |
| 144 | |
| 145 | async function openWorkspace() { |
| 146 | await p.evaluate(() => window.DaimondPanels && DaimondPanels.show('work')); |
| 147 | await sleep(500); |
| 148 | await p.evaluate(() => { |
| 149 | const b = document.querySelector('#panel-work [data-act="refresh"]'); |
| 150 | if (b) b.click(); // NOT page.click: a forced click is inert headless |
| 151 | }); |
| 152 | await sleep(1200); |
| 153 | } |
| 154 | |
| 155 | /// Everything the tree is currently saying, at whatever level it is showing. |
| 156 | const tree = () => p.evaluate(() => { |
| 157 | const t = document.querySelector('#panel-work .files-tree'); |
| 158 | if (!t) return null; |
| 159 | const clean = el => ((el.querySelector('.files-name') || {}).textContent || '') |
| 160 | .replace(/^[^A-Za-z0-9._-]+/, '').trim(); |
| 161 | const head = t.querySelector('.files-rest-head'); |
| 162 | const body = t.querySelector('.files-rest-body'); |
| 163 | const countEl = t.querySelector('.files-rest-count'); |
| 164 | return { |
| 165 | row: !!head, |
| 166 | label: head ? (head.querySelector('.files-rest-label') || {}).textContent : '', |
| 167 | count: countEl ? countEl.textContent : '', |
| 168 | expanded: head ? head.getAttribute('aria-expanded') : null, |
| 169 | // `hidden` AND laid out: a body that is `hidden` but styled visible would |
| 170 | // pass the attribute and still be on the screen. |
| 171 | shown: body ? !!body.offsetParent : null, |
| 172 | // Direct children of the tree are the top level; the row's own wrapper is |
| 173 | // a sibling of them, so its contents cannot leak into this list. |
| 174 | top: [...t.children].filter(c => c.classList.contains('files-row')).map(clean), |
| 175 | inside: body ? [...body.querySelectorAll('.files-row')].map(clean) : [], |
| 176 | // Is the row LAST, under his own files rather than over them? |
| 177 | last: !!head && t.lastElementChild === head.parentElement, |
| 178 | }; |
| 179 | }); |
| 180 | |
| 181 | /// Press the row's head, from inside the page. |
| 182 | async function toggleRest() { |
| 183 | await p.evaluate(() => { |
| 184 | const h = document.querySelector('#panel-work .files-rest-head'); |
| 185 | if (h) h.click(); |
| 186 | }); |
| 187 | await sleep(500); |
| 188 | } |
| 189 | |
| 190 | /// Click a row by the name it shows, wherever in the tree it is. |
| 191 | async function clickRow(name) { |
| 192 | return await p.evaluate((want) => { |
| 193 | const rows = [...document.querySelectorAll('#panel-work .files-tree .files-row')]; |
| 194 | const hit = rows.find(r => ((r.querySelector('.files-name') || {}).textContent || '') |
| 195 | .replace(/^[^A-Za-z0-9._-]+/, '').trim() === want); |
| 196 | if (!hit) return false; |
| 197 | hit.click(); |
| 198 | return true; |
| 199 | }, name); |
| 200 | } |
| 201 | |
| 202 | await openWorkspace(); |
| 203 | |
| 204 | // ── 1. The row is there, shut, and under his own files ─────────────────────── |
| 205 | const t0 = await tree(); |
| 206 | check('1a the tree carries the row', !!t0 && t0.row === true); |
| 207 | check('1b AND IT IS SHUT ON FIRST PAINT', |
| 208 | !!t0 && t0.expanded === 'false' && t0.shown === false, |
| 209 | t0 ? `aria-expanded=${t0.expanded} laid out=${t0.shown}` : 'no tree'); |
| 210 | check('1c and it sits at the foot of the tree, under his own files', |
| 211 | !!t0 && t0.last === true); |
| 212 | await shot(s, 'ownfiles-shut'); |
| 213 | |
| 214 | // ── 2. His own files did not move ──────────────────────────────────────────── |
| 215 | // |
| 216 | // The whole tidying is worthless if it was paid for by burying his work. |
| 217 | check('2a HIS OWN FILES ARE AT THE TOP LEVEL', |
| 218 | !!t0 && t0.top.includes('notes.md') && t0.top.includes('myproject'), |
| 219 | t0 ? JSON.stringify(t0.top) : ''); |
| 220 | check('2b and none of them was swept into the row', |
| 221 | !!t0 && !t0.inside.includes('notes.md') && !t0.inside.includes('myproject'), |
| 222 | t0 ? JSON.stringify(t0.inside) : ''); |
| 223 | check('2c while it is shut, nothing of the rest is on the screen', |
| 224 | !!t0 && !t0.top.includes('.daimond') && !t0.top.includes('.git') && t0.shown === false); |
| 225 | |
| 226 | // ── 3. The count, which is what says whether opening it costs anything ─────── |
| 227 | // |
| 228 | // Read BEFORE the row is opened: it is a promise about what is inside, and a |
| 229 | // count computed after the fact would be no promise at all. |
| 230 | const promised = t0 && /(\d+)/.test(t0.count) ? parseInt(t0.count.match(/(\d+)/)[1], 10) : -1; |
| 231 | check('3a the row says how many entries it holds', promised > 0, t0 ? JSON.stringify(t0.count) : ''); |
| 232 | |
| 233 | // Timed, because "it does not walk" is a claim about cost and the honest way to |
| 234 | // make it is with a clock. The rows inside the row were built from the SAME |
| 235 | // listing the tree was drawn from -- one `file_list` of the current directory, |
| 236 | // which happened before the row existed -- so pressing it lays out what is |
| 237 | // already in the DOM and asks the engine for nothing. |
| 238 | const pressed = Date.now(); |
| 239 | await toggleRest(); |
| 240 | const tookMs = Date.now() - pressed - 500; // less the settle `toggleRest` sleeps |
| 241 | const t1 = await tree(); |
| 242 | check('3b OPENING IT REVEALS EXACTLY WHAT IT PROMISED', |
| 243 | !!t1 && t1.inside.length === promised, |
| 244 | t1 ? `promised ${promised}, showed ${t1.inside.length}` : ''); |
| 245 | check('3c and a fat .git is ONE entry in it, not its contents', |
| 246 | !!t1 && t1.inside.includes('.git') && t1.inside.length < 20, |
| 247 | t1 ? `${t1.inside.length} rows for a .git of ${GIT_OBJECTS} objects` : ''); |
| 248 | check('3d and opening it lists nothing: no directory is walked to fill it', |
| 249 | tookMs < 150, `${tookMs} ms to open over a .git of ${GIT_OBJECTS} objects`); |
| 250 | |
| 251 | // ── 4. Nothing is concealed ────────────────────────────────────────────────── |
| 252 | check('4a `.daimond` IS THERE, WHICH IS THE WHOLE REQUEST', |
| 253 | !!t1 && t1.inside.includes('.daimond'), t1 ? JSON.stringify(t1.inside) : ''); |
| 254 | check('4b and so is every other dotfile the panel used to drop', |
| 255 | !!t1 && ['.git', '.gitignore', '.env'].every(n => t1.inside.includes(n)), |
| 256 | t1 ? JSON.stringify(t1.inside) : ''); |
| 257 | check('4c and Daimond’s own working folders are in it too, not among his files', |
| 258 | !!t1 && t1.inside.includes('mail') && t1.inside.includes('prompts') |
| 259 | && !t1.top.includes('mail') && !t1.top.includes('prompts'), |
| 260 | t1 ? `inside ${JSON.stringify(t1.inside)} / top ${JSON.stringify(t1.top)}` : ''); |
| 261 | check('4d the row is open and laid out', !!t1 && t1.expanded === 'true' && t1.shown === true); |
| 262 | await shot(s, 'ownfiles-open'); |
| 263 | |
| 264 | // ── 5. Once open it is an ordinary tree ────────────────────────────────────── |
| 265 | check('5a `.daimond` can be walked into', await clickRow('.daimond')); |
| 266 | await sleep(900); |
| 267 | const t2 = await tree(); |
| 268 | check('5b and it lists like any other folder', |
| 269 | !!t2 && t2.top.includes('skills'), t2 ? JSON.stringify(t2.top) : ''); |
| 270 | check('5c a folder walked into is listed one level at a time', |
| 271 | !!t2 && t2.top.length <= 4, t2 ? `${t2.top.length} rows` : ''); |
| 272 | check('5d and its contents can be walked in turn', await clickRow('skills')); |
| 273 | await sleep(900); |
| 274 | check('5e A FILE INSIDE IT OPENS', await clickRow('mine.md')); |
| 275 | await sleep(1200); |
| 276 | const shown = await p.evaluate(() => { |
| 277 | const pre = document.querySelector('#doc-view .files-view-body'); |
| 278 | return pre ? pre.textContent : null; |
| 279 | }); |
| 280 | check('5f and its text is the file’s own', |
| 281 | !!shown && shown.indexOf('the body of it') !== -1, |
| 282 | shown === null ? 'nothing in the Doc panel' : JSON.stringify(shown.slice(0, 40))); |
| 283 | await shot(s, 'ownfiles-file'); |
| 284 | |
| 285 | /// Reload, and come back signed in. |
| 286 | /// |
| 287 | /// A RELOAD IS A LOCK: `boot()` finds the stored identity and returns before |
| 288 | /// `renderAll`, so a wait on `__DAIMOND_READY` alone is a wait on the lock |
| 289 | /// screen. See dev/verify_reopen.mjs, which documents this. |
| 290 | /// |
| 291 | /// The lock class is asked for REPEATEDLY rather than once. A warm second boot |
| 292 | /// can raise `__DAIMOND_READY` a beat before it paints the gate, and a single |
| 293 | /// glance at that instant sees an unlocked page, skips the sign-in, and then |
| 294 | /// waits thirty seconds for a lock that will never lift. That is a THROW, which |
| 295 | /// takes the process and every check after it -- so this answers false instead, |
| 296 | /// and the caller reddens one check with a fact somebody can act on. |
| 297 | async function reboot() { |
| 298 | await p.reload({ waitUntil: 'domcontentloaded' }); |
| 299 | await p.waitForFunction(() => window.__DAIMOND_READY === true, null, { timeout: 30000 }) |
| 300 | .catch(() => {}); |
| 301 | const locked = () => p.evaluate(() => document.body.classList.contains('locked')); |
| 302 | const until = Date.now() + 20000; |
| 303 | while (Date.now() < until) { |
| 304 | if (await locked()) { |
| 305 | try { await signInAs(s, 'ownfiles'); } catch (e) { return false; } |
| 306 | break; |
| 307 | } |
| 308 | await sleep(300); |
| 309 | } |
| 310 | const done = Date.now() + 20000; |
| 311 | while (Date.now() < done) { |
| 312 | if (!await locked()) { await sleep(1200); return true; } |
| 313 | await sleep(300); |
| 314 | } |
| 315 | return false; |
| 316 | } |
| 317 | |
| 318 | // ── 6. It is remembered ────────────────────────────────────────────────────── |
| 319 | // |
| 320 | // The preference is a preference in both directions, so both are reloaded. |
| 321 | const saved = await p.evaluate(() => { |
| 322 | try { return localStorage.getItem('daimond-files-rest'); } catch (e) { return null; } |
| 323 | }); |
| 324 | check('6a the open state is written down where the panel’s other state is', |
| 325 | saved === '1', JSON.stringify(saved)); |
| 326 | |
| 327 | check('6b the session came back from a reload', await reboot()); |
| 328 | await openWorkspace(); |
| 329 | const t3 = await tree(); |
| 330 | check('6c IT COMES BACK OPEN AFTER A RELOAD', |
| 331 | !!t3 && t3.expanded === 'true' && t3.shown === true, |
| 332 | t3 ? `aria-expanded=${t3.expanded} laid out=${t3.shown}` : 'no tree'); |
| 333 | check('6d and his own files are still at the top level where he left them', |
| 334 | !!t3 && t3.top.includes('notes.md'), t3 ? JSON.stringify(t3.top) : ''); |
| 335 | |
| 336 | // Shut it again, and that is remembered too — the preference is a preference in |
| 337 | // both directions, not a one-way door. |
| 338 | await toggleRest(); |
| 339 | check('6e the session came back from the second reload', await reboot()); |
| 340 | await openWorkspace(); |
| 341 | const t4 = await tree(); |
| 342 | check('6f and shutting it again is remembered as well', |
| 343 | !!t4 && t4.expanded === 'false' && t4.shown === false, |
| 344 | t4 ? `aria-expanded=${t4.expanded}` : 'no tree'); |
| 345 | |
| 346 | // ── 7. The console ─────────────────────────────────────────────────────────── |
| 347 | const errs = errors(s).filter(e => !/favicon|404|401|402|502|Bad Gateway|Account service|net::ERR/.test(e)); |
| 348 | check('7 nothing throws while all this happens', errs.length === 0, errs.slice(0, 2).join(' | ')); |
| 349 | |
| 350 | await s.close(); |
| 351 | |
| 352 | console.log(`\n${ok.length} passed, ${bad.length} failed`); |
| 353 | if (BREAK) { |
| 354 | // A break that reddened nothing proves nothing about the check it names. |
| 355 | console.log(bad.length ? `--break ${BREAK}: reddened ${bad.length} check(s), as it must` |
| 356 | : `--break ${BREAK}: CHANGED NOTHING — the check it names is not testing what it says`); |
| 357 | process.exit(bad.length ? 0 : 1); |
| 358 | } |
| 359 | if (bad.length) console.log('FAILED:\n ' + bad.join('\n ')); |
| 360 | process.exit(bad.length ? 1 : 0); |