Oregami
Repositories/oxedyne/daimond

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
39import fs from 'node:fs';
40import path from 'node:path';
41import { fileURLToPath, pathToFileURL } from 'node:url';
42import { PW, CHROME, scratch } from './harness.mjs';
43
44const { chromium } = await import(pathToFileURL(PW).href);
45
46const HERE = path.dirname(fileURLToPath(import.meta.url));
47const WWW = path.join(HERE, '..', 'www');
48
49const BREAK = (() => {
50 const i = process.argv.indexOf('--break');
51 return i > 0 ? String(process.argv[i + 1] || '') : '';
52})();
53
54const PROFILE = scratch('pw', 'returned' + (BREAK ? '-' + BREAK : ''));
55fs.rmSync(PROFILE, { recursive: true, force: true });
56
57const ok = [], bad = [];
58const 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.
66const 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
84if (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.
90const FILES = new Map();
91function 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}
100function 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}
107build();
108
109const 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
116const VOICE = 'caller-voice-secret-2026';
117let store = []; // [{ when, title, reason }, …]
118const reqs = []; // { method, url, voice, ctype, body }
119
120function 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
126async 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
162const 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>
168window.__voiceHeld = true;
169window.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
183const ORIGIN = 'https://daimond.test';
184
185async 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
268await run();
269
270console.log(`\n${ok.length} ok, ${bad.length} failed`);
271process.exit(bad.length ? 1 : 0);