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 | |
| 45 | import fs from 'node:fs'; |
| 46 | import path from 'node:path'; |
| 47 | import { fileURLToPath } from 'node:url'; |
| 48 | import { open, shot, scratch, errors, signInAs } from './harness.mjs'; |
| 49 | |
| 50 | const HERE = path.dirname(fileURLToPath(import.meta.url)); |
| 51 | const WWW = path.join(HERE, '..', 'www'); |
| 52 | |
| 53 | const BREAK = (() => { |
| 54 | const i = process.argv.indexOf('--break'); |
| 55 | return i > 0 ? String(process.argv[i + 1] || '') : ''; |
| 56 | })(); |
| 57 | |
| 58 | const PROFILE = scratch('pw', 'approvelist' + (BREAK ? '-' + BREAK : '')); |
| 59 | fs.rmSync(PROFILE, { recursive: true, force: true }); |
| 60 | |
| 61 | const ok = [], bad = []; |
| 62 | const 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 | |
| 69 | const 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 | |
| 76 | function 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 | |
| 92 | const 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 | |
| 133 | if (BREAK && !BREAKS[BREAK]) { |
| 134 | console.error(`unknown break '${BREAK}'; one of: ${Object.keys(BREAKS).join(', ')}`); |
| 135 | process.exit(2); |
| 136 | } |
| 137 | |
| 138 | function 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 | |
| 148 | const FILES = new Map(); |
| 149 | function 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 | } |
| 158 | build(); |
| 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 | |
| 166 | const HDR = 'x-daimond-voice'; |
| 167 | // allowlist secret |
| 168 | const SECRET = 'mock-voice-ada-0000000000000'; |
| 169 | const 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. |
| 175 | const 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). |
| 189 | function 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 | |
| 199 | function 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 | |
| 208 | const asked = []; // every request that reached /api/improve |
| 209 | const wire = []; // every request the page made, for the sync/parcel check |
| 210 | |
| 211 | async 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 | |
| 250 | const json = (body, status = 200) => ({ status, contentType: 'application/json', body: JSON.stringify(body) }); |
| 251 | |
| 252 | async 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 | |
| 277 | const opens = () => asked.filter(a => a.method === 'POST' && a.query.n === undefined); |
| 278 | const says = () => asked.filter(a => a.method === 'POST' && a.query.n !== undefined |
| 279 | && a.query.vote === undefined && a.query.amend === undefined); |
| 280 | const revisions = () => asked.filter(a => a.method === 'POST' && a.query.amend === '1'); |
| 281 | const posts = () => asked.filter(a => a.method === 'POST'); |
| 282 | |
| 283 | const 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 | |
| 292 | const 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 | |
| 303 | const s = await open({ name: 'approvelist', profile: PROFILE, signIn: false, connect: false, route: stub }); |
| 304 | const { page } = s; |
| 305 | |
| 306 | await signInAs(s, 'approvelist'); |
| 307 | await page.waitForTimeout(1200); |
| 308 | |
| 309 | try { |
| 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 | |
| 555 | console.log(`\nforge posts: ${posts().length} opens: ${opens().length}` |
| 556 | + ` comments: ${says().length} revisions: ${revisions().length}`); |
| 557 | if (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 | } |
| 562 | console.log(bad.length === 0 ? `\nall ${ok.length} checks passed` : `\n${bad.length} check(s) FAILED`); |
| 563 | process.exit(bad.length === 0 ? 0 : 1); |