Oregami
Repositories/oxedyne/daimond

oxedyne/daimond/dev/verify_foldall.mjs

15.4 KiB, 1 run

created by r2519314175:417, 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_foldall.mjs — the fold never fails in silence.
2//
3// The bug this exists for: a user selected every turn of a chat, pressed Fold,
4// picked a Diamond, and NOTHING happened. Not an error, not a spinner, not a
5// diff. Every exit from the fold path was either invisible (a 12px status line
6// on a panel the user may have left) or literally a bare `return`, and a throw
7// past the one try block left `crystalBusy` true for the rest of the session —
8// Steer and Propose dead, with no explanation.
9//
10// So each check here is one way out of the fold, and what it asserts is that the
11// user can SEE it happened. The one that matters most is the last shape of the
12// failure: an empty reply from the reducer used to render as a diff deleting
13// every line of the crystal, with Accept enabled — one click from wiping the
14// document the app exists to build.
15//
16// node dev/verify_foldall.mjs
17//
18// Needs dev/serve.mjs (DAIMOND_PORT, default 8777) and dev/mockllm.mjs
19// (DAIMOND_MOCK_PORT, default 9099). No gateway.
20
21import { open, chat, shot, errors } from './harness.mjs';
22
23const ok = [], bad = [];
24const check = (name, pass, detail) => {
25 (pass ? ok : bad).push(name + (detail ? ' — ' + detail : ''));
26 console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : ''));
27};
28const sleep = ms => new Promise(r => setTimeout(r, ms));
29
30const DIAMOND = 'Fold Target';
31const s = await open({ name: 'foldall' });
32const p = s.page;
33
34// Toasts are the app's own floating status line; `.err` marks a failure. They
35// remove themselves after ~4s, which is shorter than some of the waits here, so
36// they are RECORDED as they appear rather than looked for afterwards — a check
37// that polls for a live toast passes or fails on the sleep it happened to use.
38await p.evaluate(() => {
39 window.__toasts = [];
40 new MutationObserver(muts => {
41 for (const m of muts) for (const n of m.addedNodes) {
42 if (n.nodeType === 1 && n.classList && n.classList.contains('daimond-toast')) {
43 window.__toasts.push({ text: n.textContent, err: n.classList.contains('err') });
44 }
45 }
46 }).observe(document.body, { childList: true });
47});
48const toasts = () => p.evaluate(() => window.__toasts.slice());
49const clearToasts = () => p.evaluate(() => {
50 window.__toasts = [];
51 document.querySelectorAll('.daimond-toast').forEach(e => e.remove());
52});
53// The Diamond's own crystal surface: is a diff on show, and can it be applied?
54const diffState = () => p.evaluate(() => {
55 const lines = document.querySelectorAll('.diff-lines .diff-line');
56 const acc = document.querySelector('.diff-accept');
57 let add = 0, del = 0;
58 lines.forEach(l => { if (l.classList.contains('add')) add++; if (l.classList.contains('del')) del++; });
59 return {
60 lines: lines.length, add, del,
61 accept: !!acc, acceptEnabled: !!acc && !acc.disabled,
62 // The crystal's own delta box and Propose button are gone -- a Diamond
63 // has one composer, in the chat face -- so what "not stuck" means now is
64 // that the SELECTION path can be used again: `crystalBusy` released, and
65 // Fold selected offered.
66 foldSel: !!document.getElementById('sel-fold'),
67 foldSelDisabled: !!(document.getElementById('sel-fold') || {}).disabled,
68 };
69});
70/// How much of the user's own text a Diamond's crystal holds, read from
71/// `crystal.json` through the store.
72///
73/// IT USED TO MEASURE THE PANEL, and the panel no longer holds it: a crystal with
74/// anything in it is drawn by that Diamond's own page inside a sandboxed frame,
75/// so `.crystal-body` has no text of its own for `textContent` to count. That
76/// probe would not have failed -- it would have returned 0 for every Diamond and
77/// turned two preconditions into checks that can never bite, which is the quieter
78/// and worse outcome. Both uses below are preconditions of the form "there is
79/// something here worth deleting", so what they want is the words, not the
80/// markup: the JSON's own braces and key names are not the user's, and are not
81/// counted.
82///
83/// Returns -1 for a Diamond that is not there and -2 for one whose crystal is not
84/// JSON, so a broken store reads differently from an empty one.
85const crystalWords = (name) => p.evaluate(async (name) => {
86 const m = await import('/pkg/oxedyne_daimond.js');
87 const app = new m.DaimondApp('http://127.0.0.1/v1/chat/completions', '', 'none', 4096, '', true);
88 const row = JSON.parse(await app.list_diamonds()).find(r => r.name === name);
89 if (!row) return -1;
90 const text = await app.read_crystal_data(row.id);
91 if (!String(text).trim()) return 0;
92 let data;
93 try { data = JSON.parse(text); } catch (e) { return -2; }
94 let n = 0;
95 (function walk(v) {
96 if (typeof v === 'string') n += v.length;
97 else if (Array.isArray(v)) v.forEach(walk);
98 else if (v && typeof v === 'object') Object.keys(v).forEach(k => walk(v[k]));
99 })(data);
100 return n;
101}, name);
102const dialogText = () => p.evaluate(() => {
103 const c = document.querySelector('.dlg-card');
104 return c ? c.textContent : '';
105});
106const dismissDialog = async () => {
107 if (await p.$('.dlg-ok')) { await p.click('.dlg-ok', { force: true }); await sleep(300); }
108};
109
110// ── Seed: one Diamond and a chat of four turns ───────────────────────
111await p.click('#new-diamond-btn', { force: true });
112await p.waitForSelector('.dlg-input', { timeout: 10000 });
113await p.fill('.dlg-input', DIAMOND);
114await p.click('.dlg-ok', { force: true });
115await sleep(1000);
116for (const m of ['MARK-ONE', 'MARK-TWO', 'MARK-THREE', 'MARK-FOUR']) {
117 await chat(s, `@text ${m} and something worth keeping about it.`);
118}
119
120/// Select every turn and fold the lot into the Diamond. Returns once the picker
121/// item has been clicked; the caller decides how long to wait for the reducer.
122async function foldAll({ pickName = DIAMOND } = {}) {
123 // Back into the chat, in case a previous check left the centre on a Diamond.
124 await p.evaluate(() => {
125 const box = document.querySelector('.session-box:not(.diamond-box)');
126 if (box) box.click();
127 });
128 await sleep(600);
129 await p.evaluate(() => {
130 const c = document.getElementById('collapse-btn');
131 if (c && !c.classList.contains('on')) c.click();
132 });
133 await sleep(400);
134 await p.click('#sel-all', { force: true });
135 await sleep(200);
136 await p.click('#sel-fold', { force: true });
137 await p.waitForSelector('.fold-menu', { timeout: 8000 });
138 await p.evaluate((name) => {
139 const item = [...document.querySelectorAll('.fold-menu-item')]
140 .find(b => b.textContent.trim() === name);
141 if (item) item.click();
142 }, pickName);
143}
144
145/// Wait until the fold has visibly resolved one way or the other.
146async function settled(timeout = 20000) {
147 const t0 = Date.now();
148 while (Date.now() - t0 < timeout) {
149 const d = await diffState();
150 if (d.lines > 0) return 'diff';
151 if ((await toasts()).length) return 'toast';
152 if (await p.$('.dlg-card')) return 'dialog';
153 await sleep(250);
154 }
155 return 'nothing';
156}
157
158// ── 1. Folding the whole chat says something within ten seconds ──────
159await clearToasts();
160await foldAll();
161const outcome1 = await settled(20000);
162const d1 = await diffState();
163check('folding every turn produces a visible result, not silence',
164 outcome1 !== 'nothing', `outcome=${outcome1}`);
165check('and that result is a diff with real changes in it',
166 d1.lines > 0 && (d1.add + d1.del) > 0, `${d1.lines} lines, +${d1.add}/-${d1.del}`);
167await shot(s, 'foldall-diff');
168
169// Accept it, so the crystal has content for the empty-reply check below to risk.
170if (d1.acceptEnabled) { await p.click('.diff-accept', { force: true }); await sleep(3500); }
171const crystalLen = await crystalWords(DIAMOND);
172check('the accepted fold left a crystal with something in it (so the next check can bite)',
173 crystalLen > 20, `${crystalLen} chars`);
174
175// ── 2. A provider failure is shown, and does not wedge the crystal ───
176await clearToasts();
177await p.route('**/v1/chat/completions', route => route.fulfill({
178 status: 400,
179 contentType: 'application/json',
180 body: JSON.stringify({ error: { message: 'routed: refused' } }),
181}));
182await foldAll();
183await settled(20000);
184const t2 = await toasts();
185check('a reducer that fails says so, out loud',
186 t2.some(x => x.err), JSON.stringify(t2.slice(0, 2)));
187await p.unroute('**/v1/chat/completions');
188// Back on the Diamond: the controls must be live again. A sticky crystalBusy
189// leaves Propose disabled for the rest of the session.
190await p.evaluate((name) => {
191 const box = [...document.querySelectorAll('.diamond-box')]
192 .find(b => (b.querySelector('.session-box-name') || {}).textContent === name);
193 if (box) box.click();
194}, DIAMOND);
195await sleep(1200);
196const d2 = await diffState();
197check('and the crystal is not left disabled by the failure',
198 d2.foldSel && !d2.foldSelDisabled, `fold-selected present=${d2.foldSel} disabled=${d2.foldSelDisabled}`);
199
200// ── 3. A Diamond deleted under the picker is refused loudly ──────────
201// The realistic case: another tab deletes it while the picker is open. The rail
202// re-reads on the storage event, so the id the menu item holds is stale.
203await clearToasts();
204await p.evaluate(() => {
205 const box = document.querySelector('.session-box:not(.diamond-box)');
206 if (box) box.click();
207});
208await sleep(600);
209await p.evaluate(() => {
210 const c = document.getElementById('collapse-btn');
211 if (c && !c.classList.contains('on')) c.click();
212});
213await sleep(400);
214await p.click('#sel-all', { force: true });
215await p.click('#sel-fold', { force: true });
216await p.waitForSelector('.fold-menu', { timeout: 8000 });
217// Delete it for real, then tell this tab the way another tab would.
218await p.evaluate(async (name) => {
219 const m = await import('/pkg/oxedyne_daimond.js');
220 const app = new m.DaimondApp('http://127.0.0.1/v1/chat/completions', '', 'none', 4096, '', true);
221 const row = JSON.parse(await app.list_diamonds()).find(r => r.name === name);
222 if (row) await app.delete_diamond(row.id);
223 window.dispatchEvent(new StorageEvent('storage', { key: 'daimond-diamonds-rev', newValue: String(Date.now()) }));
224}, DIAMOND);
225await sleep(1500);
226await p.evaluate((name) => {
227 const item = [...document.querySelectorAll('.fold-menu-item')]
228 .find(b => b.textContent.trim() === name);
229 if (item) item.click();
230}, DIAMOND);
231await sleep(1200);
232const dlg3 = await dialogText();
233check('folding into a Diamond that has gone says so, rather than doing nothing',
234 /gone/i.test(dlg3), dlg3 ? dlg3.slice(0, 90) : '(no dialog at all)');
235await dismissDialog();
236
237// ── 4. An empty reply is a failure, never a deletion diff ────────────
238// A second Diamond, folded once so its crystal is not empty, then the reducer is
239// made to answer with nothing at all.
240await p.click('#new-diamond-btn', { force: true });
241await p.waitForSelector('.dlg-input', { timeout: 10000 });
242await p.fill('.dlg-input', 'Empty Reply');
243await p.click('.dlg-ok', { force: true });
244await sleep(1000);
245await clearToasts();
246await foldAll({ pickName: 'Empty Reply' });
247await settled(20000);
248const dA = await diffState();
249if (dA.acceptEnabled) { await p.click('.diff-accept', { force: true }); await sleep(3500); }
250// The precondition, asserted rather than assumed: an empty reply against an EMPTY
251// crystal diffs to "no change" and Accept is disabled anyway, so the check below
252// would pass without proving anything.
253const emptyBase = await crystalWords('Empty Reply');
254check('the second Diamond has a crystal worth deleting (the precondition of the next check)',
255 emptyBase > 20, `${emptyBase} chars`);
256
257// Now the empty stream: a well-formed SSE turn that says nothing.
258const EMPTY_SSE = [
259 'data: {"id":"x","object":"chat.completion.chunk","model":"mock/fast","choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":null}]}',
260 '',
261 'data: {"id":"x","object":"chat.completion.chunk","model":"mock/fast","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}',
262 '',
263 'data: {"id":"x","object":"chat.completion.chunk","model":"mock/fast","choices":[],"usage":{"prompt_tokens":10,"completion_tokens":0,"total_tokens":10}}',
264 '',
265 'data: [DONE]',
266 '',
267].join('\n');
268await clearToasts();
269await p.route('**/v1/chat/completions', route => route.fulfill({
270 status: 200,
271 headers: { 'content-type': 'text/event-stream', 'cache-control': 'no-cache' },
272 body: EMPTY_SSE,
273}));
274await foldAll({ pickName: 'Empty Reply' });
275await sleep(9000);
276const t4 = await toasts();
277const d4 = await diffState();
278await p.unroute('**/v1/chat/completions');
279check('an empty reducer reply is reported as a failure',
280 t4.some(x => x.err), JSON.stringify(t4.slice(0, 2)));
281check('and is NEVER offered as a diff that deletes the crystal',
282 !(d4.acceptEnabled && d4.del > 0), `accept=${d4.acceptEnabled} deletions=${d4.del}`);
283await shot(s, 'foldall-empty-reply');
284// Nothing was left pending on that Diamond, so no rail row claims one is waiting.
285const pendingAfterEmpty = await p.$$eval('.diamond-box', els => els.filter(e =>
286 e.querySelector('.diamond-pending')).map(e => (e.querySelector('.session-box-name') || {}).textContent));
287check('and no proposal is left pending from it',
288 pendingAfterEmpty.length === 0, JSON.stringify(pendingAfterEmpty));
289
290// ── 5. Leaving mid-propose leaves a mark on the rail row ─────────────
291// The reducer is slow on purpose; the user goes back to the chat while it runs.
292await clearToasts();
293await p.route('**/v1/chat/completions', async route => {
294 await sleep(6000);
295 await route.continue();
296});
297await foldAll({ pickName: 'Empty Reply' });
298await sleep(1500);
299await p.evaluate(() => {
300 const box = document.querySelector('.session-box:not(.diamond-box)');
301 if (box) box.click(); // away from the Diamond, mid-propose
302});
303await sleep(14000);
304await p.unroute('**/v1/chat/completions');
305const pending = await p.$$eval('.diamond-box', els => els.map(e => ({
306 name: (e.querySelector('.session-box-name') || {}).textContent,
307 pending: !!e.querySelector('.diamond-pending'),
308})));
309check('a proposal that landed while you were elsewhere is marked on its rail row',
310 pending.some(x => x.pending), JSON.stringify(pending));
311const t5 = await toasts();
312check('and the toast names the Diamond it is waiting on',
313 t5.some(x => /Empty Reply/.test(x.text)), JSON.stringify(t5.slice(0, 2)));
314await shot(s, 'foldall-pending-badge');
315
316// ── 6. The floor under the whole class ───────────────────────────────
317// Most of this app is started rather than awaited: a click hands a promise to
318// nobody. Each of the folds above is one such promise, and the fix for each was
319// local — but a rejection escaping ANY of them must reach the user, or the next
320// one written without a .catch is the same silence again.
321await clearToasts();
322await p.evaluate(() => { Promise.reject(new Error('probe: an escaped rejection')); });
323await sleep(600);
324const t6 = await toasts();
325check('a promise rejection nobody handled still reaches the user',
326 t6.some(x => x.err), JSON.stringify(t6.slice(0, 2)));
327
328// The gateway is not running for this flow, so its 502s are the absence of a
329// server. The routed 400 is this test's own doing.
330// The probe rejection above is this test's own doing and is reported to the
331// console as a pageerror by design: the app's handler tells the USER, it does not
332// swallow the developer's copy.
333const errs = errors(s).filter(e => !/favicon|404|401|402|502|Bad Gateway|net::ERR|400|probe: an escaped rejection/.test(e));
334console.log('\nconsole errors:', errs.slice(0, 5));
335check('nothing throws into the console while all this happens', errs.length === 0, errs[0] || '');
336
337await s.close();
338console.log(`\n${ok.length} passed, ${bad.length} failed`);
339if (bad.length) console.log('FAILED:\n ' + bad.join('\n '));
340process.exit(bad.length ? 1 : 0);