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 | |
| 74 | import fs from 'node:fs'; |
| 75 | import http from 'node:http'; |
| 76 | import path from 'node:path'; |
| 77 | import { spawn } from 'node:child_process'; |
| 78 | import { fileURLToPath, pathToFileURL } from 'node:url'; |
| 79 | import { PW, CHROME, scratch } from './harness.mjs'; |
| 80 | import * as forge from './forge.mjs'; |
| 81 | |
| 82 | const { chromium } = await import(pathToFileURL(PW).href); |
| 83 | |
| 84 | const HERE = path.dirname(fileURLToPath(import.meta.url)); |
| 85 | const WWW = path.join(HERE, '..', 'www'); |
| 86 | |
| 87 | const BREAK = (() => { |
| 88 | const i = process.argv.indexOf('--break'); |
| 89 | return i > 0 ? String(process.argv[i + 1] || '') : ''; |
| 90 | })(); |
| 91 | |
| 92 | const PROFILE = scratch('pw', 'tracker' + (BREAK ? '-' + BREAK : '')); |
| 93 | fs.rmSync(PROFILE, { recursive: true, force: true }); |
| 94 | |
| 95 | const ok = [], bad = []; |
| 96 | const 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. |
| 105 | const 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 | |
| 232 | if (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. |
| 238 | const FILES = new Map(); |
| 239 | function 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 | } |
| 248 | function 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 | } |
| 255 | build(); |
| 256 | |
| 257 | const 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 | |
| 264 | const CAP_PORT = 8453; |
| 265 | const captured = []; |
| 266 | const 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 | |
| 281 | const 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. |
| 284 | function 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 | |
| 291 | async 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 | |
| 334 | const MOCK_PORT = 8452; |
| 335 | let 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. |
| 339 | const SHIP_ID = 'deadbeef1234'; |
| 340 | |
| 341 | async 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 | } |
| 345 | async 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 | } |
| 356 | function stopMock() { if (mockProc) { try { mockProc.kill('SIGTERM'); } catch { /* gone */ } mockProc = null; } } |
| 357 | process.on('exit', stopMock); |
| 358 | for (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. |
| 362 | const 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. |
| 368 | let 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. |
| 374 | async 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. |
| 386 | function 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. |
| 411 | function 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. |
| 423 | async 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 | |
| 470 | const PAGE = `<!doctype html><meta charset="utf-8"><title>Tracker harness</title> |
| 471 | <body><div id="trk"></div> |
| 472 | <script> |
| 473 | window.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. |
| 485 | function __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 | } |
| 497 | window.__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 | |
| 521 | const ORIGIN = 'https://daimond.test'; |
| 522 | |
| 523 | async 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 | |
| 914 | await run(); |
| 915 | |
| 916 | console.log(`\n${ok.length} ok, ${bad.length} failed`); |
| 917 | process.exit(bad.length ? 1 : 0); |