Oregami
Repositories/oxedyne/daimond

oxedyne/daimond/dev/verify_tracker.mjs

53.9 KiB, 71 runs

created by r2519314175:755, 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_tracker.mjs — the Tracker BOARD reads Daimond's proposals, settles them, and stamps ships.
2//
3// The Tracker (www/js/tracker.js) is a DECISION-QUEUE BOARD onto Daimond's own development, which
4// lives as PROPOSALS on the Oregami forge repository `oxedyne/daimond`. Four columns, the life of
5// a proposal left to right: Awaiting you (open) -> Greenlit (accepted) -> Shipped (done) ->
6// Dropped (declined). It reaches the forge the way improve.js does: through the same-origin
7// Daimond gateway route `/api/improve`, which forwards to the forge and translates
8// `x-daimond-voice` into the forge's `x-ore-voice`. READS ARE PUBLIC AND UNVOICED; only a settle
9// carries the admin voice. This drives the view in a real browser; the gateway is STOOD IN FOR
10// here (as verify_improve.mjs stands it in), and the forge behind it is dev/mock_forge.mjs.
11//
12// WHAT IS PROVED, each a clause of what the board is for:
13// NODE, against a wire-capture server (dev/forge.mjs `ship`):
14// 0a. SHIP STAMPS THE REAL BUILD. `ship` reads the deployed build id from build.json and
15// comments "Shipped in build <id>" — the id from the FILE, never a constant, carried under
16// the pull voice. A different build.json changes the stamp; a build.json with no id THROWS
17// and posts NOTHING (the board would rather show no stamp than an invented one).
18// BROWSER, against the mock forge:
19// 1. THE BOARD DRAWS FOUR COLUMNS, each proposal under the column matching its state, newest
20// first within a column.
21// 2. A CARD carries its number, title, comment count and vote TALLY.
22// 3. VOTES ARE DARK. The tally is two counts and the DOM carries no voter identity anywhere.
23// 4. READING IS UNVOICED. Every read the board makes carries no voice header at all, even when
24// an admin voice is held.
25// 5. NO DEAD SETTLE BUTTONS. With no admin voice there is NO settle control — only the terse
26// "add your settle voice" affordance; pasting a voice REVEALS the controls.
27// 6. THE OWNER SETTLES FROM THE BOARD, WITH A REQUIRED REASON. With an admin voice, Accept on an
28// Awaiting-you card opens a one-line reason box (it does not settle yet); an empty confirm is
29// refused with no write; a filled one posts `state=accepted` AND `reason` under the voice and
30// the card moves to Greenlit. Decline the same, posting `state=declined` AND `reason`. Reopen
31// and Mark done carry `state` alone. Reason max is REASON_LIMIT (1000).
32// 7. A SHIPPED CARD STAMPS THE REAL BUILD. The board PARSES the ship stamp out of the proposal's
33// comments and draws the id, clickable; it reads the id, it does not invent one.
34// 8. A PROPOSAL OPENS IN FULL, and a refusal is SAID, not swallowed.
35// 9. ALL / MINE. The board defaults to All; Mine shows only the proposals THIS DEVICE raised,
36// read from DaimondImprove.raisedProposalNumbers() (author cannot match — the local voice has
37// no name). With no capture surface, Mine shows a "nothing raised" state and never throws.
38// 10. VOTE AND COMMENT ARE RE-HOMED HERE, cast with the PULL voice through the Social panel's own
39// doors (DaimondImprove.forge.vote / .say) — never a copy of the POST in tracker.js. Reading a
40// tally or a thread needs no voice; with no pull voice the card shows the "set a voice"
41// affordance, not a control that the forge would refuse.
42// 11. AN UPVOTE INCREMENTS THE SHOWN COUNT, drawn from the forge's own answer and never a second
43// copy: the board folds the record the vote returned and redraws, and the upvote reads pressed.
44// 12. PRESSING AN UPVOTE ALREADY CAST WITHDRAWS IT — d=0, and the count falls back.
45// 13. A POSTED COMMENT APPEARS in the opened card's thread, from the answer the comment returned.
46// 14. A RE-SHOWN BOARD REFETCHES. A proposal declined on ANOTHER device (a settle the board never
47// made) moves from "Awaiting you" to "Dropped" when the panel is re-shown past the refresh
48// throttle -- the board no longer reads once and freezes.
49// 15. A FAILED READ IS THROTTLED. Once the board has read, an erroring forge is refetched at most
50// once per REFRESH_MS, because a failed read stamps the throttle exactly as a good one does.
51//
52// EACH CHECK IS PROVED AGAINST BROKEN CODE FIRST. `--break <name>` serves a damaged tracker.js
53// and the run is expected to FAIL the one check it targets:
54// node dev/verify_tracker.mjs --break miscolumn # 1 a proposal lands in the wrong column
55// node dev/verify_tracker.mjs --break leakvoters # 3 a voter name reaches the DOM
56// node dev/verify_tracker.mjs --break voicedread # 4 a read carries a voice
57// node dev/verify_tracker.mjs --break alwayssettle # 5 settle drawn with no voice
58// node dev/verify_tracker.mjs --break noreasongate # 6 Accept settles with no reason box
59// node dev/verify_tracker.mjs --break emptyreasonok # 6 an empty reason is sent, not refused
60// node dev/verify_tracker.mjs --break shipinvent # 7 the shipped stamp is invented
61// node dev/verify_tracker.mjs --break swallow # 8 a refusal drawn as blank
62// node dev/verify_tracker.mjs --break minefilter # 9 Mine shows more than this device raised
63// node dev/verify_tracker.mjs --break votedark # 10 a live upvote drawn with no pull voice
64// node dev/verify_tracker.mjs --break votenofold # 11 the vote answer is not folded back
65// node dev/verify_tracker.mjs --break voteonlyup # 12 an upvote cannot be withdrawn
66// node dev/verify_tracker.mjs --break commentswallow # 13 a posted comment never appears
67// node dev/verify_tracker.mjs --break freeze # 14 a re-shown board never refetches
68// node dev/verify_tracker.mjs --break nothrottlefail # 15 a failed read leaves the forge unfloored
69// node dev/verify_tracker.mjs # and then, clean
70//
71// Needs playwright-core (resolved via dev/harness.mjs) and node. No dev server, no Rust: the page
72// and the module are served from disk through page.route, and the forge is the mock, proxied.
73
74import fs from 'node:fs';
75import http from 'node:http';
76import path from 'node:path';
77import { spawn } from 'node:child_process';
78import { fileURLToPath, pathToFileURL } from 'node:url';
79import { PW, CHROME, scratch } from './harness.mjs';
80import * as forge from './forge.mjs';
81
82const { chromium } = await import(pathToFileURL(PW).href);
83
84const HERE = path.dirname(fileURLToPath(import.meta.url));
85const WWW = path.join(HERE, '..', 'www');
86
87const BREAK = (() => {
88 const i = process.argv.indexOf('--break');
89 return i > 0 ? String(process.argv[i + 1] || '') : '';
90})();
91
92const PROFILE = scratch('pw', 'tracker' + (BREAK ? '-' + BREAK : ''));
93fs.rmSync(PROFILE, { recursive: true, force: true });
94
95const ok = [], bad = [];
96const check = (name, pass, detail) => {
97 (pass ? ok : bad).push(name);
98 console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : ''));
99};
100
101// ── The broken copies ────────────────────────────────────────────────
102// Each edit is a real change to www/js/tracker.js, served in place of it. `find` must appear
103// exactly once, or the run aborts: a break that matched nothing would prove the opposite of what
104// it claims.
105const BREAKS = {
106 // A proposal lands in the wrong column: the Awaiting-you column is pointed at `accepted`, so
107 // the five open proposals appear nowhere and three accepted ones sit under "Awaiting you".
108 miscolumn: [{
109 file: 'js/tracker.js',
110 find: "\t\t{ state: 'open', key: 'col_open', label: 'Awaiting you' },",
111 with: "\t\t{ state: 'accepted', key: 'col_open', label: 'Awaiting you' },",
112 }],
113 // A voter identity drawn into the tally. The forge never sends one, so this fabricates it —
114 // exactly the shape of the mistake the dark-vote rule forbids. The counts stay right, so only
115 // the dark check moves.
116 leakvoters: [{
117 file: 'js/tracker.js',
118 find: "\t\treturn el('span', 'trk-tally', tOr('tracker.tally', '{yes} for, {no} against',\n\t\t\t{ yes: p.votes.for, no: p.votes.against }));",
119 with: "\t\tvar s = el('span', 'trk-tally', tOr('tracker.tally', '{yes} for, {no} against',\n\t\t\t{ yes: p.votes.for, no: p.votes.against }));\n\t\ts.title = 'quokka-voter-leak voted for';\n\t\treturn s;",
120 }],
121 // A voice attached to a READ. Reads stay public, so a read must carry none; this makes the
122 // listing read send the admin voice on its GET. It bites where an admin voice is held.
123 voicedread: [{
124 file: 'js/tracker.js',
125 find: "\t\tvar a = await request(route('limit=' + PAGE), { method: 'GET' });",
126 with: "\t\tvar a = await request(route('limit=' + PAGE), { method: 'GET' }, cfg.voice);",
127 }],
128 // Settle controls drawn with no admin voice: a control that belongs to the owner offered to a
129 // reader who cannot use it.
130 alwayssettle: [{
131 file: 'js/tracker.js',
132 find: "\t\tif (!canSettle()) return null;\n\t\tvar acts = el('div', 'trk-settle');",
133 with: "\t\tvar acts = el('div', 'trk-settle');",
134 }],
135 // The shipped stamp INVENTED. `parseShip` stops reading the comment and returns a constant id
136 // for any discussion, so the board draws a build that was never stamped.
137 shipinvent: [{
138 file: 'js/tracker.js',
139 find: "\t\tfor (var i = discussion.length - 1; i >= 0; i--) {\n\t\t\tvar said = discussion[i] && discussion[i].said;\n\t\t\tvar m = (typeof said === 'string') ? SHIP_RE.exec(said) : null;\n\t\t\tif (m) return m[1];\n\t\t}\n\t\treturn '';",
140 with: "\t\treturn discussion.length ? 'ffffffffffff' : '';",
141 }],
142 // A refusal swallowed: the error is dropped and the view draws blank.
143 swallow: [{
144 file: 'js/tracker.js',
145 find: "\t\tif (_st.err) _host.appendChild(el('div', 'trk-err', saying(_st.err)));",
146 with: "\t\tif (_st.err && false) _host.appendChild(el('div', 'trk-err', saying(_st.err)));",
147 }],
148 // The Mine filter ignores the raised set, so Mine shows every proposal rather
149 // than only the ones this device raised. Bites check 9: Mine is no longer Mine.
150 minefilter: [{
151 file: 'js/tracker.js',
152 find: "\t\t\t\tif (_filter === 'mine') return !!(mine && mine[n]);",
153 with: "\t\t\t\tif (_filter === 'mine') return true;",
154 }],
155 // A live upvote drawn WITHOUT a pull voice: a control that the forge would
156 // refuse, offered to a reader who cannot post. Bites check 10: the no-voice
157 // card must show the "set a voice" affordance, not a live button.
158 votedark: [{
159 file: 'js/tracker.js',
160 find: "\t\tif (pullVoice() && voteDoor()) {\n\t\t\tvar up = button('trk-vote-btn', 'tracker-vote',",
161 with: "\t\tif (true) {\n\t\t\tvar up = button('trk-vote-btn', 'tracker-vote',",
162 }],
163 // The vote answer is not folded back, so the shown count never moves and the
164 // upvote never reads pressed -- the tally kept in the client disagreeing with
165 // the forge, which is exactly what the one-store rule forbids. Bites check 11.
166 votenofold: [{
167 file: 'js/tracker.js',
168 find: "\t\tif (!a || !a.ok) { _st.err = a || { why: 'gateway' }; draw(); return false; }\n\t\tabsorb(clean(a.data));\n\t\tderiveFrom(whole(n));\n\t\t_st.err = null;\n\t\tdraw();\n\t\treturn true;\n\t}\n\n\t/// Say something on proposal",
169 with: "\t\tif (!a || !a.ok) { _st.err = a || { why: 'gateway' }; draw(); return false; }\n\t\tif (false) absorb(clean(a.data));\n\t\tderiveFrom(whole(n));\n\t\t_st.err = null;\n\t\tdraw();\n\t\treturn true;\n\t}\n\n\t/// Say something on proposal",
170 }],
171 // Pressing an upvote already cast sends d=1 again instead of d=0, so there is
172 // no way to take a vote back -- a pressed control that will not un-press. Bites
173 // check 12: the withdrawal body.
174 voteonlyup: [{
175 file: 'js/tracker.js',
176 find: "\t\tvar d = (p.asked && p.mine === 1) ? 0 : 1;",
177 with: "\t\tvar d = 1;",
178 }],
179 // A posted comment's answer is not folded back, so the comment never appears in
180 // the thread -- the reader is left unsure whether it was sent. Bites check 13.
181 commentswallow: [{
182 file: 'js/tracker.js',
183 find: "\t\tbox.value = '';\n\t\tabsorb(clean(a.data));",
184 with: "\t\tbox.value = '';\n\t\tif (false) absorb(clean(a.data));",
185 }],
186 // The board FREEZES on its first read: the observer disconnects after one fire and onOpen
187 // only ever loads when it has never read. So a proposal declined on another device stays in
188 // "Awaiting you", which is the exact regression the re-show refetch removed. Bites check 14:
189 // the re-shown board never sees the cross-device decline. TWO edits, both reverting the fix --
190 // the read-once guard in onOpen and the one-shot disconnect in the observer.
191 freeze: [{
192 file: 'js/tracker.js',
193 find: "\t\tif (!_st.read || Date.now() - _lastLoad >= REFRESH_MS) load();",
194 with: "\t\tif (!_st.read && !_st.loading) load();",
195 }, {
196 file: 'js/tracker.js',
197 find: "\t\t\t\t\t\tif (entries[i].isIntersecting) { onOpen(); return; }",
198 with: "\t\t\t\t\t\tif (entries[i].isIntersecting) { onOpen(); io.disconnect(); return; }",
199 }],
200 // A FAILED read does not stamp the throttle, so a board that has read once and then meets an
201 // erroring forge refetches on every re-show with no floor -- a down forge pounded once per
202 // panel show. Bites check 15: two rapid re-shows against an erroring forge fire two reads, not
203 // one.
204 nothrottlefail: [{
205 file: 'js/tracker.js',
206 find: "\t\tif (!a.ok) { _st.err = a; _lastLoad = Date.now(); draw(); return false; }",
207 with: "\t\tif (!a.ok) { _st.err = a; draw(); return false; }",
208 }],
209 // Accept and decline no longer ask for a reason: NEEDS_REASON is emptied, so
210 // the buttons settle at a press and the settle carries state alone. Bites the
211 // reason-box check (Accept must open a box, not post) and the "reason sent"
212 // checks. HALF 1.
213 noreasongate: [{
214 file: 'js/tracker.js',
215 find: "\tvar NEEDS_REASON = { accept: 1, decline: 1 };",
216 with: "\tvar NEEDS_REASON = {};",
217 }],
218 // The required-reason guard is neutered on BOTH sides -- the confirm and the
219 // settle -- so an empty reason is sent rather than refused. Bites the check
220 // that a confirm with an empty box fires NO write. HALF 1.
221 emptyreasonok: [{
222 file: 'js/tracker.js',
223 find: "\t\tif (need && !note) { flash(tOr('tracker.reason_empty', 'Give a one-line reason first.')); return false; }",
224 with: "\t\tif (false) { flash(tOr('tracker.reason_empty', 'Give a one-line reason first.')); return false; }",
225 }, {
226 file: 'js/tracker.js',
227 find: "\t\tif (!reason) { flash(tOr('tracker.reason_empty', 'Give a one-line reason first.')); return false; }\n\t\treturn settle(n, which, reason);",
228 with: "\t\treturn settle(n, which, reason);",
229 }],
230};
231
232if (BREAK && !BREAKS[BREAK]) {
233 console.error(`unknown break '${BREAK}'; one of: ${Object.keys(BREAKS).join(', ')}`);
234 process.exit(2);
235}
236
237/// The file as served: this run's break on top of what is on disk.
238const FILES = new Map();
239function edit(src, spec) {
240 const n = src.split(spec.find).length - 1;
241 if (n !== 1) {
242 console.error(`break '${BREAK}': the anchor appears ${n} times in ${spec.file}, so nothing `
243 + 'was changed and the run below would prove nothing.');
244 process.exit(2);
245 }
246 return src.replace(spec.find, spec.with);
247}
248function build() {
249 if (!BREAK) return;
250 for (const spec of BREAKS[BREAK]) {
251 const src = FILES.get(spec.file) ?? fs.readFileSync(path.join(WWW, spec.file), 'utf8');
252 FILES.set(spec.file, edit(src, spec));
253 }
254}
255build();
256
257const trackerSrc = () => FILES.get('js/tracker.js') ?? fs.readFileSync(path.join(WWW, 'js', 'tracker.js'), 'utf8');
258
259// ── NODE: dev/forge.mjs `ship` stamps the REAL build, against a wire capture ──────────
260// A capture server records exactly what `ship` put on the socket, so the stamp is proved by its
261// bytes rather than an effect: that the id is the FILE's and not a constant, that the credential
262// rode in x-ore-voice, and that a build.json with no id posts NOTHING at all.
263
264const CAP_PORT = 8453;
265const captured = [];
266const capSrv = http.createServer((req, res) => {
267 const chunks = [];
268 req.on('data', c => chunks.push(c));
269 req.on('end', () => {
270 captured.push({ method: req.method, url: req.url, headers: req.headers,
271 body: Buffer.concat(chunks).toString('utf8') });
272 const rec = { number: 7, title: 'x', state: 'done', author: 'ci', comments: 0,
273 votes: { for: 0, against: 0 }, opened: 1, changed: 1, mark: null, build: null,
274 body: '', discussion: [], revisions: [] };
275 const buf = Buffer.from(JSON.stringify(rec), 'utf8');
276 res.writeHead(200, { 'content-type': 'application/json', 'content-length': buf.length });
277 res.end(buf);
278 });
279});
280
281const PULL = 'mock-voice-grace'; // a comment (which a stamp is) needs only the pull voice
282
283/// Write a build.json fixture and hand back its path.
284function buildFile(name, obj) {
285 const p = path.join(PROFILE, name);
286 fs.mkdirSync(PROFILE, { recursive: true });
287 fs.writeFileSync(p, JSON.stringify(obj));
288 return p;
289}
290
291async function nodePhase() {
292 await new Promise((resolve, reject) => {
293 capSrv.on('error', reject);
294 capSrv.listen(CAP_PORT, '127.0.0.1', resolve);
295 });
296 const CAP = `http://127.0.0.1:${CAP_PORT}`;
297 const cfg = (voice) => ({ base: CAP, account: 'oxedyne', repo: 'daimond', voice });
298
299 const idA = '85bfe5f585b6';
300 const idB = '0011223344ff';
301 const fileA = buildFile('build_a.json', { build: idA, note: 'a' });
302 const fileB = buildFile('build_b.json', { build: idB, note: 'b' });
303 const fileBad = buildFile('build_bad.json', { note: 'no build id here' });
304
305 // A stamp with the file's id, carried under the pull voice, form-encoded on the proposal.
306 captured.length = 0;
307 await forge.ship(7, { buildFile: fileA, cfg: cfg(PULL) });
308 const c0 = captured[0] || { method: '', url: '', headers: {}, body: '' };
309 check('ship posts to the proposal on the forge', c0.method === 'POST' && /\/oxedyne\/daimond\/proposals\/7\?/.test(c0.url), c0.method + ' ' + c0.url);
310 check('the stamp carries the pull voice in x-ore-voice', c0.headers['x-ore-voice'] === PULL, String(c0.headers['x-ore-voice']));
311 const f0 = Object.fromEntries(new URLSearchParams(c0.body));
312 check('the stamp body is the said field alone', JSON.stringify(Object.keys(f0).sort()) === JSON.stringify(['said']), c0.body);
313 check('the stamp names the REAL build id from build.json', f0.said === `Shipped in build ${idA}`, f0.said);
314
315 // A different build.json → a different stamp: it reads the file, it is not a constant.
316 captured.length = 0;
317 await forge.ship(7, { buildFile: fileB, cfg: cfg(PULL) });
318 const f1 = Object.fromEntries(new URLSearchParams((captured[0] || {}).body || ''));
319 check('a different build.json changes the stamp (it is read, not hard-coded)', f1.said === `Shipped in build ${idB}`, f1.said);
320
321 // A build.json with no id THROWS and posts NOTHING — the board would rather show no stamp.
322 captured.length = 0;
323 let threw = false;
324 try { await forge.ship(7, { buildFile: fileBad, cfg: cfg(PULL) }); }
325 catch (e) { threw = true; }
326 check('a build.json with no id throws rather than inventing one', threw);
327 check('a build.json with no id posts nothing at all', captured.length === 0, `${captured.length} posts`);
328
329 await new Promise(r => capSrv.close(r));
330}
331
332// ── The forge, spawned and proxied ───────────────────────────────────
333
334const MOCK_PORT = 8452;
335let mockProc = null;
336
337/// The build id the stand-in gateway injects as a ship stamp onto every DONE proposal's detail,
338/// so the browser board has a real stamp to parse. Deliberately not the `shipinvent` constant.
339const SHIP_ID = 'deadbeef1234';
340
341async function reachable(port) {
342 try { const r = await fetch(`http://127.0.0.1:${port}/a/b/proposals?format=json&limit=1`); await r.text(); return true; }
343 catch { return false; }
344}
345async function startMock() {
346 if (await reachable(MOCK_PORT)) {
347 console.error(`:${MOCK_PORT} is already held; free it, or this run drives someone else's forge.`);
348 process.exit(2);
349 }
350 mockProc = spawn('node', [path.join(HERE, 'mock_forge.mjs'), '--port', String(MOCK_PORT), '--count', '12'],
351 { stdio: ['ignore', 'ignore', 'inherit'] });
352 for (let i = 0; i < 100; i++) { if (await reachable(MOCK_PORT)) return; await new Promise(r => setTimeout(r, 100)); }
353 console.error('the mock forge never bound.');
354 process.exit(2);
355}
356function stopMock() { if (mockProc) { try { mockProc.kill('SIGTERM'); } catch { /* gone */ } mockProc = null; } }
357process.on('exit', stopMock);
358for (const sig of ['SIGINT', 'SIGTERM']) process.on(sig, () => { stopMock(); process.exit(130); });
359
360// Every request the page made to the gateway route, so the read checks can see whether a voice
361// rode on a GET. `voice` here is the Daimond voice header the browser sent.
362const forgeReqs = [];
363
364/// When set, the gateway answers every LISTING read with a 502, so the board can be driven
365/// against a forge that has gone down AFTER a first successful read. That is the one state the
366/// throttle-on-a-failed-read fix is about: a board that has read once (`read` is true) and then
367/// meets an erroring forge must not refetch on every re-show. Off for every other check.
368let failReads = false;
369
370/// Decline a proposal ON THE FORGE the way ANOTHER DEVICE would — a settle POST straight to the
371/// mock under the admin voice, which the board itself never made. This is the cross-device change
372/// the re-show refetch has to notice; the board has no hand in it. The mock holds its corpus in
373/// memory, so this mutation is the same corpus the browser reads back through the gateway.
374async function declineOnForge(n) {
375 const r = await fetch(`http://127.0.0.1:${MOCK_PORT}/oxedyne/daimond/proposals/${n}?format=json`, {
376 method: 'POST',
377 headers: { 'content-type': 'application/x-www-form-urlencoded', 'x-ore-voice': 'mock-voice-ada' },
378 body: 'state=declined',
379 });
380 return r.status;
381}
382
383/// The upstream forge path the gateway builds from the query, reproduced from
384/// gateway/src/handlers/improve.rs (and verify_improve.mjs's stand-in): `format=json` is written
385/// by the gateway and never taken from the caller; the settle `state` field rides in the body.
386function upstreamPath(u) {
387 const q = u.searchParams;
388 const n = q.get('n');
389 // The sub-resource of a proposal rides in the QUERY here and becomes a path
390 // segment upstream, exactly as improve.rs does: `&vote=1` -> `/proposals/n/vote`,
391 // `&amend=1` -> `/proposals/n/amend`. A settle carries neither, and its `state`
392 // rides in the body. Without this the board's vote POST would hit the comment
393 // route, and its `d=1` would be read as an empty comment.
394 const leaf = q.get('vote') === '1' ? '/vote' : (q.get('amend') === '1' ? '/amend' : '');
395 let p = `/${q.get('account')}/${q.get('repo')}/proposals`;
396 if (n !== null) p += '/' + n + leaf;
397 p += '?format=json';
398 if (n === null) {
399 for (const k of ['state', 'from', 'limit']) {
400 const v = q.get(k);
401 if (v !== null) p += `&${k}=` + v;
402 }
403 }
404 return p;
405}
406
407/// A DONE proposal's detail, with a ship stamp appended to its comments as a shipped agent would
408/// have left one. The forge is not made to carry it (that would change a shared fixture); the
409/// stand-in gateway adds it, so the board has a real stamp to parse. Left verbatim on anything but
410/// a done detail.
411function withShipStamp(text) {
412 let j;
413 try { j = JSON.parse(text); } catch { return text; }
414 if (!j || j.state !== 'done' || !Array.isArray(j.discussion)) return text;
415 j.discussion = j.discussion.concat([{ author: 'ci', said: `Shipped in build ${SHIP_ID}`, when: (j.changed || 1) + 1 }]);
416 return JSON.stringify(j);
417}
418
419/// THE DAIMOND GATEWAY, stood in for. It reads the account/repo/n/selectors off the query,
420/// translates the Daimond voice header (`x-daimond-voice`) into the forge's (`x-ore-voice`),
421/// forwards to the mock forge over loopback, and hands the answer back. Same-origin as the page,
422/// so no CORS is involved — which is the whole point of routing through the gateway.
423async function gatewayRoute(route) {
424 const req = route.request();
425 const u = new URL(req.url());
426 const method = req.method();
427 const headers = req.headers();
428 const dvoice = headers['x-daimond-voice'] || '';
429 forgeReqs.push({ url: req.url(), method, voice: dvoice, body: req.postData() || '' });
430 // A forge that has gone down: every LISTING read answers 502. The read is still counted above,
431 // so a check can see the board FIRE a read; what it cannot get back is a listing, so `load()`
432 // fails and stamps its throttle. Only the listing, so an open/enrich detail read is untouched.
433 if (failReads && method === 'GET' && u.searchParams.get('n') === null) {
434 return route.fulfill({ status: 502, contentType: 'application/json',
435 body: JSON.stringify({ ok: false, error: 'the forge is unreachable' }) });
436 }
437 // The gateway refuses a voiceless POST before forwarding (reproduced from improve.rs).
438 if (method === 'POST' && !dvoice) {
439 return route.fulfill({ status: 401, contentType: 'application/json',
440 body: JSON.stringify({ ok: false, error: 'writing needs a voice' }) });
441 }
442 const out = { 'accept': 'application/json' };
443 if (dvoice) out['x-ore-voice'] = dvoice; // the translation the gateway performs
444 if (method === 'POST') out['content-type'] = headers['content-type'] || 'application/x-www-form-urlencoded';
445 let res, text;
446 try {
447 res = await fetch(`http://127.0.0.1:${MOCK_PORT}${upstreamPath(u)}`, {
448 method, headers: out, body: method === 'POST' ? (req.postData() || '') : undefined,
449 });
450 text = await res.text();
451 } catch (e) {
452 return route.fulfill({ status: 502, contentType: 'application/json',
453 body: JSON.stringify({ ok: false, error: 'the forge could not be reached' }) });
454 }
455 // A done detail gets a ship stamp, so the board's Shipped column has an id to parse.
456 if (res.ok && u.searchParams.get('n') !== null) text = withShipStamp(text);
457 return route.fulfill({
458 status: res.status,
459 contentType: res.headers.get('content-type') || 'application/json',
460 body: text,
461 });
462}
463
464// ── The harness page ─────────────────────────────────────────────────
465// A bare page whose only job is to hold a mount point and load the module. It carries a MINIMAL
466// DaimondIdentity stub so the "add your settle voice" paste path is drawable and testable (the
467// real one wraps under the passphrase; here wrap/unwrap are the identity). The view draws itself
468// into `#trk`; the checks read the DOM and call the published API.
469
470const PAGE = `<!doctype html><meta charset="utf-8"><title>Tracker harness</title>
471<body><div id="trk"></div>
472<script>
473window.DaimondIdentity = {
474 isUnlocked: function () { return true; },
475 wrap: async function (s) { return 'w:' + String(s); },
476 unwrap: async function (w) { return String(w).replace(/^w:/, ''); },
477};
478// The Social panel (js/improve.js) STOOD IN FOR: the board holds no pull voice
479// and no vote/comment POST of its own -- it calls these doors, which carry the
480// pull voice and speak the one copy of the wire improve.js keeps. The stub POSTs
481// through the same gateway route with the mock's PULL voice, and reads the answer
482// into the panel's {ok,data} shape exactly as improve.js's own \`ask\` does. This
483// is what lets the checks prove the board CALLS these doors and draws the result,
484// rather than re-implementing the POST in tracker.js.
485function __door(path, body) {
486 return fetch(path, { method: 'POST',
487 headers: { 'content-type': 'application/x-www-form-urlencoded', 'x-daimond-voice': 'mock-voice-grace' },
488 body: body }).then(function (r) {
489 return r.text().then(function (t) {
490 var data = null; try { data = t ? JSON.parse(t) : null; } catch (e) { data = null; }
491 if (data && data.error) return { ok: false, why: data.error };
492 if (r.ok && data) return { ok: true, data: data };
493 return { ok: false, why: 'gateway', status: r.status };
494 });
495 }).catch(function () { return { ok: false, why: 'offline' }; });
496}
497window.__installImprove = function (hasVoice) {
498 window.DaimondImprove = {
499 hasVoice: function () { return !!hasVoice; },
500 raisedProposalNumbers: function () { return []; },
501 provision: function () { return Promise.resolve(false); },
502 forge: {
503 vote: function (n, d) { return __door('/api/improve?account=oxedyne&repo=daimond&n=' + n + '&vote=1', 'd=' + d); },
504 say: function (n, text) { var f = new URLSearchParams(); f.set('said', text);
505 return __door('/api/improve?account=oxedyne&repo=daimond&n=' + n, f.toString()); },
506 },
507 };
508};
509</script>
510<script src="/js/tracker.js"></script>
511<script>window.__mount = function (opts) {
512 DaimondTracker.reset();
513 try { DaimondTracker.adminClear(); } catch (e) {}
514 // base is the same-origin gateway route; the harness's #trk is the mount point (the app uses
515 // #tracker-view). onOpen() reads the listing, which mount() no longer does on its own.
516 DaimondTracker.configure(Object.assign({ base: '/api/improve', account: 'oxedyne', repo: 'daimond', voice: '' }, opts || {}));
517 DaimondTracker.mount(document.getElementById('trk'));
518 DaimondTracker.onOpen();
519};</script></body>`;
520
521const ORIGIN = 'https://daimond.test';
522
523async function run() {
524 await nodePhase();
525 await startMock();
526
527 fs.mkdirSync(PROFILE, { recursive: true });
528 const env = Object.assign({}, process.env);
529 delete env.DISPLAY;
530 const browser = await chromium.launchPersistentContext(PROFILE, {
531 executablePath: CHROME, headless: true, args: ['--no-sandbox', '--disable-dev-shm-usage', '--headless=new'],
532 env, viewport: { width: 1100, height: 900 },
533 });
534 const page = browser.pages()[0] || await browser.newPage();
535 const errs = [];
536 page.on('pageerror', e => errs.push(String(e.message)));
537
538 // Serve the page and the module; proxy the forge host.
539 await page.route(`${ORIGIN}/`, r => r.fulfill({ status: 200, contentType: 'text/html', body: PAGE }));
540 await page.route(`${ORIGIN}/js/tracker.js`, r => r.fulfill({ status: 200, contentType: 'application/javascript', body: trackerSrc() }));
541 await page.route(`${ORIGIN}/api/improve*`, gatewayRoute);
542
543 const sleep = (ms) => new Promise(r => setTimeout(r, ms));
544 /// Mount the view with the given config and wait for the listing and its enrichment reads.
545 const mount = async (opts) => {
546 forgeReqs.length = 0;
547 await page.evaluate((o) => window.__mount(o), opts || null);
548 await sleep(700);
549 };
550 const col = (i) => page.locator('#trk .trk-col').nth(i);
551
552 try {
553 await page.goto(`${ORIGIN}/`, { waitUntil: 'domcontentloaded' });
554
555 // ── 1. The board draws four columns ──────────────────────────
556 await mount();
557 check('the board draws four columns', (await page.locator('#trk .trk-col').count()) === 4);
558 const labels = await page.locator('#trk .trk-col-label').allInnerTexts();
559 check('the four columns are Awaiting you / Greenlit / Shipped / Dropped',
560 JSON.stringify(labels.map(s => s.trim())) === JSON.stringify(['Awaiting you', 'Greenlit', 'Shipped', 'Dropped']),
561 labels.join(' | '));
562
563 // Each proposal under the column matching its state (corpus: 5 open, 3 accepted, 2 done,
564 // 2 declined). This is what `miscolumn` breaks.
565 check('Awaiting you holds the five open proposals', (await col(0).locator('.trk-card').count()) === 5, `${await col(0).locator('.trk-card').count()}`);
566 check('Greenlit holds the three accepted proposals', (await col(1).locator('.trk-card').count()) === 3);
567 check('Shipped holds the two done proposals', (await col(2).locator('.trk-card').count()) === 2);
568 check('Dropped holds the two declined proposals', (await col(3).locator('.trk-card').count()) === 2);
569
570 // Newest first within the Shipped column: #11 before #7 (done sits at positions 7 and 11).
571 const shipNums = (await col(2).locator('.trk-num').allInnerTexts()).map(s => Number(s.replace('#', '')));
572 check('a column runs newest first', JSON.stringify(shipNums) === JSON.stringify(shipNums.slice().sort((a, b) => b - a)) && shipNums[0] > shipNums[1], shipNums.join(','));
573
574 // ── 2. A card's fields ───────────────────────────────────────
575 const anyCard = page.locator('#trk .trk-card').first();
576 check('a card carries its number', /^#\d+$/.test((await anyCard.locator('.trk-num').innerText()).trim()));
577 check('a card carries a title', (await anyCard.locator('.trk-title').innerText()).trim().length > 0);
578 check('a card carries a comment count', (await anyCard.locator('.trk-comments').count()) === 1);
579 check('a card carries a vote tally', (await anyCard.locator('.trk-tally').count()) === 1);
580
581 // ── 3. Votes are dark ────────────────────────────────────────
582 const tally = (await anyCard.locator('.trk-tally').innerText()).trim();
583 check('the tally is two counts and nothing else', /for.*against/i.test(tally) || /\d+.*\d+/.test(tally), tally);
584 const html = await page.locator('#trk').innerHTML();
585 check('no voter identity appears anywhere in the view', !/voter|quokka-voter/i.test(html));
586
587 // ── 4. Reading is unvoiced (read-only phase, no voice held) ──
588 check('the listing read carried no voice', forgeReqs.filter(r => r.method === 'GET' && r.voice).length === 0);
589
590 // ── 5. No dead settle buttons ────────────────────────────────
591 check('with no admin voice, no settle control is drawn', (await page.locator('#trk .trk-settle-btn').count()) === 0);
592 const affordance = page.locator('#trk [data-act="tracker-admin-open"]');
593 check('with no admin voice, the "add your settle voice" affordance is shown', (await affordance.count()) === 1);
594
595 // ── 7. A Shipped card stamps the REAL build ──────────────────
596 // The done details were given a ship stamp by the stand-in; the board parses the id.
597 const shipId = col(2).locator('.trk-ship-id').first();
598 check('a Shipped card shows a build stamp', (await shipId.count()) >= 1);
599 if (await shipId.count()) {
600 check('the shipped stamp is the REAL parsed id, not invented', (await shipId.innerText()).trim() === SHIP_ID, (await shipId.innerText()).trim());
601 check('the shipped stamp is clickable to the transparency log',
602 (await shipId.getAttribute('data-act')) === 'tracker-transparency' && (await shipId.getAttribute('data-build')) === SHIP_ID);
603 } else {
604 check('the shipped stamp is the REAL parsed id, not invented', false, 'no stamp drawn');
605 check('the shipped stamp is clickable to the transparency log', false, 'no stamp drawn');
606 }
607
608 // parseShip, driven directly: the same parse the board does, proved on crafted comments.
609 const ps = await page.evaluate(() => {
610 const P = window.DaimondTracker.parseShip;
611 return {
612 hit: P([{ said: 'working on it' }, { said: 'Shipped in build 85bfe5f585b6' }]),
613 none: P([{ said: 'no stamp here' }, { said: 'still none' }]),
614 last: P([{ said: 'Shipped in build aaaaaaaa1111' }, { said: 'Shipped in build bbbbbbbb2222' }]),
615 };
616 });
617 check('parseShip extracts the build id from a ship stamp', ps.hit === '85bfe5f585b6', ps.hit);
618 check('parseShip returns nothing when no stamp is present', ps.none === '', JSON.stringify(ps.none));
619 check('parseShip takes the last stamp when several are present', ps.last === 'bbbbbbbb2222', ps.last);
620
621 // ── 8a. A proposal opens in full ─────────────────────────────
622 await anyCard.locator('.trk-title').click();
623 await sleep(500);
624 check('opening a card shows the detail view', (await page.locator('#trk .trk-detail').count()) === 1);
625 check('the detail shows the title and body', (await page.locator('#trk .trk-detail-title').count()) === 1 && (await page.locator('#trk .trk-body').count()) === 1);
626 check('the detail shows a comments section', (await page.locator('#trk .trk-comments-list').count()) === 1);
627 check('the detail shows the state', (await page.locator('#trk .trk-detail .trk-state').count()) === 1);
628 check('the detail read carried no voice', forgeReqs.filter(r => r.method === 'GET' && r.voice).length === 0);
629 await page.locator('#trk .trk-back').click();
630 await sleep(300);
631 check('Back returns to the board', (await page.locator('#trk .trk-col').count()) === 4);
632
633 // ── 5b. Pasting a settle voice REVEALS the controls ──────────
634 check('before pasting, still no settle control', (await page.locator('#trk .trk-settle-btn').count()) === 0);
635 await page.locator('#trk [data-act="tracker-admin-open"]').click();
636 await sleep(150);
637 check('the affordance opens a paste field', (await page.locator('#trk #tracker-admin-in').count()) === 1);
638 await page.locator('#trk #tracker-admin-in').fill('a-settle-voice-2026');
639 await page.locator('#trk [data-act="tracker-admin-save"]').click();
640 await sleep(300);
641 check('pasting a settle voice reveals the settle controls', (await page.locator('#trk .trk-settle-btn').count()) >= 1);
642 await page.evaluate(() => window.DaimondTracker.adminClear());
643
644 // ── 6. The owner settles from the board ──────────────────────
645 // mock-voice-ada is the mock's ADMIN voice; the view sends it verbatim as x-ore-voice.
646 await mount({ voice: 'mock-voice-ada' });
647 // EVEN WITH A VOICE HELD, a READ carries none — reads stay public. This is where
648 // `voicedread` bites: the listing read here would leak the held voice.
649 check('reads carry no voice even when an admin voice is held', forgeReqs.filter(r => r.method === 'GET' && r.voice).length === 0, `${forgeReqs.filter(r => r.method === 'GET' && r.voice).length} voiced GET(s)`);
650 check('with an admin voice, settle controls are drawn on Awaiting-you cards', (await col(0).locator('.trk-settle-btn').count()) >= 1);
651
652 // Accept now COLLECTS A REQUIRED REASON: pressing Accept opens a reason box rather than
653 // settling; an empty confirm is refused with no write; and the write that does go carries
654 // state=accepted AND the reason. Guarded by the button's presence, so a break that empties
655 // the Awaiting-you column fails cleanly rather than hanging on a click that can never land.
656 const openCard = col(0).locator('.trk-card').first();
657 const acceptBtn = openCard.locator('.trk-settle-btn[data-which="accept"]');
658 if (await acceptBtn.count()) {
659 const acceptN = Number((await openCard.locator('.trk-num').innerText()).replace('#', ''));
660
661 // Pressing Accept opens the reason box and posts NOTHING yet. Bitten by
662 // `noreasongate`, where Accept settles at a press with no box.
663 forgeReqs.length = 0;
664 await acceptBtn.click();
665 await sleep(200);
666 const askRow = col(0).locator(`.trk-card[data-prop="${acceptN}"] .trk-reason`);
667 check('pressing Accept opens a reason box and posts nothing yet',
668 (await askRow.count()) === 1 && forgeReqs.filter(r => r.method === 'POST').length === 0,
669 `box ${await askRow.count()}, ${forgeReqs.filter(r => r.method === 'POST').length} writes`);
670
671 // A confirm with an EMPTY reason is refused client-side: no write leaves. Bitten by
672 // `emptyreasonok`, where the empty reason is sent instead.
673 forgeReqs.length = 0;
674 await col(0).locator(`.trk-card[data-prop="${acceptN}"] [data-act="tracker-settle-do"]`).click();
675 await sleep(300);
676 check('a settle with an empty reason is refused, firing no write',
677 forgeReqs.filter(r => r.method === 'POST').length === 0,
678 `${forgeReqs.filter(r => r.method === 'POST').length} writes`);
679
680 // Fill the reason and confirm: one write, state=accepted AND the reason.
681 const REASON = 'clear win, shipping it';
682 await col(0).locator(`.trk-card[data-prop="${acceptN}"] .trk-reason`).fill(REASON);
683 forgeReqs.length = 0;
684 await col(0).locator(`.trk-card[data-prop="${acceptN}"] [data-act="tracker-settle-do"]`).click();
685 await sleep(500);
686 const posted = forgeReqs.filter(r => r.method === 'POST');
687 check('Accept, with a reason, posts exactly one write', posted.length === 1, `${posted.length} writes`);
688 const w = posted[0] || { voice: '', body: '' };
689 check('the settle write carried the admin voice in x-daimond-voice', w.voice === 'mock-voice-ada', w.voice);
690 const f = Object.fromEntries(new URLSearchParams(w.body));
691 check('the accept write is state=accepted AND the reason, and nothing else',
692 JSON.stringify(Object.keys(f).sort()) === JSON.stringify(['reason', 'state'])
693 && f.state === 'accepted' && f.reason === REASON, JSON.stringify(f));
694 await sleep(200);
695 const inGreen = await col(1).locator(`.trk-card[data-prop="${acceptN}"]`).count();
696 check('the accepted proposal moves to Greenlit', inGreen === 1, `#${acceptN} in Greenlit: ${inGreen}`);
697
698 // Reopen it from Greenlit: posts state=open.
699 const greenCard = col(1).locator(`.trk-card[data-prop="${acceptN}"]`);
700 if (await greenCard.locator('.trk-settle-btn[data-which="reopen"]').count()) {
701 forgeReqs.length = 0;
702 await greenCard.locator('.trk-settle-btn[data-which="reopen"]').click();
703 await sleep(400);
704 const rf = Object.fromEntries(new URLSearchParams((forgeReqs.filter(r => r.method === 'POST')[0] || {}).body || ''));
705 check('Reopen posts state=open and no reason',
706 rf.state === 'open' && !('reason' in rf), JSON.stringify(rf));
707 } else {
708 check('Reopen posts state=open and no reason', false, 'no Reopen offered on the Greenlit card');
709 }
710 } else {
711 check('pressing Accept opens a reason box and posts nothing yet', false, 'no Accept on the first Awaiting-you card');
712 check('a settle with an empty reason is refused, firing no write', false, 'no Accept to click');
713 check('Accept, with a reason, posts exactly one write', false, 'no Accept to click');
714 check('the settle write carried the admin voice in x-daimond-voice', false, 'no Accept to click');
715 check('the accept write is state=accepted AND the reason, and nothing else', false, 'no Accept to click');
716 check('the accepted proposal moves to Greenlit', false, 'no Accept to click');
717 check('Reopen posts state=open and no reason', false, 'no Accept to click');
718 }
719
720 // Decline also collects a required reason, sent as state=declined AND reason.
721 const declCard = col(0).locator('.trk-card').first();
722 const declineBtn = declCard.locator('.trk-settle-btn[data-which="decline"]');
723 if (await declineBtn.count()) {
724 const declN = Number((await declCard.locator('.trk-num').innerText()).replace('#', ''));
725 await declineBtn.click();
726 await sleep(200);
727 const DREASON = 'out of scope for now';
728 await col(0).locator(`.trk-card[data-prop="${declN}"] .trk-reason`).fill(DREASON);
729 forgeReqs.length = 0;
730 await col(0).locator(`.trk-card[data-prop="${declN}"] [data-act="tracker-settle-do"]`).click();
731 await sleep(500);
732 const dPosted = forgeReqs.filter(r => r.method === 'POST');
733 const df = Object.fromEntries(new URLSearchParams((dPosted[0] || {}).body || ''));
734 check('Decline, with a reason, posts state=declined AND the reason',
735 dPosted.length === 1 && df.state === 'declined' && df.reason === DREASON
736 && JSON.stringify(Object.keys(df).sort()) === JSON.stringify(['reason', 'state']),
737 `${dPosted.length} writes, ${JSON.stringify(df)}`);
738 } else {
739 check('Decline, with a reason, posts state=declined AND the reason', false, 'no Decline on the first Awaiting-you card');
740 }
741
742 // ── 10-13. Vote and comment, re-homed to the hub ─────────────
743 // The pull voice lives in the Social panel; the board CALLS its doors. Install
744 // the stand-in DaimondImprove with a pull voice held, and redraw.
745 await page.evaluate(() => window.__installImprove(true));
746 await mount();
747 const vCard = col(0).locator('.trk-card').first();
748 check('with a pull voice, a card offers a live upvote', (await vCard.locator('.trk-vote-btn').count()) === 1);
749 check('with a pull voice, the card shows no "set a voice" affordance', (await vCard.locator('.trk-setvoice').count()) === 0);
750
751 const vN = Number((await vCard.locator('.trk-num').innerText()).replace('#', ''));
752 const beforeFor = await page.evaluate((n) => window.DaimondTracker.proposal(n).votes.for, vN);
753 forgeReqs.length = 0;
754 await vCard.locator('.trk-vote-btn').click();
755 await sleep(400);
756 const postV = forgeReqs.filter(r => r.method === 'POST');
757 check('the upvote posts exactly one write', postV.length === 1, `${postV.length}`);
758 check('the upvote carried the PULL voice, not the admin one', (postV[0] || {}).voice === 'mock-voice-grace', (postV[0] || {}).voice);
759 check('the upvote body is d=1 and nothing else', (postV[0] || {}).body === 'd=1', (postV[0] || {}).body);
760 const afterFor = await page.evaluate((n) => window.DaimondTracker.proposal(n).votes.for, vN);
761 check('the shown for-count increments by one', afterFor === beforeFor + 1, `${beforeFor} -> ${afterFor}`);
762 check('the upvote reads pressed after casting', (await col(0).locator(`.trk-card[data-prop="${vN}"] .trk-vote-btn.on`).count()) === 1);
763
764 // Pressing the cast upvote again withdraws it: d=0, and the count falls back.
765 forgeReqs.length = 0;
766 await col(0).locator(`.trk-card[data-prop="${vN}"] .trk-vote-btn`).click();
767 await sleep(400);
768 const wBody = (forgeReqs.filter(r => r.method === 'POST')[0] || {}).body;
769 check('pressing an upvote already cast withdraws it (d=0)', wBody === 'd=0', String(wBody));
770 const backFor = await page.evaluate((n) => window.DaimondTracker.proposal(n).votes.for, vN);
771 check('the for-count falls back after a withdrawal', backFor === beforeFor, `${backFor}`);
772
773 // Comment, in the opened card where the thread is read.
774 const cCard = col(0).locator('.trk-card').first();
775 const cN = Number((await cCard.locator('.trk-num').innerText()).replace('#', ''));
776 await cCard.locator('.trk-title').click();
777 await sleep(500);
778 check('the opened card offers a reply box with a pull voice', (await page.locator(`#trk .trk-reply[data-prop="${cN}"]`).count()) === 1);
779 const commentsBefore = await page.locator('#trk .trk-comment').count();
780 const SAYTEXT = 'the dark-mode grey needs this too';
781 await page.locator(`#trk .trk-reply[data-prop="${cN}"]`).fill(SAYTEXT);
782 forgeReqs.length = 0;
783 await page.locator(`#trk [data-act="tracker-comment"][data-prop="${cN}"]`).click();
784 await sleep(500);
785 const postC = forgeReqs.filter(r => r.method === 'POST');
786 check('the comment posts exactly one write', postC.length === 1, `${postC.length}`);
787 check('the comment carried the pull voice', (postC[0] || {}).voice === 'mock-voice-grace', (postC[0] || {}).voice);
788 const cf = Object.fromEntries(new URLSearchParams((postC[0] || {}).body || ''));
789 check('the comment body is the said field alone', JSON.stringify(Object.keys(cf).sort()) === JSON.stringify(['said']) && cf.said === SAYTEXT, JSON.stringify(cf));
790 const commentsAfter = await page.locator('#trk .trk-comment').count();
791 check('the posted comment appears in the thread', commentsAfter === commentsBefore + 1, `${commentsBefore} -> ${commentsAfter}`);
792 check('the posted comment text is shown', (await page.locator('#trk .trk-comment-said').allInnerTexts()).some(s => s.includes(SAYTEXT)));
793 await page.locator('#trk .trk-back').click();
794 await sleep(200);
795
796 // With NO pull voice: the affordance, never a control that would 500.
797 await page.evaluate(() => window.__installImprove(false));
798 await mount();
799 const nCard = col(0).locator('.trk-card').first();
800 check('with no pull voice, a card shows the set-a-voice affordance, not a live upvote',
801 (await nCard.locator('.trk-setvoice').count()) === 1 && (await nCard.locator('.trk-vote-btn').count()) === 0);
802 check('with no pull voice, the vote count is still shown', (await nCard.locator('.trk-vote-count').count()) === 1);
803 const nN = Number((await nCard.locator('.trk-num').innerText()).replace('#', ''));
804 await nCard.locator('.trk-title').click();
805 await sleep(400);
806 check('with no pull voice, the opened card shows the say affordance, not a reply box',
807 (await page.locator('#trk .trk-say-novoice').count()) === 1 && (await page.locator(`#trk .trk-reply`).count()) === 0);
808 check('reading votes and comments needed no voice at all', forgeReqs.filter(r => r.method === 'GET' && r.voice).length === 0);
809 await page.evaluate(() => { try { delete window.DaimondImprove; } catch (e) { window.DaimondImprove = undefined; } });
810
811 // ── 9. All / Mine filter ─────────────────────────────────────
812 // "Mine" is the proposals THIS DEVICE raised. The local voice has no name, so
813 // Mine cannot be an author match; it is a match against the numbers the capture
814 // surface (js/improve.js) publishes as DaimondImprove.raisedProposalNumbers().
815 // The harness has no capture surface by default, which is the empty-state path.
816 await page.evaluate(() => { try { delete window.DaimondImprove; } catch (e) { window.DaimondImprove = undefined; } });
817 await mount();
818 check('the board draws an All / Mine filter', (await page.locator('#trk .trk-filter').count()) === 1);
819 check('the filter defaults to All',
820 (await page.locator('#trk .trk-filter-btn[data-filter="all"].on').count()) === 1
821 && (await page.locator('#trk .trk-filter-btn[data-filter="mine"].on').count()) === 0);
822 const allNums = (await page.locator('#trk .trk-board .trk-num').allInnerTexts()).map(s => Number(s.replace('#', '')));
823 check('All shows every loaded proposal', allNums.length === 12, `${allNums.length} cards`);
824
825 // Mine with NO capture surface: the empty state, no cards, and no throw.
826 await page.locator('#trk .trk-filter-btn[data-filter="mine"]').click();
827 await sleep(150);
828 check('Mine, with no capture surface, shows the "nothing raised" state', (await page.locator('#trk .trk-mine-empty').count()) === 1);
829 check('Mine, with no capture surface, shows no cards', (await page.locator('#trk .trk-card').count()) === 0);
830
831 // A capture surface that raised two specific proposals: Mine shows exactly those.
832 const raised = [allNums[0], allNums[5]].sort((a, b) => a - b);
833 await page.evaluate((nums) => { window.DaimondImprove = { raisedProposalNumbers: () => nums.slice() }; }, raised);
834 await page.locator('#trk .trk-filter-btn[data-filter="all"]').click(); // force a redraw off Mine
835 await sleep(120);
836 await page.locator('#trk .trk-filter-btn[data-filter="mine"]').click();
837 await sleep(150);
838 const mineNums = (await page.locator('#trk .trk-board .trk-num').allInnerTexts()).map(s => Number(s.replace('#', ''))).sort((a, b) => a - b);
839 check('Mine shows ONLY the proposals this device raised', JSON.stringify(mineNums) === JSON.stringify(raised),
840 `shown ${JSON.stringify(mineNums)} vs raised ${JSON.stringify(raised)}`);
841 check('Mine drew those cards, not the empty state',
842 (await page.locator('#trk .trk-mine-empty').count()) === 0 && mineNums.length === raised.length);
843
844 // All restores the whole board.
845 await page.locator('#trk .trk-filter-btn[data-filter="all"]').click();
846 await sleep(120);
847 const backNums = (await page.locator('#trk .trk-board .trk-num').allInnerTexts()).map(s => Number(s.replace('#', '')));
848 check('All restores every proposal', backNums.length === allNums.length, `${backNums.length} vs ${allNums.length}`);
849 await page.evaluate(() => { try { delete window.DaimondImprove; } catch (e) { window.DaimondImprove = undefined; } });
850
851 // ── 8b. A refusal is said ────────────────────────────────────
852 await mount({ repo: '_absent' });
853 const err = await page.locator('#trk .trk-err').count();
854 check('a repository that is not available draws the refusal sentence', err === 1);
855 if (err) {
856 const said = (await page.locator('#trk .trk-err').innerText()).trim();
857 check('the refusal sentence is non-empty', said.length > 0, said);
858 } else {
859 check('the refusal sentence is non-empty', false, 'no refusal drawn');
860 }
861
862 // ── 14. A RE-SHOWN BOARD REFETCHES A CROSS-DEVICE DECLINE ────
863 // A proposal open under "Awaiting you" is declined ON THE FORGE by another device -- a
864 // settle the board never made. A re-show past the REFRESH_MS throttle must refetch and move
865 // it to "Dropped"; the old freeze (a one-shot observer and a read-once guard) left declines
866 // made elsewhere sitting in "Awaiting you". Proved red by `--break freeze`.
867 await mount();
868 const declineN = Number((await col(0).locator('.trk-card').first().locator('.trk-num').innerText()).replace('#', ''));
869 check('a proposal is awaiting the owner before the cross-device decline',
870 (await col(0).locator(`.trk-card[data-prop="${declineN}"]`).count()) === 1, `#${declineN}`);
871 const declStatus = await declineOnForge(declineN);
872 check('the cross-device decline reached the forge', declStatus === 200, `status ${declStatus}`);
873 // Wait out the throttle (REFRESH_MS is 4s), then re-show the way a person returning to the
874 // panel does. The board must refetch, not serve its frozen snapshot.
875 await sleep(4200);
876 forgeReqs.length = 0;
877 await page.evaluate(() => window.DaimondTracker.onOpen());
878 await sleep(700);
879 check('the re-show refetched the listing (a network read fired)',
880 forgeReqs.filter(r => r.method === 'GET').length >= 1,
881 `${forgeReqs.filter(r => r.method === 'GET').length} GET(s)`);
882 check('after a cross-device decline, the re-shown board moves it to Dropped',
883 (await col(3).locator(`.trk-card[data-prop="${declineN}"]`).count()) === 1,
884 `#${declineN} in Dropped: ${await col(3).locator(`.trk-card[data-prop="${declineN}"]`).count()}`);
885 check('and the declined proposal is gone from Awaiting you',
886 (await col(0).locator(`.trk-card[data-prop="${declineN}"]`).count()) === 0,
887 `#${declineN} still awaiting: ${await col(0).locator(`.trk-card[data-prop="${declineN}"]`).count()}`);
888
889 // ── 15. A FAILED READ STAMPS THE THROTTLE (bounded refetch) ──
890 // The board reads once successfully, the forge then errors, and two rapid re-shows past the
891 // stale point must fire EXACTLY ONE network read -- the second throttled by the `_lastLoad`
892 // the failed read stamped. Without that stamp a down forge is refetched on every re-show.
893 // Proved red by `--break nothrottlefail`.
894 await mount(); // a clean, successful first read: `read` is now true
895 failReads = true; // the forge now errors on every listing read
896 await sleep(4200); // let the snapshot go stale
897 forgeReqs.length = 0;
898 await page.evaluate(() => window.DaimondTracker.onOpen()); // stale -> load -> fails, stamps the throttle
899 await sleep(400);
900 await page.evaluate(() => window.DaimondTracker.onOpen()); // within 4s of the failure -> throttled, no read
901 await sleep(400);
902 const failReadCount = forgeReqs.filter(r => r.method === 'GET').length;
903 check('a failed read stamps the throttle: two rapid re-shows against a down forge fire ONE read, not two',
904 failReadCount === 1, `${failReadCount} GET(s)`);
905 failReads = false;
906
907 check('no page errors were thrown', errs.length === 0, errs.join(' | '));
908 } finally {
909 await browser.close();
910 stopMock();
911 }
912}
913
914await run();
915
916console.log(`\n${ok.length} ok, ${bad.length} failed`);
917process.exit(bad.length ? 1 : 0);