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 | |
| 38 | import fs from 'node:fs'; |
| 39 | import path from 'node:path'; |
| 40 | import { fileURLToPath } from 'node:url'; |
| 41 | import { open, shot, scratch, errors, signInAs } from './harness.mjs'; |
| 42 | |
| 43 | const HERE = path.dirname(fileURLToPath(import.meta.url)); |
| 44 | const WWW = path.join(HERE, '..', 'www'); |
| 45 | |
| 46 | const BREAK = (() => { |
| 47 | const i = process.argv.indexOf('--break'); |
| 48 | return i > 0 ? String(process.argv[i + 1] || '') : ''; |
| 49 | })(); |
| 50 | |
| 51 | const PROFILE = scratch('pw', 'proposalsappear' + (BREAK ? '-' + BREAK : '')); |
| 52 | fs.rmSync(PROFILE, { recursive: true, force: true }); |
| 53 | |
| 54 | const ok = [], bad = []; |
| 55 | const 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 | |
| 64 | const 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 | |
| 71 | function 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 | |
| 87 | const 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 | |
| 103 | if (BREAK && !BREAKS[BREAK]) { |
| 104 | console.error(`unknown break '${BREAK}'; one of: ${Object.keys(BREAKS).join(', ')}`); |
| 105 | process.exit(2); |
| 106 | } |
| 107 | |
| 108 | function 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 | |
| 118 | const FILES = new Map(); |
| 119 | function 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 | } |
| 129 | build(); |
| 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 | |
| 135 | const HDR = 'x-daimond-voice'; |
| 136 | // allowlist secret |
| 137 | const SECRET = 'mock-voice-ada-0000000000000'; |
| 138 | |
| 139 | let nextNew = 100; |
| 140 | |
| 141 | function 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 | |
| 147 | function 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 | |
| 153 | const asked = []; |
| 154 | |
| 155 | async 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 | |
| 182 | const j = (body, status = 200) => ({ status, contentType: 'application/json', body: JSON.stringify(body) }); |
| 183 | |
| 184 | async 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 | |
| 198 | const 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 | |
| 203 | const 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 | |
| 209 | const s = await open({ name: 'proposalsappear', profile: PROFILE, signIn: false, connect: false, route: stub }); |
| 210 | const { page } = s; |
| 211 | |
| 212 | await signInAs(s, 'proposalsappear'); |
| 213 | await page.waitForTimeout(1200); |
| 214 | |
| 215 | try { |
| 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 | |
| 327 | console.log(`\nforge opens: ${opens().length}`); |
| 328 | if (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 | } |
| 333 | console.log(bad.length === 0 ? `\nall ${ok.length} checks passed` : `\n${bad.length} check(s) FAILED`); |
| 334 | process.exit(bad.length === 0 ? 0 : 1); |