Oregami
Repositories/oxedyne/daimond

oxedyne/daimond/dev/verify_doc.mjs

16.6 KiB, 1 run

created by r2519314175:371, 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_doc.mjs — where a document goes when you open it.
2//
3// The rule this asserts, which was decided rather than inherited:
4//
5// The stage holds what you are ATTENDING to -- a web page, a mail message, a
6// document. The dock holds AMBIENT state you want in the corner of your eye,
7// including the file tree. So a file tree is dock furniture and a file you
8// have opened is stage furniture, whatever format it happens to be in.
9//
10// Before this, text opened inside the ~260px Workspace tile and only a compiled
11// PDF reached the wide Doc panel -- a split by file format, which is a fact
12// about the implementation and not about the reader. Worse, opening a file hid
13// the tree to make room, so reading cost you your bearings.
14//
15// And what the panel does when it is ASKED FOR with nothing to show, which until
16// now was nothing at all: no hook, a blank title and an empty `display:none`
17// body. It offers a new document instead, and the checks below require it to
18// name itself, to name which of the two filesystems Save will write it to, and
19// then to write exactly the bytes typed and no others.
20//
21// node dev/verify_doc.mjs
22
23import fs from 'node:fs';
24import os from 'node:os';
25import path from 'node:path';
26import { spawn } from 'node:child_process';
27import { fileURLToPath } from 'node:url';
28import * as H from './harness.mjs';
29
30const HERE = path.dirname(fileURLToPath(import.meta.url));
31const ROOT = path.join(HERE, '..');
32
33const ok = [], bad = [];
34const check = (name, pass, detail) => {
35 (pass ? ok : bad).push(name);
36 console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : ''));
37};
38const sleep = ms => new Promise(r => setTimeout(r, ms));
39
40/// A screenshot of one element, PROVEN to exist. `H.shot` swallows a failed
41/// capture, and a clean run is otherwise no evidence that anything was taken.
42async function shotOf(page, name, sel) {
43 const dir = path.join(os.homedir(), '.cache/daimond/lane-v-shots');
44 const out = path.join(dir, name + '.png');
45 try {
46 fs.mkdirSync(dir, { recursive: true });
47 const el = await page.$(sel);
48 if (!el) { console.log(` note no ${sel} to photograph`); return null; }
49 await el.screenshot({ path: out, timeout: 8000 });
50 } catch (e) { console.log(` note screenshot ${name} failed: ${String(e).split('\n')[0]}`); return null; }
51 if (!fs.existsSync(out) || fs.statSync(out).size < 500) { console.log(` note ${name} is not on disk`); return null; }
52 console.log(' shot ' + out);
53 return out;
54}
55const procs = [];
56async function waitFor(fn, ms = 15000, gap = 300) {
57 const t0 = Date.now();
58 for (;;) {
59 try { if (await fn()) return true; } catch (e) {}
60 if (Date.now() - t0 > ms) return false;
61 await sleep(gap);
62 }
63}
64function cleanup(s) {
65 if (s) { try { s.browser.close(); } catch (e) {} }
66 for (const p of procs) { try { p.kill('SIGKILL'); } catch (e) {} }
67}
68
69/// The state of the two panels, as a reader would see it.
70async function surfaces(page) {
71 return await page.evaluate(() => {
72 const shown = el => !!el && el.offsetParent !== null;
73 const doc = document.getElementById('panel-doc');
74 const work = document.getElementById('panel-work');
75 const view = document.getElementById('doc-view');
76 return {
77 docOpen: shown(doc),
78 workOpen: shown(work),
79 treeShown: shown(work && work.querySelector('.files-tree')),
80 docName: (document.getElementById('doc-name') || {}).textContent || '',
81 docText: ((view && view.querySelector('.files-view-body')) || {}).textContent || '',
82 viewInDoc: !!(view && view.querySelector('.files-view-body')),
83 viewInWork: !!(work && work.querySelector('.files-view-body')),
84 embedShown: shown(document.getElementById('doc-embed')),
85 };
86 });
87}
88
89let s = null;
90(async () => {
91 let served = false;
92 try { served = (await fetch(H.APP + '/')).ok; } catch (e) {}
93 if (!served) {
94 procs.push(spawn('node', ['dev/serve.mjs'], { cwd: ROOT, stdio: ['ignore', 'ignore', 'ignore'] }));
95 served = await waitFor(async () => (await fetch(H.APP + '/')).ok, 10000);
96 }
97 check('dev server serves the app', served);
98 if (!served) { cleanup(null); process.exit(1); }
99
100 s = await H.open({ name: 'doc-panel', connect: false });
101 const page = s.page;
102
103 // Write a file straight through the tool the agent uses, so this tests the
104 // panels and not the file dialogs.
105 const body = 'Chapter one.\nThe document panel is where a document goes.\nLine three.\n';
106 await page.evaluate(async (text) => {
107 await window.DaimondPanels.show('work');
108 const app = window.__daimondToolHost || null;
109 // The Workspace panel's own writer, which is what the New File button uses.
110 const w = await navigator.storage.getDirectory();
111 const fh = await w.getFileHandle('chapter.txt', { create: true });
112 const ws = await fh.createWritable();
113 await ws.write(new TextEncoder().encode(text));
114 await ws.close();
115 return !!app;
116 }, body);
117
118 await page.evaluate(() => window.DaimondPanels.show('work'));
119 await sleep(600);
120 await page.evaluate(() => {
121 const r = document.querySelector('#panel-work [data-act="refresh"]');
122 if (r) r.click();
123 });
124 await sleep(900);
125
126 const before = await surfaces(page);
127 check('the Workspace tile is open with its tree showing',
128 before.workOpen && before.treeShown, JSON.stringify(before));
129 check('Doc is not open before anything is opened', !before.docOpen);
130 // The viewer must not still be built into the Workspace panel.
131 check('the Workspace panel no longer contains a document view', !before.viewInWork);
132
133 // Open it the way a person does: click the row in the tree.
134 const opened = await page.evaluate(() => {
135 const rows = Array.from(document.querySelectorAll('#panel-work .files-row'));
136 const row = rows.find(r => /chapter\.txt/.test(r.textContent || ''));
137 if (!row) return 'chapter.txt is not in the tree: ' + rows.map(r => r.textContent).join(',');
138 row.click();
139 return true;
140 });
141 check('the file is in the tree and can be clicked', opened === true, String(opened));
142 await sleep(1200);
143
144 const after = await surfaces(page);
145 check('opening a text file raises the Doc panel', after.docOpen, JSON.stringify(after));
146 check('the document renders as text in Doc', after.viewInDoc && /Chapter one/.test(after.docText),
147 after.docText.slice(0, 40));
148 check('Doc names the file', /chapter\.txt/.test(after.docName), after.docName);
149 // The whole point of moving it: you keep your bearings while reading.
150 check('the file tree is STILL showing while the document is open',
151 after.workOpen && after.treeShown, JSON.stringify({ work: after.workOpen, tree: after.treeShown }));
152 check('the PDF embed stays out of the way for a text file', !after.embedShown);
153
154 // Editing happens where reading happens, or the split is back.
155 const canEdit = await page.evaluate(() => {
156 const v = document.getElementById('doc-view');
157 return !!(v && v.querySelector('[data-act="edit"]') && v.querySelector('[data-act="download"]'));
158 });
159 check('the document can be edited and downloaded from Doc', canEdit);
160
161 // ── The button row is one height, not two ──────────────────────────
162 // ◈ only shows itself with a Diamond open (it names what that Diamond
163 // holds), so one is made here purely to bring the button on screen.
164 //
165 // Measured, not eyeballed: "consistent" means every visible button in
166 // `.files-view-head` reports the SAME `getBoundingClientRect().height`,
167 // to within sub-pixel layout rounding. ◈ used to carry a bigger font on
168 // the same padding as its neighbours, which grew the whole button by the
169 // same fraction as the glyph — 23.6px against their 22.4px, a difference
170 // too small to name on sight and exactly the kind a screenshot alone
171 // would miss and a measurement catches every time.
172 // The new-Diamond dialog refuses to create one with no model chosen, and
173 // this suite runs with `connect: false` for everything up to here.
174 await H.connectMock(s);
175 await sleep(400);
176 await page.click('#new-diamond-btn', { force: true });
177 await page.waitForSelector('.dlg-input', { timeout: 10000 });
178 await page.fill('.dlg-input', 'Doc header probe');
179 await page.click('.dlg-ok', { force: true });
180 await sleep(900);
181 await page.evaluate(() => {
182 const r = document.querySelector('#panel-work [data-act="refresh"]');
183 if (r) r.click();
184 });
185 await sleep(700);
186 const reopened = await page.evaluate(() => {
187 const rows = Array.from(document.querySelectorAll('#panel-work .files-row'));
188 const row = rows.find(r => /chapter\.txt/.test(r.textContent || ''));
189 if (!row) return false;
190 row.click();
191 return true;
192 });
193 check('the file can be reopened with a Diamond in focus', reopened);
194 await sleep(900);
195
196 const heights = await page.evaluate(() => {
197 return Array.from(document.querySelectorAll('.files-view-head .files-btn'))
198 .filter(b => getComputedStyle(b).display !== 'none')
199 .map(b => ({ act: b.dataset.act, h: b.getBoundingClientRect().height }));
200 });
201 // `attach`, not `hold`: the ◈ became the paperclip when attaching stopped
202 // meaning "keep with this Diamond" and started meaning "attach to whatever is
203 // in focus". The height property is unchanged — this is the button's name.
204 const holdShown = heights.some(b => b.act === 'attach');
205 check('the attach button is on screen for this measurement', holdShown, JSON.stringify(heights));
206 const distinct = Array.from(new Set(heights.map(b => Math.round(b.h * 10) / 10)));
207 check('every button in the row reports the same height',
208 holdShown && distinct.length === 1,
209 `${JSON.stringify(heights)} — ${distinct.length} distinct height(s)`);
210
211 // Closing it puts the panel away and leaves the tree where it was.
212 await page.evaluate(() => {
213 const b = document.querySelector('#doc-view [data-act="back"]');
214 if (b) b.click();
215 });
216 await sleep(800);
217 const closed = await surfaces(page);
218 check('closing the document closes Doc', !closed.docOpen, JSON.stringify(closed));
219 check('and the tree is untouched', closed.workOpen && closed.treeShown);
220
221 // ── The panel asked for with nothing to show ───────────────────────
222 //
223 // It held NOTHING. `#doc-view` starts empty and `display:none`, the title
224 // starts blank, and `DaimondPanels.show` carried an onOpen hook for the
225 // Workspace, Mail, Spending, the Terminal, the Trash and Social -- and none
226 // for this panel. So the Doc chip opened a rectangle with a close button in
227 // it and no other mark, which reads as broken rather than as empty.
228 //
229 // What it does now is offer a NEW DOCUMENT, and the three things a new
230 // document must not leave unsaid are each asserted rather than admired:
231 // what it is called, that it is Save and not time that writes it, and WHICH
232 // FILESYSTEM it lands in -- the last being the confusion this project has
233 // paid most for, so the sentence is required to name the place in the same
234 // words the Workspace panel's own mode chip uses.
235 const coldPath = 'notes-' + new Date().toISOString().slice(0, 10) + '.md';
236 // The panel names the cold document by TODAY, computed when it opens -- not
237 // when this line runs. A run that straddles midnight sees the date advance in
238 // between, so the name the panel shows may be this day's or the next. Both are
239 // covered here, and the file is read back below under the name it ACTUALLY
240 // showed, so a rollover mid-run is a fact about the clock and not a red.
241 const nextPath = 'notes-'
242 + new Date(Date.parse(coldPath.slice(6, 16)) + 86400000).toISOString().slice(0, 10) + '.md';
243 // It must not exist BEFORE the panel is opened, or "Save created it" is a
244 // claim about a file that was already there. Neither candidate name may be on
245 // disk, so whichever the panel picks, Save is what wrote it.
246 const existedBefore = await page.evaluate(async (rels) => {
247 for (const rel of rels) {
248 try { const m = await import('/pkg/oxedyne_daimond.js'); await m.read_file(rel); return rel; }
249 catch (e) { /* good: not on disk */ }
250 }
251 return '';
252 }, [coldPath, nextPath]);
253 check('the document this will propose is not on disk yet', !existedBefore,
254 existedBefore || (coldPath + ' / ' + nextPath));
255
256 await page.evaluate(() => {
257 window.DaimondPanels.markUsed('doc');
258 window.DaimondPanels.show('doc');
259 window.DaimondPanels.reflow();
260 });
261 await sleep(1500);
262 const cold = await page.evaluate(() => {
263 const view = document.getElementById('doc-view');
264 const shown = (sel) => {
265 const e = view && view.querySelector(sel);
266 return !!e && e.getClientRects().length > 0;
267 };
268 return {
269 name: (document.getElementById('doc-name') || {}).textContent || '',
270 editing: !!(view && view.querySelector('.files-edit')),
271 said: ((view && view.querySelector('.files-view-msg')) || {}).textContent || '',
272 saidShown: shown('.files-view-msg'),
273 // The Browser chip's own word, read off the mode row rather than
274 // restated here: the check is that the two agree, and hard-coding the
275 // word would let them drift apart while it went on passing.
276 place: ((document.querySelector('#panel-work .files-mode-chip.active')) || {}).textContent || '',
277 saveBtn: ((view && view.querySelector('[data-act="edit"]')) || {}).textContent || '',
278 download: shown('[data-act="download"]'),
279 attach: shown('[data-act="attach"]'),
280 };
281 });
282 check('opening Doc cold puts an editable document in it, not nothing',
283 cold.editing, JSON.stringify(cold));
284 check('and it is named, so it can be found again', cold.name === coldPath || cold.name === nextPath,
285 cold.name + (cold.name === nextPath ? ' (the clock rolled to the next day mid-run)' : ''));
286 check('the panel says where the document will land, in the mode row\'s own word',
287 cold.saidShown && !!cold.place.trim() && cold.said.includes(cold.place.trim()),
288 JSON.stringify({ said: cold.said, place: cold.place }));
289 check('the button says which state it is in: there are edits to write',
290 /Save/i.test(cold.saveBtn), cold.saveBtn);
291 // The picture, because the checks above say the panel is not empty and only a
292 // picture says whether what replaced the emptiness is worth looking at.
293 await shotOf(page, 'doc-cold', '#panel-doc');
294 check('nothing that acts on a file is offered before there is one',
295 cold.editing && !cold.download && !cold.attach,
296 JSON.stringify({ editing: cold.editing, download: cold.download, attach: cold.attach }));
297
298 // And it really saves, at the name it showed, with the bytes that were typed
299 // and no others -- the property `verify_docroundtrip` asserts for a file that
300 // already exists, asserted here for the one this panel invents.
301 const NEW_BODY = '# Cold open\n\nTyped into a document that did not exist.\n';
302 await page.evaluate((text) => {
303 const ta = document.querySelector('#doc-view .files-edit');
304 if (ta) { ta.value = text; ta.dispatchEvent(new Event('input', { bubbles: true })); }
305 }, NEW_BODY);
306 await page.evaluate(() => {
307 const b = document.querySelector('#doc-view [data-act="edit"]');
308 if (b) b.click();
309 });
310 await sleep(2500);
311 // Read back under the name the panel ACTUALLY showed, not the one guessed
312 // before it opened: on a midnight-straddling run those differ, and the round
313 // trip is about the bytes at the panel's own name.
314 const saved = await page.evaluate(async (rel) => {
315 const view = document.getElementById('doc-view');
316 let onDisk = null;
317 try { const m = await import('/pkg/oxedyne_daimond.js'); onDisk = await m.read_file(rel); }
318 catch (e) { onDisk = 'READ FAILED: ' + e; }
319 const shown = (sel) => {
320 const e = view && view.querySelector(sel);
321 return !!e && e.getClientRects().length > 0;
322 };
323 return {
324 onDisk,
325 said: ((view && view.querySelector('.files-view-msg')) || {}).textContent || '',
326 editBtn: ((view && view.querySelector('[data-act="edit"]')) || {}).textContent || '',
327 download: shown('[data-act="download"]'),
328 attach: shown('[data-act="attach"]'),
329 };
330 }, cold.name);
331 check('Save writes exactly what was typed, at the name the panel showed',
332 saved.onDisk === NEW_BODY, JSON.stringify(saved.onDisk));
333 check('and the button flips back, so the user can tell it is written',
334 /Edit/i.test(saved.editBtn) && /Saved/i.test(saved.said),
335 JSON.stringify({ btn: saved.editBtn, said: saved.said }));
336 check('the file controls come back now that there is a file',
337 saved.download && saved.attach,
338 JSON.stringify({ download: saved.download, attach: saved.attach }));
339 await shotOf(page, 'doc-cold-saved', '#panel-doc');
340
341 // The gateway is not running for this suite, and the app is meant to work
342 // without one, so its 502s are the expected answer rather than a fault.
343 const real = s.errs.filter(e => !/502|Bad Gateway|Failed to load resource/.test(e));
344 check('no page errors', real.length === 0, real.slice(0, 3).join(' | '));
345
346 await H.shot(s, 'doc-panel').catch(() => {});
347 cleanup(s);
348 console.log(`\n${ok.length} passed, ${bad.length} failed`);
349 process.exit(bad.length ? 1 : 0);
350})().catch(e => { cleanup(s); console.error(e); process.exit(1); });