Oregami
Repositories/oxedyne/daimond

oxedyne/daimond/dev/verify_crystalpanel.mjs

14.3 KiB, 1 run

created by r2519314175:335, 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_crystalpanel.mjs — opening or closing a panel does not take the crystal's page down.
2//
3// THE DEFECT THIS IS WRITTEN FROM, in the owner's words (notes6, item 3): "adding or removing a
4// panel in the stage makes the crystal show 'This Diamond's page did not show everything it
5// holds…'". That note is `crystal.js` giving up on the page: `fell('partial')`, the frame gone,
6// the built-in view in its place. Nothing about the crystal had changed — the user had merely
7// opened the Tools panel.
8//
9// WHAT WAS ACTUALLY HAPPENING, measured before anything was fixed. Not a second `rendered` with
10// fewer keys, which is the trap CAPP_CONTRACT §5 warns about: the key list was complete and
11// identical throughout. It was the OTHER rule, §4. `DaimondPanels.apply()` re-seated the layout
12// by appending every open panel to its zone, every time anything opened or closed anywhere —
13// and `appendChild` on a node that is already in the document is a REMOVAL and a re-insertion.
14// An iframe taken out of the document loses its content window and is navigated to its `src`
15// again when it goes back in, so the crystal frame fired a second `load`; `crystal.js` reads
16// that as the page navigating itself and shuts the channel, because a `postMessage` sent to
17// `'*'` after the frame moved on would be delivered to whatever is there now. That rule is
18// right and is not what changed. What changed is that the app stopped moving the frame.
19//
20// It could not have healed itself either: the blob URL is revoked once the document is fetched,
21// so the reload had nothing to fetch. The frame was blank before the fallback replaced it.
22//
23// THE PROPERTIES:
24//
25// 1. A stage panel OPENING leaves the crystal's page up — and it is the SAME DOCUMENT, not a
26// fresh one. A mark set inside the frame survives. This is the check that matters: an app
27// that re-mounted the crystal after every panel change would look right in a screenshot
28// and would still throw away whatever a capp had on screen — the half-typed entry, the
29// lane you were reading, the scroll position.
30// 2. A stage panel CLOSING, the same. Both directions, because the owner named both.
31// 3. A DOCK panel opening and closing, the same. The crystal is in the stage, so a dock panel
32// had no business touching it — and it did, because the stage was re-seated on every pass.
33// 4. NO PANEL IS TAKEN OUT OF THE DOCUMENT AND PUT BACK because a DIFFERENT panel opened or
34// closed. The mechanism, stated where it can be measured. It covers what checks 1-3 cannot
35// see: the Web panel's browser and the Preview panel's `<embed>` were being restarted by
36// every panel change too, and no crystal check would ever have noticed.
37// 5. The layout is still right afterwards — the seats drawn in the asked-for order, left to
38// right, with a divider between each pair. Without this a "fix" that seated nothing at all
39// would pass everything above.
40//
41// The gestures are the user's: a chip in the panel row to open, the panel's own × to close.
42// Driving `DaimondPanels.show()` from the console would test the engine and not the app.
43//
44// EACH CHECK PROVED AGAINST BROKEN CODE FIRST:
45//
46// node dev/verify_crystalpanel.mjs --break moves # 1, 2, 3, 4: the stage re-seats by re-appending
47// node dev/verify_crystalpanel.mjs --break dockmoves # 4 alone: the dock does
48// node dev/verify_crystalpanel.mjs # and then, clean
49//
50// `dockmoves` earns its place by turning ONE check red and no other, which is the whole reason
51// check 4 is written in terms of the document and not in terms of the crystal: the dock holds
52// no crystal, so every other check here is blind to it. The breaks go on the APP, as
53// `verify_capp.mjs`'s do, and for the same reason — what is under test is what the app does to
54// a page it did not write.
55import fs from 'node:fs';
56import path from 'node:path';
57import { fileURLToPath } from 'node:url';
58import { open, signInAs, connectMock, errors } from './harness.mjs';
59
60const HERE = path.dirname(fileURLToPath(import.meta.url));
61const WWW = path.join(HERE, '..', 'www');
62
63const BREAK = (() => {
64 const i = process.argv.indexOf('--break');
65 return i > 0 ? String(process.argv[i + 1] || '') : '';
66})();
67
68const BREAKS = {
69 // The stage seating as it was: every seat and every divider appended in turn, wherever they
70 // already were. This is the shipped defect, byte for byte.
71 moves: {
72 file: 'js/daimond.js',
73 find: "\t\t\t\tplace(stageEl, seq);",
74 with: "\t\t\t\tseq.forEach(function (n) { stageEl.appendChild(n); });",
75 },
76 // The same for the dock's columns. It cannot touch the crystal, which is in the stage — so
77 // if check 4 did not exist, this break would change nothing at all.
78 dockmoves: {
79 file: 'js/daimond.js',
80 find: "\t\t\t\tcols.forEach(function (c, i) { place(c, colSeq[i]); });",
81 with: "\t\t\t\tcols.forEach(function (c, i) { colSeq[i].forEach(function (n) { c.appendChild(n); }); });",
82 },
83};
84
85const ok = [], bad = [];
86const check = (name, pass, detail) => {
87 (pass ? ok : bad).push(name);
88 console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : ''));
89};
90
91// A crystal with a key no page has ever heard of, because that is the case the coverage rule
92// exists for: the reducer is a fresh model rewriting the whole crystal from one sentence, so
93// `habits` is the expected shape of things and not a curiosity. The shipped page draws unknown
94// keys generically and names them all in `rendered`, so a run that falls back has fallen back
95// for a reason this file is about.
96const CRYSTAL = {
97 title: 'Panels',
98 summary: 'A crystal to watch while the stage changes around it.',
99 facts: [{ k: 'Seats', v: 'as many as the width carries' }],
100 open: ['Open a panel without losing this page'],
101 habits: { watch: 'what happens to the frame' },
102};
103
104const s = await open({ name: 'crystalpanel', signIn: false, connect: false });
105const { page } = s;
106
107if (BREAK) {
108 const spec = BREAKS[BREAK];
109 if (!spec) {
110 console.error('no such break: ' + BREAK + '\nhave: ' + Object.keys(BREAKS).join(' '));
111 process.exit(2);
112 }
113 const src = fs.readFileSync(path.join(WWW, spec.file), 'utf8');
114 const n = src.split(spec.find).length - 1;
115 if (n !== 1) {
116 console.error(`break '${BREAK}': the anchor appears ${n} times in ${spec.file}, `
117 + 'so nothing was broken and the run below would prove nothing.');
118 process.exit(2);
119 }
120 await page.route('**/' + spec.file, r => r.fulfill({
121 status: 200, contentType: 'application/javascript', body: src.replace(spec.find, spec.with),
122 }));
123 console.log(` (running with the app broken: ${BREAK})`);
124}
125
126/// What the channel says is on screen. `_state()` is the verifier's window on it and the app
127/// never uses it.
128const state = () => page.evaluate(() => {
129 const st = window.DaimondCrystal && window.DaimondCrystal._state && window.DaimondCrystal._state();
130 return st ? { mode: st.mode, reason: st.reason, keys: st.keys } : null;
131});
132
133/// The crystal's own frame. It is the only `blob:` frame on the page — the guide is a child of
134/// the main frame too, so a search by parentage finds THAT one and every measurement after it
135/// is of the wrong document.
136const crystalFrame = () => page.frames().find(fr => fr.url().indexOf('blob:') === 0) || null;
137
138/// Which panels are drawn in the stage, left to right, and what stands between them.
139const seating = () => page.evaluate(() => {
140 const out = [];
141 [].slice.call(document.getElementById('stage').children).forEach((el) => {
142 if (!el.getClientRects().length) return;
143 const r = el.getBoundingClientRect();
144 out.push({ id: el.dataset.panel || (el.className.indexOf('phandle') >= 0 ? '|' : '?'), x: Math.round(r.x) });
145 });
146 return out.sort((a, b) => a.x - b.x).map(p => p.id).join(' ');
147});
148
149/// Watch for a panel leaving the document. `childList` on the whole tree, because the two zones
150/// are different containers and a panel can be pulled out of either.
151const watch = () => page.evaluate(() => {
152 window.__pulled = [];
153 if (window.__obs) window.__obs.disconnect();
154 window.__obs = new MutationObserver((recs) => {
155 recs.forEach((r) => {
156 [].slice.call(r.removedNodes).forEach((n) => {
157 if (n.nodeType === 1 && n.dataset && n.dataset.panel) window.__pulled.push(n.dataset.panel);
158 });
159 });
160 });
161 window.__obs.observe(document.body, { childList: true, subtree: true });
162});
163const pulled = () => page.evaluate(() => (window.__pulled || []).slice());
164
165try {
166 await page.goto(process.env.DAIMOND_APP || 'http://localhost:8777', { waitUntil: 'domcontentloaded' });
167 await signInAs(s, 'crystalpanel');
168 await connectMock(s);
169 await page.waitForTimeout(1500);
170 // Wide enough for a THIRD seat. Below 2000 the stage seats two, so opening a panel would
171 // evict the one beside the conversation and the act under test would be an eviction rather
172 // than an arrival — which is a different thing and not the one the owner reported.
173 await page.setViewportSize({ width: 2000, height: 1000 });
174 await page.waitForTimeout(600);
175 const seatsMax = await page.evaluate(() => DaimondPanels.model().stageMax);
176 check('the stage has room for a third seat, so opening one evicts nothing',
177 seatsMax >= 3, String(seatsMax));
178
179 await page.click('#new-diamond-btn', { force: true });
180 await page.waitForSelector('.dlg-input', { timeout: 10000 });
181 await page.fill('.dlg-input', 'Panels');
182 await page.click('.dlg-ok', { force: true });
183 await page.waitForTimeout(1800);
184
185 const id = await page.evaluate(async () => {
186 const m = await import('/pkg/oxedyne_daimond.js');
187 const app = new m.DaimondApp('http://127.0.0.1/v1/chat/completions', '', 'none', 4096, '', true);
188 window.__free = app;
189 const d = JSON.parse(await app.list_diamonds()).find(x => x.name === 'Panels');
190 return d ? d.id : '';
191 });
192 check('a Diamond to watch', !!id, id);
193
194 // Only the memory is written. The page is the shipped one, which the app writes into the
195 // Diamond on the first render — so what is under test is what every Diamond starts with.
196 await page.evaluate(async (a) => {
197 await window.__free.run_tool('file_write', JSON.stringify({
198 path: 'diamonds/' + a.id + '/crystal.json', content: a.crystal }));
199 }, { id, crystal: JSON.stringify(CRYSTAL) });
200 await page.$$eval('.diamond-box', els => els[0] && els[0].click());
201 await page.waitForTimeout(2500);
202
203 const st0 = await state();
204 check('the crystal is drawn by its own page to start with',
205 !!st0 && st0.mode === 'frame', JSON.stringify(st0));
206 const missing = Object.keys(CRYSTAL).filter(k => !(st0 && (st0.keys || []).includes(k)));
207 check('and the page named every content key, so nothing below is a coverage failure',
208 missing.length === 0, 'missing: ' + (missing.join(', ') || 'none'));
209
210 // The mark. A DOM move destroys the frame's content window, and so does a re-mount by the
211 // app; both lose this, and nothing else does.
212 const mark = async () => {
213 const f = crystalFrame();
214 if (!f) return false;
215 return await f.evaluate(() => { window.__kept = 'kept'; return true; }).catch(() => false);
216 };
217 const kept = async () => {
218 const f = crystalFrame();
219 if (!f) return 'no frame';
220 return await f.evaluate(() => String(window.__kept || 'gone')).catch(e => 'unreachable');
221 };
222 check('a mark can be set inside the page', (await mark()) === true);
223 const seats0 = await seating();
224 check('and the stage starts with the conversation and the Web panel beside it',
225 seats0 === 'ai | web', '"' + seats0 + '"');
226
227 /// One act, and everything it must not have done.
228 ///
229 /// `gesture` is what a person does; `panel` is the panel it is done to, which is the ONE
230 /// node allowed to move — a panel being seated for the first time has to come from
231 /// wherever the markup left it.
232 const act = async (name, panel, gesture, expectSeats) => {
233 await watch();
234 await gesture();
235 await page.waitForTimeout(900);
236 const st = await state();
237 const note = await page.evaluate(() => !!document.querySelector('.crystal-fallback-note'));
238 check(name + ': THE CRYSTAL PAGE IS STILL UP',
239 !!st && st.mode === 'frame' && !note,
240 'mode:' + (st && st.mode) + ' reason:' + (st && st.reason) + ' fallback:' + note);
241 check(name + ': and it is the same document, not a fresh one',
242 (await kept()) === 'kept', await kept());
243 const moved = (await pulled()).filter(p => p !== panel);
244 check(name + ': and no other panel was pulled out of the document',
245 moved.length === 0, moved.join(', ') || 'none');
246 const seats = await seating();
247 check(name + ': and the stage is drawn as it was asked to be', seats === expectSeats,
248 '"' + seats + '" wanted "' + expectSeats + '"');
249 };
250
251 // ── 1 and 2. A stage panel, opened by its chip and closed by its ×.
252 //
253 // Tools rather than Web: Web is already open on a first boot, so its chip would be a
254 // no-op and the act would prove nothing.
255 const chip = (p) => page.click('.ptag[data-panel="' + p + '"]', { timeout: 5000 });
256 const closer = (p) => page.click('[data-close="' + p + '"]', { timeout: 5000 });
257 await act('opening a stage panel', 'tools', () => chip('tools'), 'ai | web | tools');
258 await act('closing a stage panel', 'tools', () => closer('tools'), 'ai | web');
259
260 // ── 3. A dock panel. The crystal is in the stage and this must not reach it at all — the
261 // stage's seating must come out of it completely unchanged.
262 await act('opening a dock panel', 'trash', () => chip('trash'), 'ai | web');
263 await act('closing a dock panel', 'trash', () => closer('trash'), 'ai | web');
264
265 // And the acts were real: a check that passes because nothing happened is the failure this
266 // project keeps paying for.
267 const flips = await page.evaluate(() => ({
268 tools: DaimondPanels.isOpen('tools'), trash: DaimondPanels.isOpen('trash'),
269 }));
270 check('the panels really opened and closed, so none of the above passed by standing still',
271 flips.tools === false && flips.trash === false, JSON.stringify(flips));
272
273 // The 502s are the account service on :9002, which a world does not run; they say nothing
274 // about the layout. Everything else counts.
275 const errs = errors(s).filter(e => !/502|Bad Gateway|account/i.test(e));
276 check('no unexpected console errors', errs.length === 0, errs.slice(0, 2).join(' | ') || 'clean');
277} catch (e) {
278 check('the run completed', false, String((e && e.message) || e));
279} finally {
280 await s.close?.().catch(() => {});
281}
282
283console.log(`\n${ok.length} passed, ${bad.length} failed`);
284if (BREAK) {
285 console.log(bad.length
286 ? `\nbreak '${BREAK}' produced failures, as it must.`
287 : `\nBREAK '${BREAK}' CHANGED NOTHING — the check it targets is not proving anything.`);
288}
289process.exit(bad.length ? 1 : 0);