oxedyne/daimond/dev/verify_returned.mjs
14.4 KiB, 1 run
created by r2519314175:2505, 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_returned.mjs — the proposer's RETURNED-NOTES inbox (js/improve.js). |
| 2 | // |
| 3 | // A proposal this device raised may be DECLINED by an operator, and the decline carries a one-line |
| 4 | // reason BACK to the proposer. Unlike the rest of the Social panel -- which has no change feed and is |
| 5 | // read by looking (contract §7) -- a returned note is news the writer would not otherwise find, so it |
| 6 | // is read into the capture view beside the box the note was written in, and cleared PER ENTRY once |
| 7 | // seen. Two doors, behind ONE client constant so a path firm-up is a one-line change: |
| 8 | // |
| 9 | // GET /<account>/<name>/proposals/returned?format=json header x-ore-voice -> { returned:[…] } |
| 10 | // POST /<account>/<name>/proposals/returned/ack?format=json header x-ore-voice FORM acked=<when>[,…] |
| 11 | // |
| 12 | // which the client reaches through the same-origin gateway route `/api/improve?…&returned=1[&ack=1]`, |
| 13 | // exactly as vote (`&vote=1`) and amend (`&amend=1`) do. The gateway (another session's) forwards to |
| 14 | // the forge and derives the caller from the voice; this test STANDS IN FOR the gateway+forge with an |
| 15 | // in-memory store, so it can prove what the CLIENT put on the wire: that the ACK body is a FORM and |
| 16 | // not a JSON `{acked:[…]}`, that a dismiss acks that note's OWN `when` and not the whole inbox, and |
| 17 | // that the GET carries the caller's voice. |
| 18 | // |
| 19 | // WHAT IS PROVED: |
| 20 | // 1. THE INBOX READS. loadReturned() GETs the returned notes and drawReturned() draws one line per |
| 21 | // note, "Your proposal '<title>' was declined: <reason>", newest first. |
| 22 | // 2. THE READ CARRIES THE VOICE. The GET rides the caller's voice header; there is no name in it. |
| 23 | // 3. NO VOICE, NO INBOX. With no voice held, loadReturned() is a quiet no-op: no request, no rows. |
| 24 | // 4. A DISMISS ACKS PER ENTRY, AS A FORM. Dismissing one note posts `acked=<that when>` (a form, |
| 25 | // NOT a JSON body), and the forge's remainder becomes the shown list -- the dismissed note is |
| 26 | // gone, the others stay. |
| 27 | // 5. THE ACK NAMES ONLY THE SEEN KEY. Dismissing the first of three acks ONLY its `when`, never all |
| 28 | // three -- per entry, never delete-whole. |
| 29 | // |
| 30 | // EACH CHECK IS PROVED AGAINST BROKEN CODE FIRST. `--break <name>` serves a damaged improve.js and the |
| 31 | // run is expected to FAIL the one check it targets: |
| 32 | // node dev/verify_returned.mjs --break ackjson # 4 the ACK posts a JSON body, not a form |
| 33 | // node dev/verify_returned.mjs --break dropwhole # 5 a dismiss acks the whole inbox, not one key |
| 34 | // node dev/verify_returned.mjs # and then, clean |
| 35 | // |
| 36 | // Needs playwright-core (resolved via dev/harness.mjs) and node. No dev server, no Rust: the page and |
| 37 | // the module are served from disk through page.route, and the gateway+forge is an in-memory store. |
| 38 | |
| 39 | import fs from 'node:fs'; |
| 40 | import path from 'node:path'; |
| 41 | import { fileURLToPath, pathToFileURL } from 'node:url'; |
| 42 | import { PW, CHROME, scratch } from './harness.mjs'; |
| 43 | |
| 44 | const { chromium } = await import(pathToFileURL(PW).href); |
| 45 | |
| 46 | const HERE = path.dirname(fileURLToPath(import.meta.url)); |
| 47 | const WWW = path.join(HERE, '..', 'www'); |
| 48 | |
| 49 | const BREAK = (() => { |
| 50 | const i = process.argv.indexOf('--break'); |
| 51 | return i > 0 ? String(process.argv[i + 1] || '') : ''; |
| 52 | })(); |
| 53 | |
| 54 | const PROFILE = scratch('pw', 'returned' + (BREAK ? '-' + BREAK : '')); |
| 55 | fs.rmSync(PROFILE, { recursive: true, force: true }); |
| 56 | |
| 57 | const ok = [], bad = []; |
| 58 | const check = (name, pass, detail) => { |
| 59 | (pass ? ok : bad).push(name); |
| 60 | console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : '')); |
| 61 | }; |
| 62 | |
| 63 | // ── The broken copies ──────────────────────────────────────────────── |
| 64 | // Each edit is a real change to www/js/improve.js, served in place of it. `find` must appear exactly |
| 65 | // once, or the run aborts: a break that matched nothing would prove the opposite of what it claims. |
| 66 | const BREAKS = { |
| 67 | // The ACK posts a JSON `{acked:[…]}` body instead of a form. The contract's machine surface reads |
| 68 | // a form and REJECTS a `{`/`[` body, so this is the exact mistake the form rule forbids. Bites |
| 69 | // check 4: the captured ACK body is no longer `acked=<when>`. |
| 70 | ackjson: [{ |
| 71 | file: 'js/improve.js', |
| 72 | find: "\t\tvar f = new URLSearchParams();\n\t\tf.set('acked', keys.join(','));\n\t\tvar a = await ask(route(RETURNED + '&ack=1'), {\n\t\t\tmethod: 'POST',\n\t\t\theaders: { 'Content-Type': 'application/x-www-form-urlencoded' },\n\t\t\tbody: f.toString(),\n\t\t});", |
| 73 | with: "\t\tvar a = await ask(route(RETURNED + '&ack=1'), {\n\t\t\tmethod: 'POST',\n\t\t\theaders: { 'Content-Type': 'application/json' },\n\t\t\tbody: JSON.stringify({ acked: keys }),\n\t\t});", |
| 74 | }], |
| 75 | // A dismiss acks the WHOLE inbox rather than the one note seen -- delete-whole, which the per-entry |
| 76 | // rule forbids. Bites check 5: dismissing the first of three acks all three. |
| 77 | dropwhole: [{ |
| 78 | file: 'js/improve.js', |
| 79 | find: "\t\t\tackReturned(Number(b.dataset.when));", |
| 80 | with: "\t\t\tackReturned(_returned.notes.map(function (x) { return x.when; }));", |
| 81 | }], |
| 82 | }; |
| 83 | |
| 84 | if (BREAK && !BREAKS[BREAK]) { |
| 85 | console.error(`unknown break '${BREAK}'; one of: ${Object.keys(BREAKS).join(', ')}`); |
| 86 | process.exit(2); |
| 87 | } |
| 88 | |
| 89 | /// The file as served: this run's break on top of what is on disk. |
| 90 | const FILES = new Map(); |
| 91 | function edit(src, spec) { |
| 92 | const n = src.split(spec.find).length - 1; |
| 93 | if (n !== 1) { |
| 94 | console.error(`break '${BREAK}': the anchor appears ${n} times in ${spec.file}, so nothing ` |
| 95 | + 'was changed and the run below would prove nothing.'); |
| 96 | process.exit(2); |
| 97 | } |
| 98 | return src.replace(spec.find, spec.with); |
| 99 | } |
| 100 | function build() { |
| 101 | if (!BREAK) return; |
| 102 | for (const spec of BREAKS[BREAK]) { |
| 103 | const src = FILES.get(spec.file) ?? fs.readFileSync(path.join(WWW, spec.file), 'utf8'); |
| 104 | FILES.set(spec.file, edit(src, spec)); |
| 105 | } |
| 106 | } |
| 107 | build(); |
| 108 | |
| 109 | const improveSrc = () => FILES.get('js/improve.js') ?? fs.readFileSync(path.join(WWW, 'js', 'improve.js'), 'utf8'); |
| 110 | |
| 111 | // ── The gateway+forge, stood in for by an in-memory store ──────────── |
| 112 | // The store holds the caller's returned notes. A GET answers them; the ACK reads the form `acked=`, |
| 113 | // drops those `when` keys, and answers the remainder. Every request is captured so the checks can read |
| 114 | // the voice header, the method, and the raw body the client actually sent. |
| 115 | |
| 116 | const VOICE = 'caller-voice-secret-2026'; |
| 117 | let store = []; // [{ when, title, reason }, …] |
| 118 | const reqs = []; // { method, url, voice, ctype, body } |
| 119 | |
| 120 | function returnedFor(u) { |
| 121 | // The gateway maps `&returned=1[&ack=1]` to the forge's returned door, exactly as it maps |
| 122 | // `&vote=1` and `&amend=1`. Nothing else is consulted here; the account/repo ride in the query. |
| 123 | return u.searchParams.get('returned') === '1'; |
| 124 | } |
| 125 | |
| 126 | async function storeRoute(route) { |
| 127 | const req = route.request(); |
| 128 | const u = new URL(req.url()); |
| 129 | const method = req.method(); |
| 130 | const headers = req.headers(); |
| 131 | const body = req.postData() || ''; |
| 132 | reqs.push({ method, url: req.url(), voice: headers['x-ore-voice'] || headers['x-daimond-voice'] || '', |
| 133 | ctype: headers['content-type'] || '', body }); |
| 134 | |
| 135 | if (!returnedFor(u)) { |
| 136 | return route.fulfill({ status: 404, contentType: 'application/json', |
| 137 | body: JSON.stringify({ error: 'no_proposal' }) }); |
| 138 | } |
| 139 | // The forge derives the caller from the voice; an unvoiced write is refused before it is read. |
| 140 | if (method === 'POST' && !(headers['x-ore-voice'] || headers['x-daimond-voice'])) { |
| 141 | return route.fulfill({ status: 401, contentType: 'application/json', |
| 142 | body: JSON.stringify({ error: 'unvoiced' }) }); |
| 143 | } |
| 144 | if (method === 'POST' && u.searchParams.get('ack') === '1') { |
| 145 | // Read the FORM body only. A JSON body has no `acked` field here, so the break that sends one |
| 146 | // acks nothing and the store never shrinks -- which is exactly what the machine surface does. |
| 147 | const acked = new URLSearchParams(body).get('acked') || ''; |
| 148 | const drop = acked.split(/[ ,]+/).map(s => Number(s)).filter(n => n > 0); |
| 149 | store = store.filter(r => !drop.includes(r.when)); |
| 150 | } |
| 151 | return route.fulfill({ status: 200, contentType: 'application/json', |
| 152 | body: JSON.stringify({ returned: store.slice() }) }); |
| 153 | } |
| 154 | |
| 155 | // ── The harness page ───────────────────────────────────────────────── |
| 156 | // A bare page that holds the capture-view hosts js/improve.js draws into (#improve-returned and the |
| 157 | // flash target #improve-say), inside #panel-social so the module's delegated click listener fires. A |
| 158 | // minimal DaimondVoice stub sends the caller's voice on x-ore-voice -- the header the forge reads -- |
| 159 | // so the checks can prove the read is voiced and the ACK body is a form. `has()` is toggled to drive |
| 160 | // the no-voice path. |
| 161 | |
| 162 | const PAGE = `<!doctype html><meta charset="utf-8"><title>Returned-notes harness</title> |
| 163 | <body><div id="panel-social"> |
| 164 | <div id="improve-returned" hidden></div> |
| 165 | <div id="improve-say"></div> |
| 166 | </div> |
| 167 | <script> |
| 168 | window.__voiceHeld = true; |
| 169 | window.DaimondVoice = { |
| 170 | has: function () { return !!window.__voiceHeld; }, |
| 171 | send: function (path, opts) { |
| 172 | var o = Object.assign({}, opts || {}); |
| 173 | o.headers = Object.assign({}, (opts && opts.headers) || {}); |
| 174 | // The gateway translates x-daimond-voice into the forge's x-ore-voice; the store reads |
| 175 | // either, so send the forge's own spelling here and prove the caller's voice rode the read. |
| 176 | o.headers['x-ore-voice'] = ${JSON.stringify(VOICE)}; |
| 177 | return fetch(path, o); |
| 178 | }, |
| 179 | }; |
| 180 | </script> |
| 181 | <script src="/js/improve.js"></script></body>`; |
| 182 | |
| 183 | const ORIGIN = 'https://daimond.test'; |
| 184 | |
| 185 | async function run() { |
| 186 | fs.mkdirSync(PROFILE, { recursive: true }); |
| 187 | const env = Object.assign({}, process.env); |
| 188 | delete env.DISPLAY; |
| 189 | const browser = await chromium.launchPersistentContext(PROFILE, { |
| 190 | executablePath: CHROME, headless: true, args: ['--no-sandbox', '--disable-dev-shm-usage', '--headless=new'], |
| 191 | env, viewport: { width: 900, height: 700 }, |
| 192 | }); |
| 193 | const page = browser.pages()[0] || await browser.newPage(); |
| 194 | const errs = []; |
| 195 | page.on('pageerror', e => errs.push(String(e.message))); |
| 196 | |
| 197 | await page.route(`${ORIGIN}/`, r => r.fulfill({ status: 200, contentType: 'text/html', body: PAGE })); |
| 198 | await page.route(`${ORIGIN}/js/improve.js`, r => r.fulfill({ status: 200, contentType: 'application/javascript', body: improveSrc() })); |
| 199 | await page.route(`${ORIGIN}/api/improve*`, storeRoute); |
| 200 | |
| 201 | const sleep = (ms) => new Promise(r => setTimeout(r, ms)); |
| 202 | |
| 203 | try { |
| 204 | await page.goto(`${ORIGIN}/`, { waitUntil: 'domcontentloaded' }); |
| 205 | |
| 206 | // ── 1. The inbox reads and draws ───────────────────────────── |
| 207 | store = [ |
| 208 | { when: 1756700000, title: 'Dark-mode contrast', reason: 'out of scope for this cycle' }, |
| 209 | { when: 1756800000, title: 'Faster cold start', reason: 'already tracked as #41' }, |
| 210 | { when: 1756900000, title: 'Export to CSV', reason: 'not a fit for the product' }, |
| 211 | ]; |
| 212 | reqs.length = 0; |
| 213 | await page.evaluate(() => window.DaimondImprove.loadReturned()); |
| 214 | await sleep(300); |
| 215 | |
| 216 | const rows = page.locator('#improve-returned .imp-returned-one'); |
| 217 | check('the inbox draws one row per returned note', (await rows.count()) === 3, `${await rows.count()} rows`); |
| 218 | const texts = (await page.locator('#improve-returned .imp-returned-said').allInnerTexts()).map(s => s.trim()); |
| 219 | check("a row reads \"Your proposal '<title>' was declined: <reason>\"", |
| 220 | texts.some(s => s.includes("Export to CSV") && s.includes('not a fit for the product')), texts.join(' | ')); |
| 221 | check('the notes are drawn newest first', |
| 222 | texts[0].includes('Export to CSV') && texts[2].includes('Dark-mode contrast'), texts.join(' | ')); |
| 223 | |
| 224 | // ── 2. The read carried the voice, and no name ────────────── |
| 225 | const get0 = reqs.find(r => r.method === 'GET'); |
| 226 | check('the returned GET fired', !!get0, `${reqs.length} request(s)`); |
| 227 | check('the returned GET carried the caller voice on x-ore-voice', !!get0 && get0.voice === VOICE, get0 && get0.voice); |
| 228 | check('the returned GET named the returned door and no name', |
| 229 | !!get0 && /[?&]returned=1(&|$)/.test(get0.url) && !/name=|address=/.test(get0.url), get0 && get0.url); |
| 230 | |
| 231 | // ── 4/5. A dismiss acks that note's OWN when, as a FORM ───── |
| 232 | reqs.length = 0; |
| 233 | const csvWhen = 1756900000; // the newest, drawn first |
| 234 | await page.locator(`#improve-returned .imp-returned-one[data-when="${csvWhen}"] [data-act="improve-returned-dismiss"]`).click(); |
| 235 | await sleep(300); |
| 236 | const ack0 = reqs.find(r => r.method === 'POST'); |
| 237 | check('dismissing a note fired an ACK write', !!ack0, `${reqs.length} request(s)`); |
| 238 | check('the ACK is a form body, not JSON', |
| 239 | !!ack0 && /application\/x-www-form-urlencoded/.test(ack0.ctype) && ack0.body.indexOf('{') !== 0, ack0 && `${ack0.ctype} :: ${ack0.body}`); |
| 240 | const af = ack0 ? Object.fromEntries(new URLSearchParams(ack0.body)) : {}; |
| 241 | check('the ACK body is acked=<that when> and only that key', |
| 242 | JSON.stringify(Object.keys(af)) === JSON.stringify(['acked']) && af.acked === String(csvWhen), JSON.stringify(af)); |
| 243 | check('the ACK carried the caller voice on x-ore-voice', !!ack0 && ack0.voice === VOICE, ack0 && ack0.voice); |
| 244 | |
| 245 | // The dismissed note is gone; the other two stay -- per entry, never delete-whole. |
| 246 | await sleep(150); |
| 247 | const left = await page.evaluate(() => window.DaimondImprove.returned().map(r => r.when).sort((a, b) => a - b)); |
| 248 | check('only the dismissed note was acked; the rest remain', |
| 249 | JSON.stringify(left) === JSON.stringify([1756700000, 1756800000]), JSON.stringify(left)); |
| 250 | check('the store dropped only the acked note', JSON.stringify(store.map(r => r.when).sort((a, b) => a - b)) === JSON.stringify([1756700000, 1756800000]), JSON.stringify(store.map(r => r.when))); |
| 251 | check('the drawn rows fell to two', (await rows.count()) === 2, `${await rows.count()} rows`); |
| 252 | |
| 253 | // ── 3. No voice, no inbox ──────────────────────────────────── |
| 254 | await page.evaluate(() => { window.DaimondImprove.reset(); window.__voiceHeld = false; }); |
| 255 | reqs.length = 0; |
| 256 | await page.evaluate(() => window.DaimondImprove.loadReturned()); |
| 257 | await sleep(200); |
| 258 | check('with no voice, the inbox reads nothing (no request)', reqs.length === 0, `${reqs.length} request(s)`); |
| 259 | check('with no voice, no rows are drawn and the host is hidden', |
| 260 | (await rows.count()) === 0 && (await page.locator('#improve-returned[hidden]').count()) === 1); |
| 261 | |
| 262 | check('no page errors were thrown', errs.length === 0, errs.join(' | ')); |
| 263 | } finally { |
| 264 | await browser.close(); |
| 265 | } |
| 266 | } |
| 267 | |
| 268 | await run(); |
| 269 | |
| 270 | console.log(`\n${ok.length} ok, ${bad.length} failed`); |
| 271 | process.exit(bad.length ? 1 : 0); |