Oregami
Repositories/oxedyne/daimond

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
41import fs from 'node:fs';
42import path from 'node:path';
43import { fileURLToPath } from 'node:url';
44import { open, signInAs, shot, errors } from './harness.mjs';
45
46const HERE = path.dirname(fileURLToPath(import.meta.url));
47const WWW = path.join(HERE, '..', 'www');
48
49const ok = [], bad = [];
50const check = (name, pass, detail) => {
51 (pass ? ok : bad).push(name + (detail ? ' — ' + detail : ''));
52 console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : ''));
53};
54const 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.
61const BREAK = (() => { const i = process.argv.indexOf('--break'); return i > 0 ? process.argv[i + 1] : ''; })();
62const 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
85function 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
100async 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
109const s = await open({ name: 'ownfiles', route: serveBreaks, connect: false });
110const 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.
121const GIT_OBJECTS = 300;
122const 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);
143check('0 the fixture is written', seeded === true);
144
145async 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.
156const 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.
182async 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.
191async 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
202await openWorkspace();
203
204// ── 1. The row is there, shut, and under his own files ───────────────────────
205const t0 = await tree();
206check('1a the tree carries the row', !!t0 && t0.row === true);
207check('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');
210check('1c and it sits at the foot of the tree, under his own files',
211 !!t0 && t0.last === true);
212await 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.
217check('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) : '');
220check('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) : '');
223check('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.
230const promised = t0 && /(\d+)/.test(t0.count) ? parseInt(t0.count.match(/(\d+)/)[1], 10) : -1;
231check('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.
238const pressed = Date.now();
239await toggleRest();
240const tookMs = Date.now() - pressed - 500; // less the settle `toggleRest` sleeps
241const t1 = await tree();
242check('3b OPENING IT REVEALS EXACTLY WHAT IT PROMISED',
243 !!t1 && t1.inside.length === promised,
244 t1 ? `promised ${promised}, showed ${t1.inside.length}` : '');
245check('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` : '');
248check('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 ──────────────────────────────────────────────────
252check('4a `.daimond` IS THERE, WHICH IS THE WHOLE REQUEST',
253 !!t1 && t1.inside.includes('.daimond'), t1 ? JSON.stringify(t1.inside) : '');
254check('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) : '');
257check('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)}` : '');
261check('4d the row is open and laid out', !!t1 && t1.expanded === 'true' && t1.shown === true);
262await shot(s, 'ownfiles-open');
263
264// ── 5. Once open it is an ordinary tree ──────────────────────────────────────
265check('5a `.daimond` can be walked into', await clickRow('.daimond'));
266await sleep(900);
267const t2 = await tree();
268check('5b and it lists like any other folder',
269 !!t2 && t2.top.includes('skills'), t2 ? JSON.stringify(t2.top) : '');
270check('5c a folder walked into is listed one level at a time',
271 !!t2 && t2.top.length <= 4, t2 ? `${t2.top.length} rows` : '');
272check('5d and its contents can be walked in turn', await clickRow('skills'));
273await sleep(900);
274check('5e A FILE INSIDE IT OPENS', await clickRow('mine.md'));
275await sleep(1200);
276const shown = await p.evaluate(() => {
277 const pre = document.querySelector('#doc-view .files-view-body');
278 return pre ? pre.textContent : null;
279});
280check('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)));
283await 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.
297async 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.
321const saved = await p.evaluate(() => {
322 try { return localStorage.getItem('daimond-files-rest'); } catch (e) { return null; }
323});
324check('6a the open state is written down where the panel’s other state is',
325 saved === '1', JSON.stringify(saved));
326
327check('6b the session came back from a reload', await reboot());
328await openWorkspace();
329const t3 = await tree();
330check('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');
333check('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.
338await toggleRest();
339check('6e the session came back from the second reload', await reboot());
340await openWorkspace();
341const t4 = await tree();
342check('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 ───────────────────────────────────────────────────────────
347const errs = errors(s).filter(e => !/favicon|404|401|402|502|Bad Gateway|Account service|net::ERR/.test(e));
348check('7 nothing throws while all this happens', errs.length === 0, errs.slice(0, 2).join(' | '));
349
350await s.close();
351
352console.log(`\n${ok.length} passed, ${bad.length} failed`);
353if (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}
359if (bad.length) console.log('FAILED:\n ' + bad.join('\n '));
360process.exit(bad.length ? 1 : 0);