Oregami
Repositories/oxedyne/daimond

oxedyne/daimond/dev/verify_composemerge.mjs

16.3 KiB, 9 runs

created by r2519314175:1506, 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_composemerge.mjs — note-capture merged into the Proposals view.
2//
3// The standalone Notes view is gone. A note is written in a compose box at the
4// top of Proposals and posted straight as a proposal, in one of two modes that
5// both auto-send:
6//
7// POST VERBATIM — the words become the proposal at once (the direct path).
8// POLISH & POST — the model rewrites the note into a proposal, then that is
9// posted (the triage draft path).
10//
11// A note that cannot be sent yet -- written offline, or a send that failed --
12// waits in a small queue under the box, REMEMBERING its mode, and is drained
13// automatically when the browser comes back online.
14//
15// What this proves, the four the owner asked for:
16// (a) verbatim online → an immediate proposal, in the list, note gone;
17// (b) polish online → the DRAFTED proposal posted and in the list;
18// (c) offline submit → queued, with its mode, nothing on the wire;
19// (d) reconnect → the flush drains the queue, each note in its own mode.
20//
21// The model is not run for real: `DaimondTriage.polish` is overridden in the page
22// to return a fixed draft, so what is proved is improve.js's WIRING -- that a
23// polished note posts what the drafting returned, through the same door a verbatim
24// note leaves by. `dev/verify_triage.mjs` proves the real model run.
25//
26// eval "$(bash dev/world.sh 8 --env)"
27// node dev/verify_composemerge.mjs
28// node dev/verify_composemerge.mjs --break noflush
29
30import fs from 'node:fs';
31import path from 'node:path';
32import { fileURLToPath } from 'node:url';
33import { open, shot, scratch, errors, signInAs } from './harness.mjs';
34
35const HERE = path.dirname(fileURLToPath(import.meta.url));
36const WWW = path.join(HERE, '..', 'www');
37
38const BREAK = (() => {
39 const i = process.argv.indexOf('--break');
40 return i > 0 ? String(process.argv[i + 1] || '') : '';
41})();
42
43const PROFILE = scratch('pw', 'composemerge' + (BREAK ? '-' + BREAK : ''));
44fs.rmSync(PROFILE, { recursive: true, force: true });
45
46const ok = [], bad = [];
47const check = (name, pass, detail) => {
48 (pass ? ok : bad).push(name);
49 console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : ''));
50};
51
52// ── Seams: the merged surface must be wired, or the run proves nothing.
53
54const SEAM = [
55 { file: 'index.html', want: 'data-act="improve-post"',
56 why: 'the compose box has no Post action' },
57 { file: 'index.html', want: 'data-act="improve-polish"',
58 why: 'the compose box has no Polish action' },
59 { file: 'js/improve.js', want: 'flushQueue: flushQueue',
60 why: 'the queue cannot be drained on reconnect' },
61 { file: 'js/daimond.js', want: 'DaimondImprove.flushQueue()',
62 why: 'the online event never drains the queue' },
63 { file: 'index.html', want: 'id="improve-raised"',
64 why: 'the compose box has no "Raised — see it in Improve" confirmation host' },
65 { file: 'js/improve.js', want: "act === 'improve-open-hub'",
66 why: 'the Raised confirmation cannot open the Improve hub' },
67];
68
69function requireSeams() {
70 const missing = [];
71 for (const s of SEAM) {
72 const src = FILES.get(s.file) ?? fs.readFileSync(path.join(WWW, s.file), 'utf8');
73 if (!src.includes(s.want)) missing.push(` ${s.file}: ${s.why}`);
74 }
75 if (missing.length) {
76 console.error('the merged compose surface is not wired, so this run would prove nothing:');
77 for (const b of missing) console.error(b);
78 process.exit(2);
79 }
80}
81
82// ── The break: the reconnect flush is severed, so an offline note never sends.
83
84const BREAKS = {
85 // The reconnect flush is severed: it iterates an empty snapshot, so a queued
86 // note is never drained when the browser comes back. (a)-(c) stay green -- the
87 // immediate sends and the offline queueing are untouched -- and only (d) reddens.
88 noflush: [{
89 file: 'js/improve.js',
90 find: '\t\t\tvar q = load().notes.slice(); // a snapshot of ids; the list changes under us',
91 with: '\t\t\tvar q = []; // snapshot severed by the break',
92 }],
93};
94
95if (BREAK && !BREAKS[BREAK]) {
96 console.error(`unknown break '${BREAK}'; one of: ${Object.keys(BREAKS).join(', ')}`);
97 process.exit(2);
98}
99
100function edit(src, spec, what) {
101 const n = src.split(spec.find).length - 1;
102 if (n !== 1) {
103 console.error(`${what}: the anchor appears ${n} times in ${spec.file}, `
104 + 'so nothing was changed and the run below would prove nothing.');
105 process.exit(2);
106 }
107 return src.replace(spec.find, spec.with);
108}
109
110const FILES = new Map();
111function build() {
112 if (BREAK) {
113 for (const spec of BREAKS[BREAK]) {
114 const p = spec.file;
115 const src = FILES.get(p) ?? fs.readFileSync(path.join(WWW, p), 'utf8');
116 FILES.set(p, edit(src, spec, `break '${BREAK}'`));
117 }
118 }
119 requireSeams();
120}
121build();
122
123// ── The forge, answered locally. GET reads an (empty) listing; a POST that opens
124// a proposal is given the next number, from 100 up.
125
126const HDR = 'x-daimond-voice';
127// allowlist secret
128const SECRET = 'mock-voice-ada-0000000000000';
129
130let nextNew = 100;
131const asked = [];
132
133async function improveRoute(r) {
134 const req = r.request();
135 const u = new URL(req.url());
136 const q = u.searchParams;
137 const method = req.method();
138 const body = req.postData() || '';
139 const headers = req.headers();
140 asked.push({ method, body, query: Object.fromEntries(q) });
141
142 const json = (obj) => r.fulfill({ status: 200, contentType: 'application/json',
143 body: typeof obj === 'string' ? obj : JSON.stringify(obj) });
144 const refuse = (status, error) => r.fulfill({ status, contentType: 'application/json',
145 body: JSON.stringify({ error, said: 'The forge refused: ' + error + '.' }) });
146
147 if (method === 'GET') {
148 if (q.get('n') !== null) {
149 const n = Number(q.get('n'));
150 return json({ number: n, title: 'Proposal ' + n, body: 'b', state: 'open', author: 'ada',
151 comments: 0, opened: 1, changed: 2, discussion: [], votes: { for: 0, against: 0 },
152 mark: null, build: null, revisions: [] });
153 }
154 return json({ proposals: [], total: 0, done: true });
155 }
156 if (!headers[HDR]) return refuse(401, 'unvoiced');
157
158 const n = q.get('n');
159 const num = n !== null ? Number(n) : nextNew++;
160 return json({ number: num, title: 'Proposal ' + num, body: 'b', state: 'open', author: 'ada',
161 comments: 0, opened: 1, changed: 2, discussion: [], votes: { for: 0, against: 0 },
162 mark: null, build: null, revisions: [] });
163}
164
165const j = (body, status = 200) => ({ status, contentType: 'application/json', body: JSON.stringify(body) });
166
167async function stub(page) {
168 for (const [p, body] of FILES) {
169 const type = p.endsWith('.html') ? 'text/html' : 'application/javascript';
170 await page.route('**/' + p, r => r.fulfill({ status: 200, contentType: type, body }));
171 }
172 if (FILES.has('index.html')) {
173 await page.route(u => u.pathname === '/' || u.pathname === '/index.html',
174 r => r.fulfill({ status: 200, contentType: 'text/html', body: FILES.get('index.html') }));
175 }
176 await page.route(u => u.pathname === '/api/improve', improveRoute);
177 await page.route('**/api/telemetry', r => r.fulfill(j({ ok: true })));
178 await page.route('**/api/account', r => r.fulfill(j({ ok: true })));
179 await page.route('**/api/auth/challenge', r => r.fulfill(j({ ok: true, challenge: 'chal-cm', challenge_id: 'cid-1' })));
180 await page.route('**/api/auth/verify', r => r.fulfill(j({ ok: true })));
181 await page.route('**/api/balance', r => r.fulfill(j({ ok: true, credits_minor: 0, currency: 'usd', entries: [] })));
182 await page.route('**/api/licence', r => r.fulfill(j({ ok: true, licence: false, currency: 'usd' })));
183}
184
185const opens = () => asked.filter(a => a.method === 'POST' && a.query.n === undefined);
186const fields = (raw) => { const o = {}; for (const [k, v] of new URLSearchParams(raw)) o[k] = v; return o; };
187
188const s = await open({ name: 'composemerge', profile: PROFILE, signIn: false, connect: false, route: stub });
189const { page } = s;
190
191await signInAs(s, 'composemerge');
192await page.waitForTimeout(1000);
193
194// A page helper: force the browser's online state, and (optionally) fire the event.
195async function setOnline(on, fire) {
196 await page.evaluate(({ on, fire }) => {
197 Object.defineProperty(navigator, 'onLine', { configurable: true, get: () => on });
198 if (fire) window.dispatchEvent(new Event(on ? 'online' : 'offline'));
199 }, { on, fire });
200}
201
202// The polish path, without a real model: a fixed draft, so the WIRING is proved.
203async function stubPolish() {
204 await page.evaluate(() => {
205 window.DaimondTriage = window.DaimondTriage || {};
206 window.DaimondTriage.polish = async function (text) {
207 return { title: 'Polished: ' + String(text || '').split('\n')[0].slice(0, 40),
208 body: 'A tidier body the model wrote from the note.' };
209 };
210 });
211}
212
213async function typeAndClick(text, act) {
214 await page.evaluate((t) => {
215 const box = document.getElementById('improve-box');
216 box.value = t;
217 box.dispatchEvent(new Event('input', { bubbles: true }));
218 }, text);
219 await page.waitForTimeout(80);
220 await page.click('#panel-social [data-act="' + act + '"]');
221}
222
223try {
224 await page.evaluate(() => { window.DaimondPanels.show('social'); });
225 await page.waitForTimeout(300);
226 await page.evaluate(() => { if (window.DaimondImprove) window.DaimondImprove.onOpen(); });
227 await page.evaluate(() => window.DaimondImprove.load(false));
228 await page.waitForTimeout(400);
229
230 // A voice, set through the Settings view where it now lives.
231 await page.evaluate(() => window.DaimondSocial.show('settings'));
232 await page.waitForTimeout(200);
233 await page.click('#panel-social [data-act="improve-voice-open"]');
234 await page.waitForTimeout(150);
235 await page.fill('#improve-voice-in', SECRET);
236 await page.click('#panel-social [data-act="improve-voice-save"]');
237 await page.waitForTimeout(500);
238 check('a voice is held (set from Settings), so the box can post',
239 await page.evaluate(() => window.DaimondVoice.has()) === true);
240 await page.evaluate(() => window.DaimondSocial.show('proposals'));
241 await page.waitForTimeout(200);
242
243 await stubPolish();
244 await setOnline(true, false);
245
246 // ── (a) Verbatim online → an immediate proposal, note gone ──
247 let before = opens().length;
248 await typeAndClick('Reload empties the note box\nIt should keep what I typed.', 'improve-post');
249 for (let i = 0; i < 40 && await page.evaluate(() => window.DaimondImprove.notes().length > 0); i++) {
250 await page.waitForTimeout(150);
251 }
252 await page.waitForTimeout(300);
253 const aPost = opens().length - before === 1 ? fields(opens()[opens().length - 1].body) : {};
254 check('(a) verbatim online posted exactly one proposal', opens().length - before === 1,
255 `${opens().length - before} posts`);
256 check('(a) it carried the words verbatim, as title + body',
257 aPost.title === 'Reload empties the note box' && /keep what I typed/.test(aPost.body || ''),
258 JSON.stringify(aPost).slice(0, 140));
259 check('(a) the note is gone from the queue: it became a proposal',
260 await page.evaluate(() => window.DaimondImprove.notes().length) === 0);
261 check('(a) and the proposal appears in the store',
262 await page.evaluate(() => window.DaimondImprove.forge.props().some(p => p.n === 100)));
263
264 // ── Capture-only: the browse list is gone, a confirmation points at the hub ──
265 // The Social ▸ Proposals surface writes a proposal; the browse/vote list of all
266 // proposals is the Improve hub (js/tracker.js) now, not here, so the two surfaces
267 // no longer duplicate a list.
268 check('the browse list of all proposals is NOT on the capture surface',
269 await page.locator('#panel-social #improve-props').count() === 0,
270 `${await page.locator('#panel-social #improve-props').count()} list host(s)`);
271 check('after a raise, the "Raised — see it in Improve" confirmation shows',
272 await page.locator('#improve-raised [data-act="improve-open-hub"]').count() === 1);
273 const raisedText = await page.evaluate(() => {
274 const h = document.getElementById('improve-raised');
275 return h ? (h.textContent || '').replace(/\s+/g, ' ').trim() : '';
276 });
277 check('the confirmation reads "Raised" and names the Improve hub',
278 /[Rr]aised/.test(raisedText) && /Improve/.test(raisedText), raisedText);
279 // The affordance opens the Improve hub panel (js/tracker.js, #panel-tracker).
280 await page.click('#improve-raised [data-act="improve-open-hub"]');
281 await page.waitForTimeout(300);
282 check('pressing Improve opens the Improve hub panel',
283 await page.evaluate(() => !!(window.DaimondPanels && DaimondPanels.isOpen('tracker'))) === true);
284 // Back to Social for the checks that follow.
285 await page.evaluate(() => { window.DaimondPanels.show('social'); window.DaimondSocial.show('proposals'); });
286 await page.waitForTimeout(200);
287
288 // ── (b) Polish online → the DRAFTED proposal posted ─────────
289 before = opens().length;
290 await typeAndClick('the reply box scrolls to the top on send', 'improve-polish');
291 for (let i = 0; i < 40 && await page.evaluate(() => window.DaimondImprove.notes().length > 0); i++) {
292 await page.waitForTimeout(150);
293 }
294 await page.waitForTimeout(300);
295 const bPost = opens().length - before === 1 ? fields(opens()[opens().length - 1].body) : {};
296 check('(b) polish online posted exactly one proposal', opens().length - before === 1,
297 `${opens().length - before} posts`);
298 check('(b) it carried the model\'s DRAFT, not the raw note',
299 /^Polished:/.test(bPost.title || '') && /tidier body/.test(bPost.body || ''),
300 JSON.stringify(bPost).slice(0, 140));
301 check('(b) the note is gone and the drafted proposal appears',
302 await page.evaluate(() => window.DaimondImprove.notes().length) === 0
303 && await page.evaluate(() => window.DaimondImprove.forge.props().some(p => p.n === 101)));
304
305 await shot(s, 'composemerge-posted' + (BREAK ? '-' + BREAK : ''));
306
307 // ── (c) Offline submit → queued, with its mode, nothing sent ─
308 await setOnline(false, true);
309 before = opens().length;
310 await typeAndClick('offline verbatim note about a crash', 'improve-post');
311 await page.waitForTimeout(250);
312 await typeAndClick('offline polish note about wording', 'improve-polish');
313 await page.waitForTimeout(250);
314 const queued = await page.evaluate(() => window.DaimondImprove.notes().map(n => ({ mode: n.mode, text: n.text.split('\n')[0] })));
315 check('(c) offline, nothing reached the forge', opens().length - before === 0,
316 `${opens().length - before} posts`);
317 check('(c) both notes are queued, each remembering its mode',
318 queued.length === 2
319 && queued.some(n => n.mode === 'verbatim' && /crash/.test(n.text))
320 && queued.some(n => n.mode === 'polish' && /wording/.test(n.text)),
321 JSON.stringify(queued));
322 const qHead = await page.evaluate(() => {
323 const h = document.querySelector('#improve-queue .imp-queue-head');
324 return h ? h.textContent : '';
325 });
326 check('(c) the queue shows "Waiting to send (2)"', /2/.test(qHead) && /[Ww]aiting/.test(qHead), qHead);
327 check('(c) a queued (offline) note also shows the Raised confirmation',
328 await page.locator('#improve-raised [data-act="improve-open-hub"]').count() === 1);
329 await shot(s, 'composemerge-offline' + (BREAK ? '-' + BREAK : ''));
330
331 // ── (d) Reconnect → the flush drains, each in its own mode ──
332 before = opens().length;
333 await stubPolish(); // the override does not survive if the panel reset; re-arm
334 await setOnline(true, true); // fires 'online' → daimond.js → flushQueue
335 for (let i = 0; i < 60 && await page.evaluate(() => window.DaimondImprove.notes().length > 0); i++) {
336 await page.waitForTimeout(200);
337 }
338 await page.waitForTimeout(400);
339 const drained = await page.evaluate(() => window.DaimondImprove.notes().length);
340 const newPosts = opens().slice(before).map(a => fields(a.body).title);
341 check('(d) reconnect drained the queue: nothing left waiting', drained === 0, `${drained} left`);
342 check('(d) both queued notes were sent on reconnect', opens().length - before === 2,
343 `${opens().length - before} posts`);
344 check('(d) the verbatim one went verbatim, the polish one as a draft',
345 newPosts.some(t => t === 'offline verbatim note about a crash')
346 && newPosts.some(t => /^Polished:/.test(t)),
347 JSON.stringify(newPosts));
348
349 const errs = errors(s).filter(e => !/Failed to load resource/.test(e));
350 check('nothing above was reached by way of an unhandled error', errs.length === 0,
351 errs.slice(0, 3).join(' | '));
352} finally {
353 await s.close();
354}
355
356console.log(`\nforge opens: ${opens().length}`);
357if (BREAK) {
358 console.log(`\nbreak '${BREAK}': ${bad.length} check(s) failed`
359 + (bad.length ? ' — ' + bad.join('; ') : ' — NOTHING FAILED, so the checks above prove nothing'));
360 process.exit(bad.length ? 0 : 1);
361}
362console.log(bad.length === 0 ? `\nall ${ok.length} checks passed` : `\n${bad.length} check(s) FAILED`);
363process.exit(bad.length === 0 ? 0 : 1);