Oregami
Repositories/oxedyne/daimond

oxedyne/daimond/dev/verify_proposalsappear.mjs

15.2 KiB, 11 runs

created by r2519314175:1508, 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_proposalsappear.mjs — the meta-bug: a batch-sent proposal must APPEAR.
2//
3// The owner sent eight queued drafts as proposals. All eight reached the forge,
4// and ONE appeared in the app's Proposals view. The other seven were hidden --
5// which hid, among them, the very proposals about the app they were meant to
6// surface. This is the fault that made everything else harder to see, so it is
7// checked on its own here.
8//
9// The mechanism: `www/js/approvelist.js` sends each ticked draft through
10// improve.js's forge door (`forge.open`/`say`/`amend`). A door PUTS a write on
11// the wire and answers with the record it changed; the panel's OWN send, comment
12// and vote each fold that answer back into the proposal store (`absorb`), but the
13// batch did not. So a proposal the queue opened never entered `_by`/`_order`, and
14// the Proposals view -- drawn from `_order` -- omitted it until the next full
15// walk. The fix folds each answer in through `forge.absorb`.
16//
17// What this proves: after a MULTI-ITEM batch send, EVERY proposal the batch
18// opened is present in the panel's proposal store AND drawn in the list, with NO
19// re-walk of the forge in between -- the send itself must make them appear.
20//
21// The forge is answered locally in the /api/improve stub, deterministically, and
22// a POST that opens a proposal is given a FRESH, INCREMENTING number, so several
23// new proposals in one batch are several distinct records and "they all appear"
24// is a claim with more than one thing in it.
25//
26// It also carries a second, unrelated regression: a DIRECT verbatim Post (the compose box's
27// "Post", submit('verbatim') -> through()) must record the number the forge returned, or the
28// proposal never enters raisedProposalNumbers() and never shows under the Improve hub's "Mine"
29// filter. That path is here rather than in verify_improve.mjs because this file already stands up
30// the whole app with a voice held and an incrementing-number forge stub -- exactly what the direct
31// post needs.
32//
33// eval "$(bash dev/world.sh 8 --env)"
34// node dev/verify_proposalsappear.mjs
35// node dev/verify_proposalsappear.mjs --break noappear # the batch fold-back is reverted
36// node dev/verify_proposalsappear.mjs --break noraise # the verbatim-Post path records nothing
37
38import fs from 'node:fs';
39import path from 'node:path';
40import { fileURLToPath } from 'node:url';
41import { open, shot, scratch, errors, signInAs } from './harness.mjs';
42
43const HERE = path.dirname(fileURLToPath(import.meta.url));
44const WWW = path.join(HERE, '..', 'www');
45
46const BREAK = (() => {
47 const i = process.argv.indexOf('--break');
48 return i > 0 ? String(process.argv[i + 1] || '') : '';
49})();
50
51const PROFILE = scratch('pw', 'proposalsappear' + (BREAK ? '-' + BREAK : ''));
52fs.rmSync(PROFILE, { recursive: true, force: true });
53
54const ok = [], bad = [];
55const check = (name, pass, detail) => {
56 (pass ? ok : bad).push(name);
57 console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : ''));
58};
59
60// ── The seams: the fix must be wired, or the run proves nothing. The approve-
61// list must hand the forge's answer to the panel, and the panel must offer the
62// door that folds it in.
63
64const SEAM = [
65 { file: 'js/approvelist.js', want: 'p.forge.absorb(a.data)',
66 why: 'a sent draft is never folded into the proposal store, so it cannot appear' },
67 { file: 'js/improve.js', want: 'absorb: function (data) { return absorb(cleanProp(data)); }',
68 why: 'the panel offers no door to fold a batch answer into its proposal store' },
69];
70
71function requireSeams() {
72 const missing = [];
73 for (const s of SEAM) {
74 const src = FILES.get(s.file) ?? fs.readFileSync(path.join(WWW, s.file), 'utf8');
75 if (!src.includes(s.want)) missing.push(` ${s.file}: ${s.why}`);
76 }
77 if (missing.length) {
78 console.error('the appear-in-the-view fix is not wired, so this run would prove nothing:');
79 for (const b of missing) console.error(b);
80 process.exit(2);
81 }
82}
83
84// ── The break. The one edit that reintroduces the meta-bug: the batch sends, but
85// its answers are never folded back, so the proposals never enter the view.
86
87const BREAKS = {
88 noappear: [{
89 file: 'js/approvelist.js',
90 find: '\t\ttry { if (p.forge && p.forge.absorb) p.forge.absorb(a.data); }',
91 with: '\t\ttry { if (false && p.forge && p.forge.absorb) p.forge.absorb(a.data); }',
92 }],
93 // The verbatim-Post path (through()) stops recording the number it raised, so a proposal posted
94 // straight from the compose box never enters raisedProposalNumbers() and never shows under the
95 // hub's "Mine" filter. Bites the direct-post check below.
96 noraise: [{
97 file: 'js/improve.js',
98 find: '\t\t\tif (prop) recordRaised(prop.n);',
99 with: '\t\t\tif (false && prop) recordRaised(prop.n);',
100 }],
101};
102
103if (BREAK && !BREAKS[BREAK]) {
104 console.error(`unknown break '${BREAK}'; one of: ${Object.keys(BREAKS).join(', ')}`);
105 process.exit(2);
106}
107
108function edit(src, spec, what) {
109 const n = src.split(spec.find).length - 1;
110 if (n !== 1) {
111 console.error(`${what}: the anchor appears ${n} times in ${spec.file}, `
112 + 'so nothing was changed and the run below would prove nothing.');
113 process.exit(2);
114 }
115 return src.replace(spec.find, spec.with);
116}
117
118const FILES = new Map();
119function build() {
120 if (BREAK) {
121 for (const spec of BREAKS[BREAK]) {
122 const p = spec.file;
123 const src = FILES.get(p) ?? fs.readFileSync(path.join(WWW, p), 'utf8');
124 FILES.set(p, edit(src, spec, `break '${BREAK}'`));
125 }
126 }
127 requireSeams();
128}
129build();
130
131// ── The forge, answered locally. A GET reads the listing (two proposals already
132// there, so the view starts non-empty) or one proposal; a POST that opens a new
133// proposal is given the next number, counting up from 100.
134
135const HDR = 'x-daimond-voice';
136// allowlist secret
137const SECRET = 'mock-voice-ada-0000000000000';
138
139let nextNew = 100;
140
141function listingBody() {
142 const prop = (n) => ({ number: n, title: 'Existing ' + n, state: 'open', author: 'ada',
143 comments: 0, opened: 1, changed: 2, mark: null, build: null });
144 return JSON.stringify({ proposals: [prop(5), prop(4)], total: 2, done: true });
145}
146
147function detailBody(n) {
148 return { number: n, title: 'Proposal ' + n, body: 'the body of ' + n, state: 'open',
149 author: 'ada', comments: 0, opened: 1, changed: 2, discussion: [],
150 votes: { for: 0, against: 0 }, mark: null, build: null, revisions: [] };
151}
152
153const asked = [];
154
155async function improveRoute(r) {
156 const req = r.request();
157 const u = new URL(req.url());
158 const q = u.searchParams;
159 const method = req.method();
160 const headers = req.headers();
161 asked.push({ url: req.url(), method, query: Object.fromEntries(q) });
162
163 const json = (obj) => r.fulfill({ status: 200, contentType: 'application/json',
164 body: typeof obj === 'string' ? obj : JSON.stringify(obj) });
165 const refuse = (status, error) => r.fulfill({ status, contentType: 'application/json',
166 body: JSON.stringify({ error, said: 'The forge refused: ' + error + '.' }) });
167
168 if (method === 'GET') {
169 if (q.get('n') !== null) return json(detailBody(Number(q.get('n'))));
170 return json(listingBody());
171 }
172 if (!headers[HDR]) return refuse(401, 'unvoiced');
173
174 // A new proposal (no n) is given a fresh number; a comment or revision lands on
175 // the proposal it named. The answer is the detail shape the panel's cleanProp
176 // reads, carrying the number the caller must fold in.
177 const n = q.get('n');
178 const num = n !== null ? Number(n) : nextNew++;
179 return json(detailBody(num));
180}
181
182const j = (body, status = 200) => ({ status, contentType: 'application/json', body: JSON.stringify(body) });
183
184async function stub(page) {
185 for (const [p, body] of FILES) {
186 const type = p.endsWith('.html') ? 'text/html' : 'application/javascript';
187 await page.route('**/' + p, r => r.fulfill({ status: 200, contentType: type, body }));
188 }
189 await page.route(u => u.pathname === '/api/improve', improveRoute);
190 await page.route('**/api/telemetry', r => r.fulfill(j({ ok: true })));
191 await page.route('**/api/account', r => r.fulfill(j({ ok: true })));
192 await page.route('**/api/auth/challenge', r => r.fulfill(j({ ok: true, challenge: 'chal-pa', challenge_id: 'cid-1' })));
193 await page.route('**/api/auth/verify', r => r.fulfill(j({ ok: true })));
194 await page.route('**/api/balance', r => r.fulfill(j({ ok: true, credits_minor: 0, currency: 'usd', entries: [] })));
195 await page.route('**/api/licence', r => r.fulfill(j({ ok: true, licence: false, currency: 'usd' })));
196}
197
198const opens = () => asked.filter(a => a.method === 'POST' && a.query.n === undefined);
199
200// ── The batch: three NEW proposals, each distinct, so "all appear" has three
201// things in it and the incrementing forge gives each its own number.
202
203const DRAFTS = [
204 { kind: 'new', title: 'Lost text on reload', body: 'the box empties', from: ['na'] },
205 { kind: 'new', title: 'Reply box scrolls', body: 'it jumps on send', from: ['nb'] },
206 { kind: 'new', title: 'Dark mode contrast', body: 'the muted grey is thin', from: ['nc'] },
207];
208
209const s = await open({ name: 'proposalsappear', profile: PROFILE, signIn: false, connect: false, route: stub });
210const { page } = s;
211
212await signInAs(s, 'proposalsappear');
213await page.waitForTimeout(1200);
214
215try {
216 // Open the Social panel and read the listing once, so the view starts with the
217 // two existing proposals and `read` is already true -- the exact state the
218 // meta-bug hid behind: a list that is read, and never re-walked after a send.
219 await page.evaluate(() => { window.DaimondPanels.show('social'); });
220 await page.waitForTimeout(300);
221 await page.evaluate(() => { if (window.DaimondImprove) window.DaimondImprove.onOpen(); });
222 await page.evaluate(() => window.DaimondImprove.load(false));
223 await page.waitForTimeout(500);
224
225 const before = await page.evaluate(() => ({
226 props: window.DaimondImprove.forge.props().map(p => p.n),
227 read: window.DaimondImprove.listing().shown.length,
228 }));
229 check('the listing is read first, so the view holds the two existing proposals',
230 before.props.length === 2 && before.props.indexOf(5) !== -1 && before.props.indexOf(4) !== -1,
231 JSON.stringify(before.props));
232
233 // A voice, so writes are permitted. It lives in the Settings view now, so show
234 // that before reaching for the paste control.
235 await page.evaluate(() => window.DaimondSocial.show('settings'));
236 await page.waitForTimeout(200);
237 await page.click('[data-act="improve-voice-open"]');
238 await page.waitForTimeout(200);
239 await page.fill('#improve-voice-in', SECRET);
240 await page.click('[data-act="improve-voice-save"]');
241 await page.waitForTimeout(600);
242 check('a voice is held, so the queue can send',
243 await page.evaluate(() => window.DaimondVoice.has()) === true);
244 await page.evaluate(() => window.DaimondSocial.show('proposals'));
245 await page.waitForTimeout(200);
246
247 // Queue the three, tick all, and send as one batch.
248 await page.evaluate((drafts) => {
249 window.DaimondApproveList.reset();
250 window.DaimondApproveList.clear();
251 window.DaimondApproveList.enqueue(drafts);
252 window.DaimondApproveList.selectAll(true);
253 }, DRAFTS);
254 await page.waitForTimeout(200);
255 const ticked = await page.evaluate(() =>
256 window.DaimondApproveList.queue().filter(d => d.sel).length);
257 check('all three drafts are queued and ticked', ticked === 3, `${ticked}`);
258
259 const beforeOpens = opens().length;
260 await page.evaluate(() => window.DaimondApproveList.send());
261 for (let i = 0; i < 40 && await page.evaluate(() => window.DaimondApproveList.busy()); i++) {
262 await page.waitForTimeout(200);
263 }
264 await page.waitForTimeout(400);
265
266 check('the batch opened three proposals on the forge', opens().length - beforeOpens === 3,
267 `${opens().length - beforeOpens} opens`);
268
269 // ── THE META-BUG CHECK. No load() was called after the send: the send ITSELF
270 // must have folded each answer into the store. All three new numbers, and the
271 // two that were already there, are present -- five in all, none displaced.
272 const after = await page.evaluate(() => window.DaimondImprove.forge.props().map(p => p.n));
273 const has = (n) => after.indexOf(n) !== -1;
274 check('every proposal the batch opened is now in the store, WITHOUT a re-walk',
275 has(100) && has(101) && has(102),
276 JSON.stringify(after));
277 check('and the two that were already there are still present: nothing was displaced',
278 has(5) && has(4) && after.length === 5,
279 JSON.stringify(after));
280
281 // ── AND DRAWN. RETIRED with the Social browse-list (option b, 2026-08-31): the
282 // Social ▸ Proposals surface is a capture box now and no longer renders
283 // `#improve-props .imp-prop` rows -- that list moved to the Improve hub
284 // (js/tracker.js). The META-BUG this file exists for is the FOLD, and it is
285 // proved above at the STORE level (`forge.props()`), which the `noappear` break
286 // still reddens; the hub's DRAWING of proposals (and the folding of a vote or
287 // comment answer into a card) is proved in dev/verify_tracker.mjs. A DOM-row
288 // check against a list that no longer exists would assert nothing, so it is not
289 // masked here -- it is gone, and its meaning is where the rendering now lives.
290
291 // The queue emptied, as a batch of accepted sends should.
292 const queue = await page.evaluate(() => window.DaimondApproveList.queue().length);
293 check('the queue is empty: every sent draft left it', queue === 0, `${queue}`);
294
295 // ── FIX 2: a DIRECT verbatim Post enters raisedProposalNumbers() ──
296 // The batch above went through the approve-list door, which records no raised number. The
297 // compose box's "Post" (submit('verbatim')) is the DIRECT path, and through() must record the
298 // number the forge returned -- otherwise a directly-posted proposal never shows under the
299 // Improve hub's "Mine" filter, the very place `showRaised` points the author. Proved red by
300 // `--break noraise`.
301 const raisedBefore = await page.evaluate(() => window.DaimondImprove.raisedProposalNumbers());
302 check('nothing is recorded as raised before the direct post (the batch does not record)',
303 raisedBefore.length === 0, JSON.stringify(raisedBefore));
304 const posted = await page.evaluate(async () => {
305 const box = document.getElementById('improve-box');
306 if (!box) return { ok: false, why: 'no compose box on the proposals surface' };
307 box.value = 'Directly posted title\nthe body of a note posted straight from the box';
308 box.dispatchEvent(new Event('input', { bubbles: true }));
309 const rec = await window.DaimondImprove.submit('verbatim');
310 return { ok: !!rec };
311 });
312 check('the direct verbatim post was submitted', posted.ok, JSON.stringify(posted));
313 await page.waitForTimeout(400);
314 const raisedAfter = await page.evaluate(() => window.DaimondImprove.raisedProposalNumbers());
315 check('the directly-posted proposal number enters raisedProposalNumbers()',
316 raisedAfter.length === 1 && raisedAfter[0] >= 100, JSON.stringify(raisedAfter));
317
318 await shot(s, 'proposalsappear' + (BREAK ? '-' + BREAK : ''));
319
320 const errs = errors(s).filter(e => !/Failed to load resource/.test(e));
321 check('nothing above was reached by way of an unhandled error', errs.length === 0,
322 errs.slice(0, 3).join(' | '));
323} finally {
324 await s.close();
325}
326
327console.log(`\nforge opens: ${opens().length}`);
328if (BREAK) {
329 console.log(`\nbreak '${BREAK}': ${bad.length} check(s) failed`
330 + (bad.length ? ' — ' + bad.join('; ') : ' — NOTHING FAILED, so the checks above prove nothing'));
331 process.exit(bad.length ? 0 : 1);
332}
333console.log(bad.length === 0 ? `\nall ${ok.length} checks passed` : `\n${bad.length} check(s) FAILED`);
334process.exit(bad.length === 0 ? 0 : 1);