Oregami
Repositories/oxedyne/daimond

oxedyne/daimond/dev/verify_steerqueue.mjs

8.4 KiB, 1 run

created by r2519314175:699, 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_steerqueue.mjs — a steer typed while the daimon is working is kept.
2//
3// ── WHY ──────────────────────────────────────────────────────────────────────
4//
5// `doSteer` opened with `if (crystalBusy || !currentDiamond) return;`. So a Send
6// pressed while a turn was in flight did NOTHING: no queue, no message, no
7// sentence saying why, and the text left sitting in the box. An ordinary chat in
8// exactly that state queues what you typed and draws it (`enqueueMessage`), and
9// a Diamond's own queue already existed — `drainSteerQueue` empties it at the end
10// of every turn and `resumeSteerQueue` when you come back to the Diamond. This
11// was the one door that never put anything into it.
12//
13// It is worse than it first reads, because `crystalBusy` is ONE FLAG FOR THE
14// WHOLE APP. The turn in flight may belong to a different Diamond entirely, so a
15// Send could do nothing on a Diamond that was not itself doing anything, with
16// nothing on that screen to explain it.
17//
18// ── THE PROPERTY ─────────────────────────────────────────────────────────────
19//
20// Nothing the user types is lost. Not "the queue exists" — that it CARRIES what
21// was typed, and that the box is cleared so the app has visibly taken it. The
22// second half matters: a queue that kept the text in the box as well would leave
23// the user unable to tell whether it had been taken, and pressing Send again is
24// how you get it twice.
25//
26// A PRESET IS NOT QUEUED, and that is asserted too. A gather round, a trigger and
27// the conductor all steer with a preset and each has its own way back; queueing
28// one would deliver a stale report at an arbitrary later turn. So the fix must
29// keep the old silent return for exactly that case, and a check that only proved
30// "typed text is queued" would let a later simplification break it.
31//
32// node dev/verify_steerqueue.mjs
33// node dev/verify_steerqueue.mjs --break swallow # the old silent return
34//
35// A `--break` run EXPECTS to fail: exit 0 when something reddened, 1 when
36// nothing did.
37//
38// Needs dev/serve.mjs (DAIMOND_PORT, default 8777) and dev/mockllm.mjs
39// (DAIMOND_MOCK_PORT, default 9099). No gateway.
40
41import fs from 'node:fs';
42import path from 'node:path';
43import { fileURLToPath } from 'node:url';
44import { open, steerDiamond, 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
56const BREAK = (() => { const i = process.argv.indexOf('--break'); return i > 0 ? process.argv[i + 1] : ''; })();
57const BREAKS = {
58 // The defect, restored exactly: busy means do nothing at all.
59 swallow: [{
60 file: 'js/daimond.js',
61 find: '\t\tif (diamondBusy(currentDiamond.id)) {',
62 with: '\t\tif (diamondBusy(currentDiamond.id)) { return; } if (false) {',
63 }],
64 // THERE IS NO `queueall` BREAK, AND THAT IS A FINDING RATHER THAN A GAP.
65 //
66 // Check 3 asserts a preset is not queued, and no break makes it go red. Two
67 // were tried. Deleting the preset guard changes nothing, because `!typed`
68 // already turns every preset away — a preset call carries no box text.
69 // Rewriting the enqueue as `typed || presetArg` changes nothing either, and
70 // that one was measured: the door is reachable and the turn IS in flight when
71 // it is taken (check 3a proves both), and the preset still never lands.
72 //
73 // So the honest reading is that the guard is defence in depth over a state
74 // that cannot currently be constructed, and check 3 is a REGRESSION guard
75 // rather than a proved one. It is kept, and it is labelled, because the state
76 // becomes constructible the moment anything queues something other than what
77 // the box holds. Writing a break that reddened it would have meant damaging
78 // the check instead of the code, which is the shape this suite exists to
79 // refuse.
80};
81
82function damagedFiles() {
83 const byFile = new Map();
84 for (const spec of (BREAKS[BREAK] || [])) {
85 const src = fs.readFileSync(path.join(WWW, spec.file), 'utf8');
86 if (!src.includes(spec.find)) {
87 console.error(`--break ${BREAK}: anchor not found in ${spec.file}. The break is stale.`);
88 process.exit(1);
89 }
90 byFile.set(spec.file, src.replace(spec.find, spec.with));
91 }
92 return byFile;
93}
94
95async function serveBreaks(page) {
96 if (!BREAK) return;
97 for (const [file, body] of damagedFiles()) {
98 await page.route('**/' + file, r => r.fulfill({
99 status: 200, contentType: 'application/javascript', body,
100 }));
101 }
102}
103
104const s = await open({ name: 'steerqueue', route: serveBreaks });
105const p = s.page;
106
107// Open a Diamond and put it on the chat face, which is where the composer is.
108const picked = await p.evaluate(() => {
109 const el = document.querySelector('#diamond-list [data-id]');
110 if (!el) return '';
111 el.click();
112 return el.dataset.id || '';
113});
114check('0 a Diamond is open', !!picked, picked);
115await sleep(800);
116
117// `@slow` holds the reply for two seconds, which is the turn-in-flight window
118// this whole file needs. Racing a fast mock would make the check pass or fail on
119// how loaded the machine is, which is not a property of the app.
120await steerDiamond(s, '@slow 4000 first');
121await sleep(700);
122
123const busy = await p.evaluate(() => {
124 const b = document.getElementById('chat-fold-btn');
125 return { foldDisabled: !!(b && b.disabled) };
126});
127check('1 a turn really is in flight', busy.foldDisabled === true,
128 'the Fold button answers `crystalBusy`, so its disabled state is the flag');
129
130// ── The typed steer, arriving while that turn runs ───────────────────────────
131const TYPED = 'kept-' + Math.random().toString(36).slice(2, 9);
132await p.fill('#chat-input', TYPED);
133await p.click('#chat-send', { force: true });
134await sleep(600);
135
136const after = await p.evaluate(() => ({
137 box: (document.getElementById('chat-input') || {}).value || '',
138 queued: document.body.innerText,
139}));
140check('2a WHAT WAS TYPED IS KEPT, not swallowed', after.queued.includes(TYPED),
141 after.queued.includes(TYPED) ? '' : 'nothing on screen carries it');
142check('2b and the box is cleared, so the app has visibly taken it',
143 after.box.trim() === '', JSON.stringify(after.box.slice(0, 40)));
144await shot(s, 'steerqueue-held');
145
146// ── A preset must NOT be queued ──────────────────────────────────────────────
147//
148// Driven through `doSteer` itself with a preset, which is how a gather round, a
149// trigger and the conductor all reach it. The queue must not grow.
150const PRESET = 'preset-' + Math.random().toString(36).slice(2, 9);
151const drove = await p.evaluate((t) => {
152 const has = !!(window.DaimondCore && typeof window.DaimondCore.steer === 'function');
153 const busy = !!(document.getElementById('chat-fold-btn') || {}).disabled;
154 if (has) window.DaimondCore.steer(t);
155 return { has, busy };
156}, PRESET);
157check('3a the preset door exists and a turn is still in flight when it is taken',
158 drove.has === true && drove.busy === true, JSON.stringify(drove));
159await sleep(600);
160const presetSeen = await p.evaluate((t) => document.body.innerText.includes(t), PRESET);
161// Labelled UNPROVED because no break reddens it — see the note beside BREAKS.
162// A check nobody can make fail is worth keeping and is not worth trusting, and
163// the name is where a reader finds that out.
164check('3 a preset is not queued (regression guard, unproved — see BREAKS)',
165 presetSeen === false, presetSeen ? 'the preset reached the queue' : '');
166
167const errs = errors(s).filter(e => !/502|401|Account service|favicon/.test(e));
168check('4 nothing was raised in the console', errs.length === 0, errs.slice(0, 2).join(' | '));
169
170await s.close();
171
172console.log(`\n${ok.length} passed, ${bad.length} failed`);
173if (BREAK) {
174 console.log(bad.length ? `--break ${BREAK}: reddened ${bad.length} check(s), as it must`
175 : `--break ${BREAK}: CHANGED NOTHING — the check it names is not testing what it says`);
176 process.exit(bad.length ? 0 : 1);
177}
178process.exit(bad.length ? 1 : 0);