Oregami
Repositories/oxedyne/daimond

oxedyne/daimond/dev/verify_dworkspace.mjs

23.1 KiB, 1 run

created by r2519314175:393, 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// The Diamond's workspace: the set of files and folders its daimon may open.
2//
3// It is a VIEW, not a container. The files live in the one workspace and the
4// Diamond points at them, so the same folder attached to two Diamonds is one
5// folder — and taking it out of a Diamond takes nothing off disk. That last
6// point is the one this file exists to hold: a folder tree makes "detach" look
7// like "delete" in a way a strip of chips never did, so the tree must offer no
8// delete on a row it does not own, and a detach must leave the bytes alone.
9//
10// What is pinned here:
11// * the scope switch is offered only while a Diamond is open, and persists;
12// * the Diamond tree is exactly its own directory plus what is attached;
13// * a directory can be attached and detached, and detaching is not deleting;
14// * two attachments sharing a basename are told apart;
15// * the strip above the steer box counts the workspace.
16//
17// Run with dev/serve.mjs up. No gateway needed.
18import { open, shot, signInAs, newChat } from './harness.mjs';
19
20const s = await open({ name: 'dworkspace' });
21const { page } = s;
22let bad = 0;
23const check = (ok, what) => { console.log(`${ok ? 'PASS' : 'FAIL'} ${what}`); if (!ok) bad++; };
24
25// Every expected string is asked of the running app, never spelled here: this
26// app ships eight languages, and a test that hard-codes English is a test that
27// only passes in one of them.
28const T = (k, v) => page.evaluate(([k, v]) => DaimondI18n.t(k, v || undefined), [k, v || null]);
29const TN = (k, n) => page.evaluate(([k, n]) => DaimondI18n.tn(k, n), [k, n]);
30
31/// Everything the store holds about this Diamond's links.
32const linksOf = () => page.evaluate(async () => {
33 const m = await import('/pkg/oxedyne_daimond.js');
34 const app = new m.DaimondApp('http://127.0.0.1/v1/chat/completions', '', 'none', 4096, '', true);
35 const rows = JSON.parse(await app.list_diamonds());
36 const links = JSON.parse(await app.links_touching('diamond:' + rows[0].id) || '[]');
37 return links.map(l => ({ other: l.other, rel: l.rel, by: l.by }));
38});
39
40/// The tree as the user sees it: what is IN the folder, and nothing else.
41///
42/// `.files-up` is excluded, and that is the whole of what changed here on
43/// 2026-08-24. The parent-folder control moved out of the panel's header and into
44/// a `..` row at the top of the listing, where every file manager puts it -- so it
45/// is a `.files-row` and carries `data-act="up"`, which is what keeps the clicks
46/// below working. It is navigation and not an entry, and counting it as one would
47/// have every "this folder holds exactly its one file" check reading two.
48const rows = () => page.$$eval('.files-tree .files-row:not(.files-up)', els => els.map(e => ({
49 name: (e.querySelector('.files-name') || {}).textContent || '',
50 path: e.dataset.path || '',
51 attached: e.dataset.attached || '',
52 elsewhere: (e.querySelector('.files-elsewhere') || {}).title || '',
53 readonly: (e.querySelector('.files-badge.files-ro') || {}).textContent || '',
54 canDelete: !!e.querySelector('.files-del'),
55 detach: (e.querySelector('.files-hold') || {}).title || '',
56})));
57
58const scopeRow = () => page.evaluate(() => {
59 const el = document.querySelector('.files-scope');
60 if (!el) return { present: false };
61 return {
62 present: true,
63 shown: el.style.display !== 'none' && !!el.offsetParent,
64 chips: Array.from(el.querySelectorAll('.files-scope-chip')).map(c => ({
65 scope: c.dataset.scope, text: c.textContent, active: c.classList.contains('active'),
66 pressed: c.getAttribute('aria-pressed'),
67 })),
68 };
69});
70
71const openPanel = async () => {
72 await page.evaluate(() => window.DaimondPanels && DaimondPanels.show('work'));
73 await page.waitForTimeout(600);
74 // Selecting a Diamond restores its arrangement, which can take the panel with
75 // it; showing it again does not necessarily re-list. The refresh button is
76 // what a user reaches for, and it is the only thing that always re-lists.
77 await page.click('#panel-work [data-act="refresh"]', { force: true }).catch(() => {});
78 await page.waitForTimeout(700);
79};
80const setScope = async (which) => {
81 await page.click(`.files-scope-chip[data-scope="${which}"]`, { force: true });
82 await page.waitForTimeout(900);
83};
84
85// ── Files to point at ──────────────────────────────────────────────────
86// Written through the real tool, so they exist the way the user's own do.
87await page.evaluate(async () => {
88 const m = await import('/pkg/oxedyne_daimond.js');
89 const app = new m.DaimondApp('http://127.0.0.1/v1/chat/completions', '', 'none', 4096, '', true);
90 const seed = [
91 ['a/notes/one.md', '# one\n'],
92 ['b/notes/two.md', '# two\n'],
93 ['docs/spec.md', '# spec\n'],
94 ['loose/other.md', '# other\n'],
95 ['loose/ref.md', '# reference\n'],
96 ];
97 for (const [p, c] of seed) await app.run_tool('file_write', JSON.stringify({ path: p, content: c }));
98});
99await page.waitForTimeout(400);
100
101// ── With no Diamond open there is one tree, so no switch ───────────────
102await openPanel();
103let sc = await scopeRow();
104check(sc.present && !sc.shown, `no Diamond open: the scope row is not offered (${JSON.stringify(sc)})`);
105
106// ── A Diamond, which is what makes the second tree exist ───────────────
107await page.click('#new-diamond-btn', { force: true });
108await page.waitForSelector('.dlg-input', { timeout: 10000 });
109await page.fill('.dlg-input', 'Ship a CSV parser');
110await page.click('.dlg-ok', { force: true });
111await page.waitForTimeout(900);
112await openPanel();
113
114sc = await scopeRow();
115const [wordAll, wordOne] = [await T('dws.mode_all'), await T('dws.mode_diamond')];
116check(sc.shown, 'a Diamond is open: the scope row appears');
117check(sc.chips.length === 2, `it offers two trees (${JSON.stringify(sc.chips)})`);
118check(sc.chips[0] && sc.chips[0].text.includes(wordAll), `one is "${wordAll}"`);
119check(sc.chips[1] && sc.chips[1].text.includes(wordOne), `the other is "${wordOne}"`);
120check(sc.chips[0] && sc.chips[0].active, 'and nothing has changed under the user: Everything is still showing');
121
122const everything = (await rows()).map(r => r.path);
123check(everything.includes('docs') && everything.includes('loose'),
124 `the whole workspace is what it shows (${everything.join(', ')})`);
125
126// ── This Diamond: its own directory, and nothing it has not been given ─
127await setScope('diamond');
128sc = await scopeRow();
129check(sc.chips[1] && sc.chips[1].active && sc.chips[1].pressed === 'true',
130 'the switch reports which tree is showing');
131check(await page.$eval('.files-path', e => e.textContent) === await T('dws.title'),
132 'the path line names the Diamond’s workspace rather than claiming to be a directory');
133
134let r = await rows();
135let paths = r.map(x => x.path);
136const id = await page.evaluate(async () => {
137 const m = await import('/pkg/oxedyne_daimond.js');
138 const app = new m.DaimondApp('http://127.0.0.1/v1/chat/completions', '', 'none', 4096, '', true);
139 return JSON.parse(await app.list_diamonds())[0].id;
140});
141check(paths.includes(`diamonds/${id}/crystal.json`),
142 `the Diamond’s own directory is there, as real contents (${paths.join(', ')})`);
143check(!paths.some(p => /^(docs|loose|a|b)$/.test(p)),
144 'and nothing else in the workspace is');
145check(await page.$$eval('.files-dws-hint', els => els.length) === 1,
146 'an empty workspace says what a workspace is for');
147check((await page.$eval('.files-dws-hint', e => e.textContent)) === await T('dws.empty'),
148 'in the app’s own words');
149
150// ── Attaching a directory ──────────────────────────────────────────────
151// The paperclip superseded the ◈ here on 2026-08-11 (ATTACH_CONTRACT.md §4):
152// one control, one hover text, regardless of what it is about to do. THE SECOND
153// HALF OF THAT WAS WITHDRAWN ON 2026-08-24. It is one control still, and it does
154// not say the same thing everywhere any more: on a FOLDER with a Diamond in
155// focus this button writes the `holds` link that `Files.bounds` turns into an
156// `OnlyWriteUnder`, so pressing it grants a daimon the run of that folder, and
157// "Attach to current focus" was the wording under which the owner could not find
158// the marking control at all. What is asserted here is therefore that the folder
159// row NAMES the Diamond it is granting to. `dev/verify_reachlegible.mjs` owns the
160// rest of that change and proves the control is not behind a hover either.
161await setScope('all');
162await page.waitForTimeout(300);
163const dirBtnTitle = await page.$eval('.files-row[data-path="docs"] [data-act="attach"]', b => b.title);
164check(dirBtnTitle === await T('attach.mark_focus', { name: 'Ship a CSV parser' }),
165 `a folder says it MARKS INTO the open Diamond: ${JSON.stringify(dirBtnTitle)}`);
166await page.click('.files-row[data-path="docs"] [data-act="attach"]', { force: true });
167await page.waitForTimeout(900);
168
169let links = await linksOf();
170// A reference now carries the workspace it was recorded in
171// (`dir:[browser]docs`), because a path without its root was allowed and absent
172// -- see dev/verify_attachroot.mjs. These checks are about WHAT IS ATTACHED, so
173// they compare the thing named and ignore the workspace; an assertion pinned to
174// the old spelling reports "no link was written" for a change of format, which
175// is the opposite of what happened.
176const names = (ref) => String(ref).replace(/^(file|dir):(\[[^\]]*\])?/, '');
177const isDir = (ref) => String(ref).indexOf('dir:') === 0;
178const namesDir = (ref, path) => isDir(ref) && names(ref) === path;
179
180const dirLink = links.filter(l => namesDir(l.other, 'docs'));
181check(dirLink.length === 1, `one link was written for the folder (${JSON.stringify(links)})`);
182check(dirLink[0] && dirLink[0].rel === 'holds', `it says "holds" (${JSON.stringify(dirLink[0])})`);
183check(dirLink[0] && dirLink[0].by === 'user', 'and that the user did it, not a fold');
184check(await page.$eval('.files-row[data-path="docs"] [data-act="attach"]',
185 b => b.classList.contains('on') && b.getAttribute('aria-pressed') === 'true'),
186 'the control reports the state it just reached');
187check(await page.$eval('.files-row[data-path="docs"] [data-act="attach"]', b => b.title)
188 === await T('attach.mark_focus', { name: 'Ship a CSV parser' }),
189 'and the same wording takes it back out -- the control, not the label, says which way it goes');
190
191// Two folders with the same basename: the case that makes a basename useless.
192for (const parent of ['a', 'b']) {
193 await page.click(`.files-row[data-path="${parent}"] .files-name`, { force: true });
194 await page.waitForTimeout(700);
195 await page.click(`.files-row[data-path="${parent}/notes"] [data-act="attach"]`, { force: true });
196 await page.waitForTimeout(700);
197 await page.click('[data-act="up"]', { force: true });
198 await page.waitForTimeout(700);
199}
200links = await linksOf();
201check(links.some(l => namesDir(l.other, 'a/notes')) && links.some(l => namesDir(l.other, 'b/notes')),
202 `both same-named folders are attached (${links.map(l => l.other).join(', ')})`);
203
204// A file joins the way it always did: the paperclip on the open file.
205await page.fill('.files-filter-input', 'other');
206await page.waitForTimeout(900);
207for (const row of await page.$$('.files-row')) {
208 const nm = await row.$eval('.files-name', e => e.textContent).catch(() => '');
209 if (nm.includes('other.md')) { await row.click({ force: true }); break; }
210}
211await page.waitForSelector('[data-act="attach"]', { timeout: 8000 });
212await page.waitForTimeout(400);
213await page.click('[data-act="attach"]', { force: true });
214await page.waitForTimeout(800);
215await page.click('[data-act="back"]', { force: true });
216await page.waitForTimeout(400);
217await page.fill('.files-filter-input', '');
218await page.waitForTimeout(800);
219
220// One attached to be consulted rather than worked on, which is what the tool
221// door calls read-only.
222await page.evaluate(async (id) => {
223 const m = await import('/pkg/oxedyne_daimond.js');
224 const app = new m.DaimondApp('http://127.0.0.1/v1/chat/completions', '', 'none', 4096, '', true);
225 await app.add_link(id, 'diamond:' + id, 'file:loose/ref.md', 'consulted', '', 'user');
226 document.dispatchEvent(new CustomEvent('daimond-links-changed'));
227}, id);
228await page.waitForTimeout(900);
229
230// ── The tree in Diamond mode: exactly what is in the workspace ─────────
231await setScope('diamond');
232r = await rows();
233paths = r.map(x => x.path).sort();
234// A Diamond's own directory is now three things: the memory, the page that draws
235// it, and the snapshots. The page is there from the first time the Diamond is
236// opened -- one whose page is missing is given the shipped default AND it is
237// written, so a default can be diffed against the edit that broke it.
238const want = [
239 `diamonds/${id}/crystal.json`, `diamonds/${id}/crystal.html`, `diamonds/${id}/versions`,
240 'a/notes', 'b/notes', 'docs', 'loose/other.md', 'loose/ref.md',
241].sort();
242check(JSON.stringify(paths) === JSON.stringify(want),
243 `the tree is its own directory plus what is attached, and nothing else\n got: ${paths.join(', ')}\n want: ${want.join(', ')}`);
244
245const att = r.filter(x => x.attached);
246check(att.length === 5, `five things are attached (${att.length})`);
247const elsewhereWord = await T('dws.elsewhere');
248check(att.every(x => x.elsewhere.startsWith(elsewhereWord)),
249 `each says it lives elsewhere in the workspace (${JSON.stringify(att.map(x => x.elsewhere))})`);
250check(att.every(x => x.elsewhere.includes(x.path)),
251 'and where, exactly — a basename alone would not say');
252check(att.every(x => !x.canDelete),
253 'no attached row offers to delete the file it points at');
254check(r.filter(x => !x.attached).every(x => x.canDelete),
255 'while the Diamond’s own files are managed as usual');
256
257const notes = att.filter(x => /notes$/.test(x.path)).map(x => x.name.replace(/^\S+\s/, ''));
258check(notes.length === 2 && notes[0] !== notes[1],
259 `two folders called "notes" are told apart: ${JSON.stringify(notes)}`);
260check(notes.every(n => n.includes('/')),
261 'by as much of the path as it takes, not by a number');
262check(att.filter(x => x.path === 'docs')[0].name.replace(/^\S+\s/, '') === 'docs',
263 'while an unambiguous one keeps its plain name');
264
265// Inside an attached folder the same rule holds: these are the workspace's
266// files, borrowed. The Diamond may work on them; it may not destroy them from
267// the view that only points at them.
268await page.click('.files-row[data-path="a/notes"] .files-name', { force: true });
269await page.waitForTimeout(800);
270let inside = await rows();
271check(inside.length === 1 && inside[0].path === 'a/notes/one.md',
272 `an attached folder can be opened (${JSON.stringify(inside.map(x => x.path))})`);
273check(inside.every(x => !x.canDelete),
274 'and what is inside it offers no delete either');
275// THE WAY OUT IS THE FIRST ROW, not a button three controls away in the header.
276// Asserted before it is pressed, because "there is a way up" and "pressing it
277// works" are two properties and the panel used to satisfy the second with a
278// control nobody could find.
279const upFirst = await page.evaluate(() => {
280 const first = document.querySelector('.files-tree .files-row');
281 const head = document.querySelector('#panel-work .railhead [data-act="up"]');
282 return { act: first ? (first.dataset.act || '') : '(no rows)',
283 text: first ? first.textContent.trim() : '', inHeader: !!head };
284});
285check(upFirst.act === 'up' && /\.\./.test(upFirst.text),
286 `the way out of a folder is the first row of the listing (${JSON.stringify(upFirst)})`);
287check(upFirst.inHeader === false,
288 'and it is not ALSO a button in the panel header, which is what he objected to');
289await page.click('[data-act="up"]', { force: true });
290await page.waitForTimeout(800);
291check((await page.$eval('.files-path', e => e.textContent)) === await T('dws.title'),
292 'going up from an attached folder lands back in the workspace, not above it');
293
294// The same folder in the Everything tree is the user's own to manage.
295await setScope('all');
296await page.click('.files-row[data-path="a"] .files-name', { force: true });
297await page.waitForTimeout(800);
298inside = await rows();
299check(inside.length === 1 && inside[0].canDelete,
300 `the same files are managed as usual in the whole workspace (${JSON.stringify(inside)})`);
301await page.click('[data-act="up"]', { force: true });
302await page.waitForTimeout(700);
303await setScope('diamond');
304r = await rows();
305att.length = 0;
306Array.prototype.push.apply(att, r.filter(x => x.attached));
307
308const roWord = await T('dws.readonly');
309const ro = att.filter(x => x.path === 'loose/ref.md')[0];
310check(ro && ro.readonly === roWord, `one attached to be consulted is marked read only (${JSON.stringify(ro && ro.readonly)})`);
311check(att.filter(x => x.path === 'loose/other.md')[0].readonly === '',
312 'and one attached to be worked on is not');
313
314// ── The strip above the steer box counts the workspace ─────────────────
315const strip = await page.$eval('#arte-strip', e => ({ shown: e.style.display !== 'none', text: e.textContent, title: e.title }));
316check(strip.shown, 'the strip above the steer box is showing');
317check(strip.text === '◈ ' + await TN('dws.count', 5),
318 `it counts what is in this Diamond’s workspace: ${JSON.stringify(strip.text)}`);
319check(strip.title === await T('dws.title'), `and calls it that: ${JSON.stringify(strip.title)}`);
320
321await shot(s, 'dworkspace-tree');
322
323// ── Detach is not delete ───────────────────────────────────────────────
324const detachTitle = await page.$eval('.files-row[data-path="docs"] .files-hold', b => b.title);
325check(detachTitle === await T('dws.detach_dir', { name: 'Ship a CSV parser' }),
326 `the only control on an attached folder is the one that takes it out: ${JSON.stringify(detachTitle)}`);
327await page.click('.files-row[data-path="docs"] .files-hold', { force: true });
328await page.waitForTimeout(1000);
329
330links = await linksOf();
331check(!links.some(l => namesDir(l.other, 'docs')), 'detaching drops the link');
332const survived = await page.evaluate(async () => {
333 const m = await import('/pkg/oxedyne_daimond.js');
334 const app = new m.DaimondApp('http://127.0.0.1/v1/chat/completions', '', 'none', 4096, '', true);
335 const list = await app.run_tool('file_list', JSON.stringify({ path: 'docs' }));
336 const read = await app.run_tool('file_read', JSON.stringify({ path: 'docs/spec.md' }));
337 return { list: String(list), read: String(read) };
338});
339check(/spec\.md/.test(survived.list), `the folder is still on disk (${JSON.stringify(survived.list)})`);
340check(/# spec/.test(survived.read), 'and so is the file in it, unchanged');
341// The probe above is the whole safety claim, so prove it can tell a file that is
342// there from one that is not: a read that answers "fine" for a deleted file
343// would make the check above meaningless.
344const oracle = await page.evaluate(async () => {
345 const m = await import('/pkg/oxedyne_daimond.js');
346 const app = new m.DaimondApp('http://127.0.0.1/v1/chat/completions', '', 'none', 4096, '', true);
347 await app.run_tool('file_write', JSON.stringify({ path: 'docs/proof.md', content: '# spec\n' }));
348 const there = String(await app.run_tool('file_read', JSON.stringify({ path: 'docs/proof.md' })));
349 await app.run_tool('file_delete', JSON.stringify({ path: 'docs/proof.md' }));
350 const gone = String(await app.run_tool('file_read', JSON.stringify({ path: 'docs/proof.md' })));
351 return { there, gone };
352});
353check(/# spec/.test(oracle.there) && !/# spec/.test(oracle.gone),
354 `and the probe knows the difference: a deleted file reads back as gone (${JSON.stringify(oracle.gone.slice(0, 60))})`);
355paths = (await rows()).map(x => x.path);
356check(!paths.includes('docs'), 'and it has left this Diamond’s workspace');
357
358// Detaching a FILE from the tree does the same.
359await page.click('.files-row[data-path="loose/other.md"] .files-hold', { force: true });
360await page.waitForTimeout(1000);
361links = await linksOf();
362check(!links.some(l => l.other === 'file:loose/other.md'), 'a file detaches from the tree too');
363const fileLives = await page.evaluate(async () => {
364 const m = await import('/pkg/oxedyne_daimond.js');
365 const app = new m.DaimondApp('http://127.0.0.1/v1/chat/completions', '', 'none', 4096, '', true);
366 return String(await app.run_tool('file_read', JSON.stringify({ path: 'loose/other.md' })));
367});
368check(/# other/.test(fileLives), 'and is still on disk afterwards');
369check((await page.$eval('#arte-strip', e => e.textContent)) === '◈ ' + await TN('dws.count', 3),
370 'the strip counts down with it');
371
372// ── Closing the Diamond closes the second tree ─────────────────────────
373// A chat is not a Diamond, so going to one closes the Diamond — and with it the
374// tree that was about the Diamond. The panel must not be left showing a
375// workspace nobody is in.
376await newChat(s);
377await page.waitForTimeout(800);
378await openPanel();
379sc = await scopeRow();
380check(!sc.shown, `closing the Diamond withdraws the switch (${JSON.stringify(sc)})`);
381paths = (await rows()).map(x => x.path);
382check(paths.includes('docs') && paths.includes('loose'),
383 `and the tree is the whole workspace again (${paths.join(', ')})`);
384
385// ── The choice is the device's, and survives a reload ──────────────────
386await page.reload({ waitUntil: 'domcontentloaded' });
387await signInAs(s, 'dworkspace');
388await page.waitForTimeout(3000);
389await page.evaluate(() => {
390 const row = Array.from(document.querySelectorAll('#diamond-list .diamond-box'))
391 .find(e => /Ship a CSV parser/.test(e.textContent));
392 if (row) row.click();
393});
394await page.waitForTimeout(1200);
395await openPanel();
396sc = await scopeRow();
397check(sc.shown && sc.chips[1] && sc.chips[1].active,
398 `the tree the user chose is the tree they get back (${JSON.stringify(sc.chips)})`);
399paths = (await rows()).map(x => x.path);
400check(paths.includes(`diamonds/${id}/crystal.json`) && !paths.includes('docs'),
401 `and it is that Diamond’s workspace (${paths.join(', ')})`);
402
403// ── Eight languages, and the row is on screen in all of them ───────────
404// The scope chips never rebuild on their own: they sit in the panel for as long
405// as it is open, which is the shape of every hardcoded-English defect this app
406// has had. So the words must change under a locale change, without a reload.
407const before = (await scopeRow()).chips.map(c => c.text);
408await page.evaluate(async () => { await DaimondI18n.setLocale('fr'); });
409await page.waitForTimeout(1200);
410const after = (await scopeRow()).chips.map(c => c.text);
411check(after[0] === await T('dws.mode_all') && after[1].includes(await T('dws.mode_diamond')),
412 `a language change reaches the scope row: ${JSON.stringify(before)} → ${JSON.stringify(after)}`);
413check(JSON.stringify(before) !== JSON.stringify(after),
414 'and it really did change, so the check is not passing on English twice');
415await page.evaluate(async () => { await DaimondI18n.setLocale('en'); });
416await page.waitForTimeout(900);
417
418// A dev server with no gateway behind it answers 502 to the account polls; that
419// is the environment, not the page. Anything the PAGE itself threw still counts.
420const errs = (s.errs || []).filter(e =>
421 !/favicon/i.test(e) && !/502|Bad Gateway|Failed to load resource/i.test(e));
422check(errs.length === 0, `nothing threw along the way (${errs.slice(0, 3).join(' | ')})`);
423
424await shot(s, 'dworkspace-reload');
425console.log(bad ? `\n${bad} FAILED` : `\nALL PASS`);
426await s.close();
427process.exit(bad ? 1 : 0);