Oregami
Repositories/oxedyne/daimond

oxedyne/daimond/dev/verify_approvelist.mjs

26.8 KiB, 5 runs

created by r2519314175:237, 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_approvelist.mjs — the approve-list: a local review queue, batch-sent.
2//
3// `www/js/approvelist.js` takes drafts (js/triage.js's own shape), holds them in
4// a local queue, and sends the ticked ones as a BATCH through improve.js's forge
5// door. What this proves:
6//
7// 1. A TRIAGE GENERATE LANDS THE DRAFTS DIRECTLY IN THE QUEUE. "Draft from
8// notes" hands its plan's drafts to the approve-list, which draws one row
9// per draft tagged with what it is and where it lands. And the OLD per-draft
10// send surface in triage is GONE: no draft boxes, no per-draft Send, no
11// send/drop exports. One review surface, no back-compat.
12//
13// 2. THE QUEUE IS LOCAL AND STAYS OUT OF THE SYNC PARCEL. It lives under
14// `daimond-approvelist`, a key sync.js's allowlist parcel never gathers, so
15// a draft is a proposal-not-yet-made and belongs to this device — the same
16// rule notes keep.
17//
18// 3. TICK + SEND SELECTED POSTS EXACTLY THE TICKED DRAFTS, and no others,
19// through improve.js's forge door: `open` for a new proposal (no `n`), `say`
20// for a comment (`n`, no `amend`), `amend` for a revision (`amend=1`). Told
21// apart the way the gateway tells them apart — by the query, never the body.
22//
23// 4. WHAT LEAVES IS THE BOX. A row is EDITED before the press, and the edit is
24// what left: the send reads the textarea at the moment it posts, not the
25// record the draft was enqueued from. The field set is exactly title+body.
26//
27// 5. A SENT DRAFT LEAVES THE QUEUE; A REFUSED ONE STAYS, with the forge's own
28// sentence beside it, and is never retried. One refusal in a batch does not
29// take the drafts that went with it.
30//
31// 6. A REVISION AMENDS THE AUTHOR'S OWN PROPOSAL ONLY. The forge's per-asker
32// `mine_to_amend` flag gates the tick: a proposal it granted is sendable and
33// posts to `amend=1`; one it did not is drawn with NO tick and never posts.
34// There is no path here that edits someone else's proposal.
35//
36// The forge is answered locally in the /api/improve stub, deterministically —
37// what is proved here is the CLIENT: the queue, the routing, and the payload.
38// `dev/verify_triage.mjs` and `dev/verify_improve.mjs` prove the forge's own
39// shape through `dev/mock_forge.mjs`; this file does not repeat that.
40//
41// eval "$(bash dev/world.sh 8 --env)"
42// node dev/verify_approvelist.mjs
43// node dev/verify_approvelist.mjs --break sendunticked
44
45import fs from 'node:fs';
46import path from 'node:path';
47import { fileURLToPath } from 'node:url';
48import { open, shot, scratch, errors, signInAs } from './harness.mjs';
49
50const HERE = path.dirname(fileURLToPath(import.meta.url));
51const WWW = path.join(HERE, '..', 'www');
52
53const BREAK = (() => {
54 const i = process.argv.indexOf('--break');
55 return i > 0 ? String(process.argv[i + 1] || '') : '';
56})();
57
58const PROFILE = scratch('pw', 'approvelist' + (BREAK ? '-' + BREAK : ''));
59fs.rmSync(PROFILE, { recursive: true, force: true });
60
61const ok = [], bad = [];
62const check = (name, pass, detail) => {
63 (pass ? ok : bad).push(name);
64 console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : ''));
65};
66
67// ── The seams: the module must be loaded and drawn, or nothing proves anything.
68
69const SEAM = [
70 { file: 'index.html', want: '<script src="js/approvelist.js"></script>',
71 why: 'the approve-list module is not loaded' },
72 { file: 'js/improve.js', want: 'window.DaimondApproveList) DaimondApproveList.draw()',
73 why: 'the panel never draws the queue, so it appears only after some other redraw' },
74];
75
76function requireSeams() {
77 const missing = [];
78 for (const s of SEAM) {
79 const src = fs.readFileSync(path.join(WWW, s.file), 'utf8');
80 if (!src.includes(s.want)) missing.push(` ${s.file}: ${s.why}`);
81 }
82 if (missing.length) {
83 console.error('the approve-list is not wired up, so this run would prove nothing:');
84 for (const b of missing) console.error(b);
85 process.exit(2);
86 }
87}
88
89// ── The breaks. Each is a real edit to a real file, served in place of it. The
90// anchor must appear exactly once, or nothing was changed.
91
92const BREAKS = {
93 // Send ignores the tick and posts every draft. Proves the batch is exactly the
94 // ticked ones and not the whole queue.
95 sendunticked: [{
96 file: 'js/approvelist.js',
97 find: '\t\tvar picked = q.filter(function (d) { return d.sel && sendable(d); });',
98 with: '\t\tvar picked = q.filter(function (d) { return sendable(d); });',
99 }],
100 // Send reads the record the draft was enqueued from rather than the box.
101 // Identical on screen until a row is edited before the press — which is the one
102 // moment the whole "what leaves is the screen" rule rests on.
103 staleedit: [{
104 file: 'js/approvelist.js',
105 find: '\t\tvar batch = picked.map(function (d) { return { id: d.id, text: boxed(d.id) }; });',
106 with: '\t\tvar batch = picked.map(function (d) { return { id: d.id, text: bodyOf(d) }; });',
107 }],
108 // A refused send is dropped from the queue anyway, so the words are lost with
109 // nothing anywhere holding them.
110 failvanishes: [{
111 file: 'js/approvelist.js',
112 find: '\t\t\td.err = p.forge.saying(a) + \' \' + tOr(\'approve.kept\', \'Kept here; nothing tried again.\');\n\t\t\treturn false;',
113 with: '\t\t\td.err = p.forge.saying(a); remove(d.id);\n\t\t\treturn false;',
114 }],
115 // A revision the forge has not granted is made tickable, so a control reaches a
116 // route this asker may not use — exactly the defect improve.js was rewritten to
117 // remove, and a path to editing a proposal that is not the asker's.
118 amendbright: [{
119 file: 'js/approvelist.js',
120 find: '\t\ttry { return !!(p && p.forge.mayAmend(d.n)); } catch (e) { return false; }',
121 with: '\t\treturn true;',
122 }],
123 // A sent draft no longer folds the notes it was written from, so the only copy
124 // of what the person wrote is now spare and the cap can evict it -- the data
125 // loss the fold exists to prevent.
126 nofold: [{
127 file: 'js/approvelist.js',
128 find: '\t\ttry { if (num && d.from.length) p.fold(d.from, num); }',
129 with: '\t\ttry { if (false && num && d.from.length) p.fold(d.from, num); }',
130 }],
131};
132
133if (BREAK && !BREAKS[BREAK]) {
134 console.error(`unknown break '${BREAK}'; one of: ${Object.keys(BREAKS).join(', ')}`);
135 process.exit(2);
136}
137
138function edit(src, spec, what) {
139 const n = src.split(spec.find).length - 1;
140 if (n !== 1) {
141 console.error(`${what}: the anchor appears ${n} times in ${spec.file}, `
142 + 'so nothing was changed and the run below would prove nothing.');
143 process.exit(2);
144 }
145 return src.replace(spec.find, spec.with);
146}
147
148const FILES = new Map();
149function build() {
150 requireSeams();
151 if (!BREAK) return;
152 for (const spec of BREAKS[BREAK]) {
153 const p = spec.file;
154 const src = FILES.get(p) ?? fs.readFileSync(path.join(WWW, p), 'utf8');
155 FILES.set(p, edit(src, spec, `break '${BREAK}'`));
156 }
157}
158build();
159
160// ── The forge, answered locally in the /api/improve stub ─────────────
161//
162// Deterministic, so a refusal happens exactly where this file wants one and the
163// routing can be asserted precisely. A POST whose title or said carries the
164// sentinel is refused with a forge token; everything else succeeds.
165
166const HDR = 'x-daimond-voice';
167// allowlist secret
168const SECRET = 'mock-voice-ada-0000000000000';
169const FAIL_SENTINEL = 'FORCE-FORGE-REFUSAL';
170
171// The panel's own name for the per-asker amend flag, read out of the source as
172// served (break and all), never written here — a fixture holding its own copy of
173// the name feeds the panel a key it recognises after a rename and keeps a broken
174// client green. This is the discipline dev/verify_triage.mjs draws out at length.
175const AMEND_FLAG = (() => {
176 const src = FILES.get('js/improve.js')
177 ?? fs.readFileSync(path.join(WWW, 'js', 'improve.js'), 'utf8');
178 const m = /\bvar AMEND_FLAG = '([A-Za-z0-9_]+)';/.exec(src);
179 if (!m) {
180 console.error('js/improve.js no longer holds `var AMEND_FLAG = \'...\';`, so this file '
181 + 'cannot know which key the panel reads and nothing below would prove anything.');
182 process.exit(2);
183 }
184 return m[1];
185})();
186
187// The listing the panel reads. #7 is the user's own (amend granted); #8 is
188// someone else's (amend answered FALSE — a real answer, not silence).
189function listingBody() {
190 const prop = (n, mineAmend) => {
191 const o = { number: n, title: 'Proposal ' + n, state: 'open', author: 'ada',
192 comments: 0, opened: 1, changed: 2, mark: null, build: null };
193 o[AMEND_FLAG] = mineAmend;
194 return o;
195 };
196 return JSON.stringify({ proposals: [prop(7, true), prop(8, false)], total: 2, done: true });
197}
198
199function oneBody(n) {
200 const mine = n === 7;
201 const o = { number: n, title: 'Proposal ' + n, body: 'the body of ' + n, state: 'open',
202 author: 'ada', comments: 0, opened: 1, changed: 2, discussion: [],
203 votes: { for: 0, against: 0 }, mark: null, build: null, revisions: [] };
204 o[AMEND_FLAG] = mine;
205 return JSON.stringify(o);
206}
207
208const asked = []; // every request that reached /api/improve
209const wire = []; // every request the page made, for the sync/parcel check
210
211async function improveRoute(r) {
212 const req = r.request();
213 const u = new URL(req.url());
214 const q = u.searchParams;
215 const method = req.method();
216 const body = req.postData() || '';
217 const headers = req.headers();
218 asked.push({ url: req.url(), method, body, query: Object.fromEntries(q), headers });
219
220 const json = (obj, status = 200) => r.fulfill({ status, contentType: 'application/json',
221 body: typeof obj === 'string' ? obj : JSON.stringify(obj) });
222 const refuse = (status, error) => r.fulfill({ status, contentType: 'application/json',
223 body: JSON.stringify({ error, said: 'The forge refused: ' + error + '.' }) });
224
225 // A read: the listing, or one proposal. No voice needed.
226 if (method === 'GET') {
227 if (q.get('n') !== null) return json(oneBody(Number(q.get('n'))));
228 return json(listingBody());
229 }
230
231 // A write needs a voice, exactly as the forge insists.
232 if (!headers[HDR]) return refuse(401, 'unvoiced');
233
234 // The sentinel forces a refusal wherever this file wants one.
235 if (body.indexOf(encodeURIComponent(FAIL_SENTINEL)) !== -1
236 || body.indexOf(FAIL_SENTINEL) !== -1) {
237 return refuse(400, 'malformed');
238 }
239
240 const n = q.get('n');
241 // A revision (amend=1) or a comment (n, no amend) both land on their proposal;
242 // a new proposal (no n) is given a fresh number. The answer is the record the
243 // forge changed, in the shape the panel's cleanProp reads.
244 const num = n !== null ? Number(n) : 101;
245 return json({ number: num, title: 'x', body: 'y', state: 'open', author: 'ada',
246 comments: 0, opened: 1, changed: 2, discussion: [], votes: { for: 0, against: 0 },
247 mark: null, build: null, revisions: [] });
248}
249
250const json = (body, status = 200) => ({ status, contentType: 'application/json', body: JSON.stringify(body) });
251
252async function stub(page) {
253 for (const [p, body] of FILES) {
254 const type = p.endsWith('.html') ? 'text/html' : 'application/javascript';
255 await page.route('**/' + p, r => r.fulfill({ status: 200, contentType: type, body }));
256 }
257 if (FILES.has('index.html')) {
258 await page.route(u => u.pathname === '/' || u.pathname === '/index.html',
259 r => r.fulfill({ status: 200, contentType: 'text/html', body: FILES.get('index.html') }));
260 }
261 page.on('request', req => {
262 let b = '';
263 try { b = req.postData() || ''; } catch (e) { b = ''; }
264 wire.push({ url: req.url(), method: req.method(), body: b });
265 });
266 await page.route(u => u.pathname === '/api/improve', improveRoute);
267 await page.route('**/api/telemetry', r => r.fulfill(json({ ok: true })));
268 await page.route('**/api/account', r => r.fulfill(json({ ok: true })));
269 await page.route('**/api/auth/challenge', r => r.fulfill(json({ ok: true, challenge: 'chal-apl', challenge_id: 'cid-1' })));
270 await page.route('**/api/auth/verify', r => r.fulfill(json({ ok: true })));
271 await page.route('**/api/balance', r => r.fulfill(json({ ok: true, credits_minor: 0, currency: 'usd', entries: [] })));
272 await page.route('**/api/licence', r => r.fulfill(json({ ok: true, licence: false, currency: 'usd' })));
273}
274
275// ── Reading what left, told apart by the query like the gateway does ──
276
277const opens = () => asked.filter(a => a.method === 'POST' && a.query.n === undefined);
278const says = () => asked.filter(a => a.method === 'POST' && a.query.n !== undefined
279 && a.query.vote === undefined && a.query.amend === undefined);
280const revisions = () => asked.filter(a => a.method === 'POST' && a.query.amend === '1');
281const posts = () => asked.filter(a => a.method === 'POST');
282
283const fields = (raw) => {
284 const out = {};
285 for (const [k, v] of new URLSearchParams(raw)) out[k] = v;
286 return out;
287};
288
289// ── The drafts this run enqueues. Two lands, one comment, one revision on the
290// user's own proposal (#7), one revision on someone else's (#8).
291
292const DRAFTS = [
293 { kind: 'new', title: 'Lost text on reload', body: 'the box empties', from: ['na'], why: 'one fault' },
294 { kind: 'comment', n: 7, body: 'this also happens on iOS', from: ['nb'], why: 'lands on 7' },
295 { kind: 'revision', n: 7, title: 'Reload keeps the box', body: 'it should persist', from: ['nc'], why: 'the statement was wrong' },
296 { kind: 'revision', n: 8, title: 'not mine to touch', body: 'someone else opened it', from: ['nd'], why: 'author-only' },
297 // A second comment, SENDABLE but left unticked in the batch below, so a send
298 // that ignored the tick would carry a draft nobody chose. Without it every
299 // sendable draft happens to be ticked and the tick cannot be shown to matter.
300 { kind: 'comment', n: 7, body: 'and one more thing about 7', from: ['ne'], why: 'second comment, unticked' },
301];
302
303const s = await open({ name: 'approvelist', profile: PROFILE, signIn: false, connect: false, route: stub });
304const { page } = s;
305
306await signInAs(s, 'approvelist');
307await page.waitForTimeout(1200);
308
309try {
310 // Open the Social panel and read the forge listing, so the panel knows which
311 // proposals this asker may amend.
312 await page.evaluate(() => { window.DaimondPanels.show('social'); });
313 await page.waitForTimeout(300);
314 await page.evaluate(() => { if (window.DaimondImprove) window.DaimondImprove.onOpen(); });
315 await page.evaluate(() => window.DaimondImprove.load(false));
316 await page.waitForTimeout(500);
317
318 // A voice, so writes are permitted (the forge refuses one without it). It lives
319 // in the Settings view now, so show that to reach the paste control, then come
320 // back to Proposals where the queue draws.
321 await page.evaluate(() => window.DaimondSocial.show('settings'));
322 await page.waitForTimeout(200);
323 await page.click('[data-act="improve-voice-open"]');
324 await page.waitForTimeout(200);
325 await page.fill('#improve-voice-in', SECRET);
326 await page.click('[data-act="improve-voice-save"]');
327 await page.waitForTimeout(600);
328 check('a voice is held, so the queue can send',
329 await page.evaluate(() => window.DaimondVoice.has()) === true);
330 await page.evaluate(() => window.DaimondSocial.show('proposals'));
331 await page.waitForTimeout(200);
332
333 // ── 1. A triage GENERATE lands the drafts DIRECTLY in the queue ─
334 //
335 // The single path: "Draft from notes" produces a plan and hands the drafts to
336 // the approve-list, which is the one review surface. `hold()` is the paid-turn-
337 // free equivalent of a run -- it goes through triage's own `parse()` and the
338 // same `toQueue` hand-off a real run makes, so what lands here is what a run
339 // would land. (`dev/verify_triage.mjs` proves the real model run reaches it.)
340 const gen = await page.evaluate((drafts) => {
341 window.DaimondApproveList.reset();
342 window.DaimondApproveList.clear();
343 const held = window.DaimondTriage.hold({ drafts, left: [] });
344 return { held: held ? held.drafts.length : 0,
345 queue: window.DaimondApproveList.queue().length };
346 }, DRAFTS);
347 await page.waitForTimeout(300);
348 check('running Draft-from-notes lands the generated drafts straight in the queue',
349 gen.held === 5 && gen.queue === 5, JSON.stringify(gen));
350 const rows = await page.locator('#improve-approve .apl-row').count();
351 check('one row per draft is drawn in the queue', rows === 5, `${rows} rows`);
352 const kinds = await page.evaluate(() => [...document.querySelectorAll('#improve-approve .apl-row')]
353 .map(r => r.dataset.kind));
354 check('each row is tagged with what pressing it would do',
355 kinds.length === 5 && kinds.every(k => ['new', 'comment', 'revision'].indexOf(k) !== -1),
356 kinds.join(','));
357
358 // ── 1b. THE OLD PER-DRAFT SEND SURFACE IN TRIAGE IS GONE ─────
359 // No back-compat: triage no longer draws draft boxes or a per-draft Send, and
360 // its send/drop exports are removed. The queue is the only place a draft is
361 // sent, so there cannot be two surfaces to keep in step.
362 const oldGone = await page.evaluate(() => ({
363 draftBoxes: document.querySelectorAll('#improve-triage .trg-draft').length,
364 sendBtns: document.querySelectorAll('#improve-triage [data-act="triage-send"]').length,
365 sendExport: typeof window.DaimondTriage.send,
366 dropExport: typeof window.DaimondTriage.drop,
367 }));
368 check('triage draws no per-draft box and no per-draft Send any more',
369 oldGone.draftBoxes === 0 && oldGone.sendBtns === 0, JSON.stringify(oldGone));
370 check('and triage exposes no send/drop of its own: the queue is the one door',
371 oldGone.sendExport === 'undefined' && oldGone.dropExport === 'undefined',
372 JSON.stringify(oldGone));
373
374 await shot(s, 'approvelist-queue' + (BREAK ? '-' + BREAK : ''));
375
376 // ── 2. The queue is local and out of the sync parcel ─────────
377 const stored = await page.evaluate(() => {
378 const key = Object.keys(localStorage).find(k => k.indexOf('daimond-approvelist') !== -1);
379 return { key, has: !!key, raw: key ? localStorage.getItem(key) : '' };
380 });
381 check('the queue is persisted under a local daimond-approvelist key',
382 stored.has && /Lost text on reload/.test(stored.raw), stored.key || '(no key)');
383 const parcel = await page.evaluate(async () => {
384 try {
385 if (!window.DaimondSync || !window.DaimondSync.parcel) return { ran: false };
386 const p = await window.DaimondSync.parcel();
387 const s = JSON.stringify(p || {});
388 return { ran: true, leaks: s.indexOf('Lost text on reload') !== -1
389 || s.indexOf('daimond-approvelist') !== -1 };
390 } catch (e) { return { ran: false, err: String(e && e.message || e) }; }
391 });
392 check('the sync parcel does not carry the queue: a draft is not synced',
393 parcel.ran ? parcel.leaks === false : true,
394 parcel.ran ? JSON.stringify(parcel) : 'DaimondSync.parcel unavailable — key is out of the allowlist by construction');
395
396 // ── 6. A revision the forge did not grant offers no tick ─────
397 const gate = await page.evaluate(() => {
398 const q = window.DaimondApproveList.queue();
399 const mine = q.find(d => d.kind === 'revision' && d.n === 7);
400 const not = q.find(d => d.kind === 'revision' && d.n === 8);
401 const tickOf = (id) => {
402 const row = document.querySelector('.apl-row[data-draft="' + id + '"]');
403 return !!(row && row.querySelector('.apl-tick'));
404 };
405 return { mineTick: tickOf(mine.id), notTick: tickOf(not.id), notId: not.id, mineId: mine.id };
406 });
407 check('a revision of the asker\'s own proposal (amend granted) offers a tick',
408 gate.mineTick === true, JSON.stringify(gate));
409 check('a revision of someone else\'s proposal (amend not granted) offers NO tick',
410 gate.notTick === false, JSON.stringify(gate));
411 // And it cannot be forced ticked through the API either.
412 const forced = await page.evaluate((id) => window.DaimondApproveList.select(id, true), gate.notId);
413 check('nor can that revision be ticked through the API: no path edits another\'s proposal',
414 forced === false);
415
416 // ── 3+4. Tick the new + comment + own-revision, edit one, send ─
417 const EDIT = ' quokka-edit-marker';
418 await page.evaluate((edit) => {
419 const q = window.DaimondApproveList.queue();
420 const byKind = {};
421 q.forEach(d => { byKind[d.kind + (d.n || '')] = d; });
422 // Tick the new proposal, the comment on 7, and the revision of 7.
423 [q.find(d => d.kind === 'new'),
424 q.find(d => d.kind === 'comment'),
425 q.find(d => d.kind === 'revision' && d.n === 7)].forEach(d => {
426 const box = document.querySelector('.apl-row[data-draft="' + d.id + '"] .apl-tick');
427 if (box && !box.checked) { box.checked = true; box.dispatchEvent(new Event('change', { bubbles: true })); }
428 });
429 // Edit the NEW proposal's box after the model answered — this is what must
430 // travel, not the enqueued record.
431 const nd = q.find(d => d.kind === 'new');
432 const ta = document.querySelector('.apl-row[data-draft="' + nd.id + '"] .apl-box');
433 if (ta) ta.value = ta.value + edit;
434 }, EDIT);
435 await page.waitForTimeout(200);
436
437 const ticked = await page.evaluate(() =>
438 window.DaimondApproveList.queue().filter(d => d.sel).length);
439 check('exactly the three sendable ticks are recorded', ticked === 3, `${ticked}`);
440
441 const beforePosts = posts().length;
442 await page.click('#improve-approve [data-act="approve-send"]');
443 await page.waitForTimeout(500);
444 for (let i = 0; i < 40 && await page.evaluate(() => window.DaimondApproveList.busy()); i++) {
445 await page.waitForTimeout(200);
446 }
447 await page.waitForTimeout(400);
448
449 check('one press posted exactly the three ticked drafts, and no others',
450 posts().length - beforePosts === 3, `${posts().length - beforePosts} posts`);
451 check('and they went to the right doors: one open, one comment, one revision',
452 opens().length === 1 && says().length === 1 && revisions().length === 1,
453 `opens ${opens().length}, says ${says().length}, revisions ${revisions().length}`);
454
455 const openF = opens().length ? fields(opens()[0].body) : {};
456 check('the new proposal carried the box, INCLUDING the edit made after enqueue',
457 (openF.title + '\n' + openF.body).indexOf(EDIT) !== -1,
458 JSON.stringify(openF).slice(0, 200));
459 check('and its field set is exactly title and body, nothing a person could not see',
460 JSON.stringify(Object.keys(openF).sort()) === JSON.stringify(['body', 'title']),
461 Object.keys(openF).sort().join(','));
462 check('the revision went to the amend route, on the asker\'s own proposal #7',
463 revisions().length === 1 && revisions()[0].query.n === '7' && revisions()[0].query.amend === '1',
464 JSON.stringify(revisions()[0] && revisions()[0].query));
465 check('the comment went to proposal #7 by the comment door, not the amend one',
466 says().length === 1 && says()[0].query.n === '7' && says()[0].query.amend === undefined,
467 JSON.stringify(says()[0] && says()[0].query));
468
469 // ── 5. Sent drafts leave; the untouched one and the un-grantable one stay ─
470 const afterSend = await page.evaluate(() => window.DaimondApproveList.queue().map(d => ({
471 kind: d.kind, n: d.n, sel: d.sel })));
472 check('every sent draft left the queue; the unticked and un-grantable ones stay',
473 afterSend.length === 2, JSON.stringify(afterSend));
474 check('what remains is the unticked comment and the revision the forge would not grant',
475 afterSend.length === 2
476 && afterSend.some(d => d.kind === 'comment' && d.n === 7)
477 && afterSend.some(d => d.kind === 'revision' && d.n === 8),
478 JSON.stringify(afterSend));
479
480 // ── 5c. Sending folds the notes a draft was written from ─────
481 // The fold MOVED here from triage: when the queue sends a draft, the notes it
482 // was drafted from are marked folded (into a proposal) but NOT sent -- this
483 // device still holds the only copy of what the person wrote, so the cap in
484 // improve.js never evicts them. dev/verify_triage.mjs proved this while triage
485 // held the send; it is the queue's now.
486 const folded = await page.evaluate(async () => {
487 const key = Object.keys(localStorage).find(k => k.indexOf('daimond-improve') !== -1) || 'daimond-improve';
488 localStorage.setItem(key, JSON.stringify({ v: 3, notes: [
489 { id: 'fa', at: 1000, text: 'note a', sent: 0, n: 0, into: [] },
490 { id: 'fb', at: 1001, text: 'note b', sent: 0, n: 0, into: [] },
491 ] }));
492 window.DaimondImprove.reset();
493 window.DaimondApproveList.reset();
494 window.DaimondApproveList.clear();
495 window.DaimondApproveList.enqueue([{ kind: 'new', title: 'folds its notes',
496 body: 'from a and b', from: ['fa', 'fb'] }]);
497 window.DaimondApproveList.selectAll(true);
498 await window.DaimondApproveList.send();
499 const notes = window.DaimondImprove.notes().filter(r => r.id === 'fa' || r.id === 'fb')
500 .map(r => ({ id: r.id, sent: r.sent, n: r.n, into: r.into,
501 delivered: window.DaimondImprove.delivered(r) }));
502 return { notes, queue: window.DaimondApproveList.queue().length };
503 });
504 await page.waitForTimeout(300);
505 check('a sent draft folds the notes it was written from, then leaves the queue',
506 folded.queue === 0 && folded.notes.length === 2, JSON.stringify(folded));
507 check('and those notes are folded, NOT sent, NOT delivered: the only copy stays here',
508 folded.notes.every(n => n.into.length === 1 && n.sent === 0 && n.n === 0 && n.delivered === false),
509 JSON.stringify(folded.notes));
510
511 // ── 5b. A refused send stays with an error; its batch-mates go ─
512 await page.evaluate((sentinel) => {
513 window.DaimondApproveList.clear();
514 window.DaimondApproveList.enqueue([
515 { kind: 'new', title: 'this one goes', body: 'fine', from: ['a'] },
516 { kind: 'new', title: sentinel, body: 'the forge will refuse this', from: ['b'] },
517 ]);
518 }, FAIL_SENTINEL);
519 await page.waitForTimeout(200);
520 await page.evaluate(() => window.DaimondApproveList.selectAll(true));
521 await page.waitForTimeout(150);
522 const beforeFail = posts().length;
523 await page.click('#improve-approve [data-act="approve-send"]');
524 await page.waitForTimeout(400);
525 for (let i = 0; i < 40 && await page.evaluate(() => window.DaimondApproveList.busy()); i++) {
526 await page.waitForTimeout(200);
527 }
528 await page.waitForTimeout(300);
529 check('both ticked drafts were attempted', posts().length - beforeFail === 2,
530 `${posts().length - beforeFail}`);
531 const afterFail = await page.evaluate(() => window.DaimondApproveList.queue().map(d => ({
532 title: d.title, err: d.err })));
533 check('the one the forge refused stayed in the queue, alone',
534 afterFail.length === 1 && afterFail[0].title === FAIL_SENTINEL,
535 JSON.stringify(afterFail).slice(0, 200));
536 check('and it carries the forge\'s own sentence plus that it is kept, so nothing looks broken in silence',
537 afterFail.length === 1 && afterFail[0].err.length > 20 && /kept here/i.test(afterFail[0].err),
538 afterFail[0] && afterFail[0].err);
539 const errRow = await page.evaluate(() => {
540 const e = document.querySelector('#improve-approve .apl-err');
541 return e ? (e.textContent || '') : '';
542 });
543 check('the error is drawn beside the draft, not only stored',
544 /kept here/i.test(errRow), errRow || '(no error row drawn)');
545
546 await shot(s, 'approvelist-sent' + (BREAK ? '-' + BREAK : ''));
547
548 const errs = errors(s).filter(e => !/Failed to load resource/.test(e));
549 check('nothing above was reached by way of an unhandled error', errs.length === 0,
550 errs.slice(0, 3).join(' | '));
551} finally {
552 await s.close();
553}
554
555console.log(`\nforge posts: ${posts().length} opens: ${opens().length}`
556 + ` comments: ${says().length} revisions: ${revisions().length}`);
557if (BREAK) {
558 console.log(`\nbreak '${BREAK}': ${bad.length} check(s) failed`
559 + (bad.length ? ' — ' + bad.join('; ') : ' — NOTHING FAILED, so the checks above prove nothing'));
560 process.exit(bad.length ? 0 : 1);
561}
562console.log(bad.length === 0 ? `\nall ${ok.length} checks passed` : `\n${bad.length} check(s) FAILED`);
563process.exit(bad.length === 0 ? 0 : 1);