oxedyne/daimond/dev/verify_improve.mjs
100.0 KiB, 93 runs
created by r2519314175:485, 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_improve.mjs — the Social panel's Notes and Proposals keep their one promise, against a real forge. |
| 2 | // |
| 3 | // `dev/IMPROVE_CONTRACT.md` §4 states it in a sentence: |
| 4 | // |
| 5 | // A NOTE LEAVES THIS DEVICE ONLY WHEN A PERSON PRESSES SEND ON THAT ONE |
| 6 | // NOTE, AND WHAT LEAVES IS EXACTLY THE CHARACTERS ON THE SCREEN AT THAT |
| 7 | // MOMENT. |
| 8 | // |
| 9 | // Every clause of that is a check here, and every check is COUNTED AT THE |
| 10 | // NETWORK — `page.route` sits between the page and the server, so a request |
| 11 | // that was never made and a request that was merely hidden look different. |
| 12 | // |
| 13 | // ── WHAT IS AT THE OTHER END ───────────────────────────────────────── |
| 14 | // |
| 15 | // `dev/mock_forge.mjs`, which is the Oregami forge's whole JSON surface stood |
| 16 | // in for, and a stand-in for the GATEWAY that builds the upstream path exactly |
| 17 | // as `gateway/src/handlers/improve.rs` does. Nothing here answers the panel out |
| 18 | // of its own idea of the protocol: a verifier that did would agree with itself |
| 19 | // and prove nothing about the forge. The bytes the panel parses are the bytes |
| 20 | // the forge will send, canonical JSON and all. |
| 21 | // |
| 22 | // Five stand-ins run at once, because several properties need a forge that |
| 23 | // behaves DIFFERENTLY and a client cannot be asked to prove it survives one it |
| 24 | // never meets: |
| 25 | // |
| 26 | // :8437 the ordinary corpus, twelve proposals — most checks |
| 27 | // :8438 fifty proposals — paging, which needs more than one page |
| 28 | // :8439 fifty, `--break fromascends` — a forge that reads `from` as a LOWER |
| 29 | // bound, which is the shape that makes a paging client loop for ever |
| 30 | // :8440 `--break novotes` — the listing carries no tally. NOT what the real |
| 31 | // forge does any more: it answers `votes` on both shapes, confirmed |
| 32 | // against the deployed host on 2026-08-27. This stand-in is kept, and |
| 33 | // deliberately, because it is what proves the control is gated on the |
| 34 | // ANSWER rather than on somebody's belief about the calendar — the |
| 35 | // belief is what went stale, three times, while the code stayed right |
| 36 | // :8441 `--refuse unsupported` — the one refusal token no path the panel |
| 37 | // takes can reach naturally, and a token nobody has met is a token |
| 38 | // nobody has handled |
| 39 | // |
| 40 | // 1. WRITING IS NOT SENDING, AND NEITHER IS KEEP. Both halves are asked, and |
| 41 | // the second is the one that matters: a check that only ever proves |
| 42 | // silence passes with the feature entirely absent. So Send is pressed |
| 43 | // afterwards and the request DOES leave. |
| 44 | // |
| 45 | // 1b. WHAT PRESSING SEND MEANS IS SAID BEFORE IT IS PRESSED. The forge |
| 46 | // refuses on a repository's `public` flag before it examines any |
| 47 | // credential and draws public repositories only, so this panel can read |
| 48 | // anything at all only while `oxedyne/daimond` is public — and a proposal |
| 49 | // there is readable by anybody, with no credential, under the name of the |
| 50 | // voice that wrote it. So the compose box carries that ABOVE Send, naming |
| 51 | // the forge by HOST, because "published online" is a claim nobody can go |
| 52 | // and check. Three things are asked of it: that it is there and names the |
| 53 | // host, that it sits above the button rather than under it, and that it is |
| 54 | // NOT THERE when Send is not — a tester with no voice cannot send, and |
| 55 | // telling them their notes will be published would be false. And it is |
| 56 | // asked in all eight languages, in the browser, because a disclosure that |
| 57 | // exists in English only is a disclosure seven readers never get. |
| 58 | // |
| 59 | // 1f. AND THE STRINGS BESIDE IT ARE IN ALL EIGHT CATALOGUES TOO. `tOr(key, |
| 60 | // english)` draws correct English when a key is in no table, so a missing |
| 61 | // translation looks exactly like a finished one and nothing reports it — |
| 62 | // which is how fifteen keys reached `en.js` alone last release and how a |
| 63 | // whole `voice.*` family came to exist in no table at all. Read off disk, |
| 64 | // and honest about being weaker than 1e: it proves the ENTRY, not the |
| 65 | // screen. The surfaces those strings appear on belong to another lane. |
| 66 | // |
| 67 | // 2. WHAT LEFT IS WHAT YOU READ. A note is now a PROPOSAL, so the request has |
| 68 | // a title field and a body field rather than being the note's own bytes — |
| 69 | // and the property that the old shape carried for free has to be bought |
| 70 | // back by two assertions instead of one: |
| 71 | // * the fields put back together, `title + "\n" + body`, are character |
| 72 | // for character the box's value plus the visible "What goes with it" |
| 73 | // line; |
| 74 | // * and the FIELD SET is exactly `title`, `body`, `build`. Not "the body |
| 75 | // contains the note": a request that contains the note and a fifth |
| 76 | // field passes that. |
| 77 | // |
| 78 | // 3. CLOSING THE ROW TAKES THE LINE OFF THE WIRE, not merely off the screen — |
| 79 | // and the `build` FIELD with it, since it carries the same characters that |
| 80 | // row's first item shows. |
| 81 | // |
| 82 | // 4. A NOTE THAT COULD NOT BE SENT IS KEPT, SAYS SO, OFFERS COPY, AND IS |
| 83 | // NEVER RETRIED. The retry half is the one worth the seconds: a queue of |
| 84 | // text outlives the consent that filled it. |
| 85 | // |
| 86 | // 5. THE WORDS APPEAR IN EXACTLY ONE REQUEST IN THE WHOLE SESSION. Every |
| 87 | // request the page makes is read, whatever its address — telemetry, sync, |
| 88 | // a beacon, an image. One carries the note; nothing else carries a |
| 89 | // syllable of it. |
| 90 | // |
| 91 | // 6. A VOTE'S WHOLE BODY IS `d=1`, `d=-1` OR `d=0`. Asserted on the RAW |
| 92 | // characters, so a fifth field smuggled in fails even though the `d` is |
| 93 | // right. And no request ever sends a vote with no `d` at all, which the |
| 94 | // forge reads as malformed and which nothing may read as a withdrawal. |
| 95 | // |
| 96 | // 7. THE VOTE CONTROL IS DRAWN FROM THE ANSWER. Against a forge whose listing |
| 97 | // carries no tally nothing is drawn: not a disabled button, not a zero. |
| 98 | // Against one that carries a tally it is drawn, cast, moved and withdrawn, |
| 99 | // which the checks above 7 do — and both forges are met in one run, which |
| 100 | // is the pair that says the control is reading the record rather than a |
| 101 | // constant. And `mine` ABSENT is drawn differently from `mine` NULL, |
| 102 | // because "I was not asked" and "I have not voted" are different facts and |
| 103 | // a panel that confused them would offer an unvoted button to somebody who |
| 104 | // cannot vote. The AMEND flag is under the same rule and is `cleanProp`'s |
| 105 | // to keep; the forge does not answer it yet, so nothing here can drive it. |
| 106 | // |
| 107 | // 8. A PROPOSAL ROW SAYS ITS OWN STATE AND ITS OWN TALLY. By NAME: the row |
| 108 | // for a named proposal reads Being done and its own numbers, not "there |
| 109 | // are three rows". And voting does not shut the proposal you were reading. |
| 110 | // |
| 111 | // 8b. EVERY ONE OF THE NINE REFUSALS IS SAID, NOT SWALLOWED, and `absent`'s |
| 112 | // sentence is TRUE IN BOTH CASES — it covers "no such repository" and |
| 113 | // "this repository is private" permanently, so a sentence that names |
| 114 | // either is a sentence that is false or that leaks. |
| 115 | // |
| 116 | // 8c. PAGING TERMINATES AND DOES NOT WRAP. `from` is a ceiling that counts |
| 117 | // down and there is no value of it meaning "nothing below this", so |
| 118 | // `from=0` is never sent — and against a forge that reads `from` the wrong |
| 119 | // way round the walk still ENDS rather than offering to show more for ever. |
| 120 | // |
| 121 | // 8h. A DAIMON REACHES THIS PANEL THROUGH THE PANEL'S OWN DOOR. |
| 122 | // `social_read` and `social_send` are B9's Improve half, and they are not a |
| 123 | // second client of `/api/improve`: `socialRead` drives `loadList`, and |
| 124 | // `socialCommit`'s propose arm goes through `through()`, which the panel's |
| 125 | // own Send calls and whose comment names it "THE ONE DOOR a note leaves by, |
| 126 | // whether the press came from the box, from a kept row, or from a daimon |
| 127 | // that was told yes". Asserted here rather than believed, because a tool |
| 128 | // that LOOKS like the panel and is not would be a surface a daimon reaches |
| 129 | // that means something different from what the user's own button means. |
| 130 | // Four things: that a listing reaches every proposal and not merely the |
| 131 | // first page; that composing puts NOTHING on the wire; that committing puts |
| 132 | // exactly one request on it with the same field set the button produces; and |
| 133 | // that a token is spent once, so a yes to one publication is not a licence |
| 134 | // to repeat it. |
| 135 | // |
| 136 | // 9. THE PANEL'S WORDS ARE THE GUIDE'S WORDS. `www/guide/social.html` is |
| 137 | // the only contract this panel was handed, so it is checked mechanically: |
| 138 | // every `<span class="ui">` label in its §"The Social panel" must be |
| 139 | // visible text in the running panel, and every part-noun that section |
| 140 | // sets in bold must be one of the terms the glossary above it defines. |
| 141 | // |
| 142 | // 10. AND IT EXISTS ON A PHONE. |
| 143 | // |
| 144 | // THE SEAM IS ASSERTED BY THIS FILE, not applied by it. The panel's markup and |
| 145 | // its four hand-wired seams live in files this lane does not own; they are all |
| 146 | // landed, so `requireSeams()` checks each is present and hard-stops naming the |
| 147 | // missing one. It used to paste them in through `page.route`, which would have |
| 148 | // hidden a rename rather than reported it. |
| 149 | // |
| 150 | // EACH CHECK IS PROVED AGAINST BROKEN CODE FIRST. `--break <name>` serves a |
| 151 | // deliberately damaged copy of a source file and the run is expected to FAIL. |
| 152 | // A break that does not apply cleanly aborts rather than passing quietly. |
| 153 | // |
| 154 | // node dev/verify_improve.mjs --break keepsends # 1 Keep sends |
| 155 | // node dev/verify_improve.mjs --break nopublic # 1b where it goes, unsaid |
| 156 | // node dev/verify_improve.mjs --break publicalways # 1c said to somebody who cannot send |
| 157 | // node dev/verify_improve.mjs --break underneath # 1d said after the button, not before it |
| 158 | // node dev/verify_improve.mjs --break i18ngap # 1e said in English only |
| 159 | // node dev/verify_improve.mjs --break cappgap # 1f a neighbour's string in seven of eight |
| 160 | // node dev/verify_improve.mjs --break hidden # 2a a line nobody saw |
| 161 | // node dev/verify_improve.mjs --break smuggle # 2b a fifth field |
| 162 | // node dev/verify_improve.mjs --break stalecontext # 2c the palette it named has gone |
| 163 | // node dev/verify_improve.mjs --break stickycontext # 3a closed, and still sent |
| 164 | // node dev/verify_improve.mjs --break buildsticky # 3b closed, and the build still sent |
| 165 | // node dev/verify_improve.mjs --break retry # 4a a failed note is retried |
| 166 | // node dev/verify_improve.mjs --break nocopy # 4b a kept note offers no Copy |
| 167 | // node dev/verify_improve.mjs --break leak # 5 the words go somewhere else too |
| 168 | // node dev/verify_improve.mjs --break votetext # 6 a vote carries a title |
| 169 | // node dev/verify_improve.mjs --break votequeue # 6b a refused vote is drawn as cast |
| 170 | // node dev/verify_improve.mjs --break alwaysvote # 7a a control with nothing behind it |
| 171 | // node dev/verify_improve.mjs --break minesame # 7b not-asked READ as not-voted |
| 172 | // node dev/verify_improve.mjs --break coercechanged # 7d absent `changed` READ as the epoch |
| 173 | // node dev/verify_improve.mjs --break minedraw # 7c not-asked DRAWN as not-voted |
| 174 | // node dev/verify_improve.mjs --break flatstate # 8a every proposal drawn Open |
| 175 | // node dev/verify_improve.mjs --break closeonvote # 8b voting shuts what you were reading |
| 176 | // node dev/verify_improve.mjs --break saidnothing # 8c a refusal swallowed |
| 177 | // node dev/verify_improve.mjs --break absentleak # 8d a sentence that is false when private |
| 178 | // node dev/verify_improve.mjs --break becauseblind # 8e every throttle said the same way |
| 179 | // node dev/verify_improve.mjs --break pagezero # 8f from=0 goes on the wire |
| 180 | // node dev/verify_improve.mjs --break nowrap # 8g the walk never ends |
| 181 | // node dev/verify_improve.mjs --break nolive # 9b the change-feed line goes |
| 182 | // node dev/verify_improve.mjs --break toolonepage # 8h a daimon sees one page only |
| 183 | // node dev/verify_improve.mjs --break eagercompose # 8h composing publishes |
| 184 | // node dev/verify_improve.mjs --break toolsecond # 8h the tool builds its own request |
| 185 | // node dev/verify_improve.mjs --break tokenreuse # 8h one yes, published twice |
| 186 | // node dev/verify_improve.mjs --break renamechip # 9 the panel and the guide disagree |
| 187 | // node dev/verify_improve.mjs # and then, clean |
| 188 | // |
| 189 | // EACH BREAK IS SCOPED SO IT SURVIVES EVERY CHECK BUT THE ONE IT PROVES. That |
| 190 | // is not decoration. `smuggle` adds a field and leaves the two real ones exactly |
| 191 | // right, so check 2a stays green and only 2b moves; had it also mangled the |
| 192 | // body, 2a would have gone red and 2b would have been credited with catching |
| 193 | // something it never saw. `buildsticky` is the same trick against `stickycontext`: |
| 194 | // one leaves the context LINE on the wire, the other leaves only the `build` |
| 195 | // FIELD, and a single "closing does nothing" break would redden both and prove |
| 196 | // neither. `hidden` appends its line only when the row is SHOWN, so check 3 — |
| 197 | // which closes the row — stays green under it. |
| 198 | // |
| 199 | // FIVE BREAKS REDDEN MORE THAN ONE CHECK, established by running all |
| 200 | // twenty-two rather than by reasoning about them. Each is written down rather |
| 201 | // than tidied away, because a break whose reach is not stated is a break whose |
| 202 | // reach is not known: |
| 203 | // |
| 204 | // `keepsends` 2 — "Keep sends nothing" and "the words of a KEPT note are in |
| 205 | // no request at all". One property counted twice, once at the |
| 206 | // button and once over the whole session. |
| 207 | // `leak` 2 — the same check over two different notes. It is written as |
| 208 | // one check per marker so a red names which note leaked. |
| 209 | // `votetext` 2 — the cast and the withdrawal, which is the same assertion |
| 210 | // about the same body made twice on purpose: a client that got |
| 211 | // the first right and the second wrong would be worse than one |
| 212 | // that got both wrong. |
| 213 | // `nopublic` 2 — the disclosure's words, and the same words asked for in |
| 214 | // seven other languages. The second is downstream of the first: a |
| 215 | // sentence that is not drawn at all is not drawn in German |
| 216 | // either. `i18ngap` is what establishes that the language check |
| 217 | // is not merely echoing the wording check — it leaves the English |
| 218 | // exactly right and takes one language away. |
| 219 | // `eagercompose` 2 — "composing puts nothing on the wire" and "committing |
| 220 | // opens exactly one proposal". The second is downstream of the |
| 221 | // first: a compose that published leaves the commit publishing a |
| 222 | // second one. `toolsecond` is what establishes that the field-set |
| 223 | // check is not merely echoing the count -- it leaves the count |
| 224 | // exactly right and changes only what is in the request. |
| 225 | // `minesame` 2 — the record and the drawing. The drawing is downstream of |
| 226 | // the record, which is why `minedraw` exists: it leaves the |
| 227 | // record right and breaks only what is drawn, so the drawing |
| 228 | // check is proved by something that is not merely echoing the |
| 229 | // record check. |
| 230 | // `flatstate` 3 — two state assertions and the guide-words check, because |
| 231 | // three of the four state words the guide names are only ever |
| 232 | // painted by a proposal that is in that state. Nothing can be |
| 233 | // drawn from the guide check that the state checks have not |
| 234 | // already said. |
| 235 | // |
| 236 | // EVERY OTHER BREAK REDDENS EXACTLY ONE. Two were not isolated when first run |
| 237 | // and both were the verifier's fault rather than the panel's, which is worth |
| 238 | // recording because both failures LOOKED like proof: |
| 239 | // |
| 240 | // `keepsends` also reddened "every write carried the voice", because the note |
| 241 | // it sends is sent before a voice exists. That check was asserting two |
| 242 | // properties at once and now asserts one. |
| 243 | // `closeonvote` reddened NOTHING and ended the run: a row shut before the |
| 244 | // vote was cast turns the click into a Playwright timeout, so the run stopped |
| 245 | // at the check it was meant to prove and said nothing about the twenty after |
| 246 | // it. Every interaction inside that row now re-opens it first, and the one |
| 247 | // check that must NOT re-open — whether voting shut it — is measured with |
| 248 | // nothing in between. |
| 249 | // |
| 250 | // WHAT FALLS BETWEEN THE CHECKS, asked deliberately. The pair "the fields put |
| 251 | // back together are what was on screen" and "the field set is exactly these |
| 252 | // three" leaves nothing between them for a proposal write: any character added |
| 253 | // fails the first and any field added fails the second. It leaves plenty |
| 254 | // between them for a request this file never makes — a second write on some |
| 255 | // other route — which is what check 5 is for, and check 5 is blind to the |
| 256 | // address on purpose. |
| 257 | // |
| 258 | // eval "$(bash dev/world.sh 7 --env)" |
| 259 | // node dev/verify_improve.mjs |
| 260 | // |
| 261 | // Needs dev/serve.mjs and node. No gateway and no Rust: the gateway's half of |
| 262 | // the path is reproduced here, from the source, and the forge's half is the |
| 263 | // mock. |
| 264 | import fs from 'node:fs'; |
| 265 | import path from 'node:path'; |
| 266 | import { spawn } from 'node:child_process'; |
| 267 | import { fileURLToPath } from 'node:url'; |
| 268 | import { open, shot, scratch, errors } from './harness.mjs'; |
| 269 | |
| 270 | const HERE = path.dirname(fileURLToPath(import.meta.url)); |
| 271 | const WWW = path.join(HERE, '..', 'www'); |
| 272 | |
| 273 | const BREAK = (() => { |
| 274 | const i = process.argv.indexOf('--break'); |
| 275 | return i > 0 ? String(process.argv[i + 1] || '') : ''; |
| 276 | })(); |
| 277 | |
| 278 | const PROFILE = scratch('pw', 'improve' + (BREAK ? '-' + BREAK : '')); |
| 279 | fs.rmSync(PROFILE, { recursive: true, force: true }); |
| 280 | |
| 281 | const ok = [], bad = []; |
| 282 | const check = (name, pass, detail) => { |
| 283 | (pass ? ok : bad).push(name); |
| 284 | console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : '')); |
| 285 | }; |
| 286 | |
| 287 | // ── The forges ─────────────────────────────────────────────────────── |
| 288 | |
| 289 | const FORGE = { |
| 290 | main: { port: 8437, args: ['--count', '12'] }, |
| 291 | pages: { port: 8438, args: ['--count', '50'] }, |
| 292 | // A forge that reads `from` as a LOWER bound. Not a straw man: it is what |
| 293 | // both implementations of this contract wrote first, and it is the shape |
| 294 | // that makes the obvious paging loop never terminate. |
| 295 | wrong: { port: 8439, args: ['--count', '50', '--break', 'fromascends'] }, |
| 296 | // A listing with no tally on it. NOT the forge as it stands -- it answers |
| 297 | // `votes` now, on both shapes -- and kept for exactly that reason: run beside |
| 298 | // the tallied forge above, the pair is what proves the control reads the |
| 299 | // RECORD and not a constant somebody will forget to change. |
| 300 | dark: { port: 8440, args: ['--break', 'novotes'] }, |
| 301 | // The one token no path the panel takes can reach naturally. |
| 302 | unsup: { port: 8441, args: ['--refuse', 'unsupported'] }, |
| 303 | }; |
| 304 | |
| 305 | const started = []; |
| 306 | |
| 307 | async function reachable(port) { |
| 308 | try { |
| 309 | const r = await fetch(`http://127.0.0.1:${port}/a/b/proposals?format=json&limit=1`); |
| 310 | await r.text(); |
| 311 | return true; |
| 312 | } catch (e) { return false; } |
| 313 | } |
| 314 | |
| 315 | async function startForges() { |
| 316 | for (const [name, f] of Object.entries(FORGE)) { |
| 317 | if (await reachable(f.port)) { |
| 318 | console.error(`:${f.port} is already held by something. Free it, or the run below ` |
| 319 | + 'would be driven against somebody else\'s forge.'); |
| 320 | process.exit(2); |
| 321 | } |
| 322 | const p = spawn('node', [path.join(HERE, 'mock_forge.mjs'), '--port', String(f.port), ...f.args], |
| 323 | { stdio: ['ignore', 'ignore', 'inherit'] }); |
| 324 | started.push(p); |
| 325 | f.proc = p; |
| 326 | } |
| 327 | for (let i = 0; i < 100; i++) { |
| 328 | const all = await Promise.all(Object.values(FORGE).map(f => reachable(f.port))); |
| 329 | if (all.every(Boolean)) return; |
| 330 | await new Promise(r => setTimeout(r, 100)); |
| 331 | } |
| 332 | console.error('a forge never bound; nothing below would prove anything.'); |
| 333 | process.exit(2); |
| 334 | } |
| 335 | |
| 336 | function stopForges() { |
| 337 | for (const p of started) { try { p.kill('SIGTERM'); } catch (e) { /* already gone */ } } |
| 338 | started.length = 0; |
| 339 | } |
| 340 | |
| 341 | // A run that fell over before its `finally` used to leave five node processes |
| 342 | // holding five ports, and the next run then refused to start against them -- |
| 343 | // which reads as a fault in the panel and is a fault in this file. An orphaned |
| 344 | // mock holding a port has caused a false failure here before. |
| 345 | process.on('exit', stopForges); |
| 346 | for (const sig of ['SIGINT', 'SIGTERM']) { |
| 347 | process.on(sig, () => { stopForges(); process.exit(130); }); |
| 348 | } |
| 349 | |
| 350 | // ── The seam ───────────────────────────────────────────────────────── |
| 351 | // |
| 352 | // The panel's markup and its four hand-wired seams live in files this lane did |
| 353 | // not write. They used to be PASTED IN HERE through `page.route`, so that the |
| 354 | // checks below could run before the owning lanes had landed them. |
| 355 | // |
| 356 | // THEY ARE LANDED. So the paste is gone and what is left is an ASSERTION: each |
| 357 | // seam must be in the file on disk, and a missing one is a hard stop naming it |
| 358 | // rather than a quiet repair. The paste form outlived its purpose the day the |
| 359 | // edits went in, and an injector that silently re-adds what a rename has just |
| 360 | // taken away is how a verifier goes green against a panel nobody can reach -- |
| 361 | // it would have re-added `improve: 1,` to `MOBILE_GUESTS` on top of the |
| 362 | // `social: 1,` that replaced it, and proved the phone case against a panel id |
| 363 | // the app no longer has. |
| 364 | |
| 365 | const SEAM = [ |
| 366 | // On a phone a dock panel is only reachable if it is a GUEST — something |
| 367 | // that rises over the chat as a sheet. Asked for by SHAPE and not by the |
| 368 | // table's literal text: that table is edited every time a panel is added. |
| 369 | { file: 'js/daimond.js', want: /var MOBILE_GUESTS = \{[\s\S]*?\n\t\tsocial: 1,/, |
| 370 | why: '`social` is not in MOBILE_GUESTS, so the panel is a blank screen on a phone' }, |
| 371 | // And the sheet's "ask about this" pill is not offered on it. The panel is |
| 372 | // already the place you write in; a second box under it, which sends what |
| 373 | // you write to a model, is two boxes with opposite meanings. |
| 374 | { file: 'js/mobile.js', want: /var NO_ASK\s*=\s*\{[^}]*\bsocial: 1\b/, |
| 375 | why: '`social` is not in NO_ASK, so the sheet offers an ask pill over the note box' }, |
| 376 | { file: 'index.html', want: 'href="css/improve.css"', |
| 377 | why: 'the panel\'s stylesheet is not linked' }, |
| 378 | { file: 'index.html', want: '<script src="js/improve.js"></script>', |
| 379 | why: 'the panel\'s script is not loaded' }, |
| 380 | { file: 'index.html', want: 'id="panel-social"', |
| 381 | why: 'the panel is not in the markup' }, |
| 382 | { file: 'index.html', want: 'data-panel="social"', |
| 383 | why: 'the panel does not declare itself to the layout engine as `social`' }, |
| 384 | ]; |
| 385 | |
| 386 | /// Every seam, or a hard stop naming the one that is missing. Run before a |
| 387 | /// browser is started: a run against a half-wired app proves nothing and takes |
| 388 | /// four minutes to say so. |
| 389 | function requireSeams() { |
| 390 | const bad = []; |
| 391 | for (const s of SEAM) { |
| 392 | const src = fs.readFileSync(path.join(WWW, s.file), 'utf8'); |
| 393 | const ok = (s.want instanceof RegExp) ? s.want.test(src) : src.includes(s.want); |
| 394 | if (!ok) bad.push(` ${s.file}: ${s.why}`); |
| 395 | } |
| 396 | if (bad.length) { |
| 397 | console.error('the panel is not wired up, so this run would prove nothing:'); |
| 398 | for (const b of bad) console.error(b); |
| 399 | process.exit(2); |
| 400 | } |
| 401 | } |
| 402 | |
| 403 | // ── The breaks ─────────────────────────────────────────────────────── |
| 404 | // Each is a real edit to a real file, served in place of it. `find` must appear |
| 405 | // exactly once: a break that silently matched nothing would leave the suite |
| 406 | // green against working code and prove the opposite of what it claims. |
| 407 | const BREAKS = { |
| 408 | // Keep reaches the network. The panel would look identical. |
| 409 | keepsends: [{ |
| 410 | file: 'js/improve.js', |
| 411 | find: '\t\tvar rec = store(text, 0);\n\t\tclearBox();\n\t\trender();\n\t\treturn rec;\n\t}', |
| 412 | with: '\t\tvar rec = store(text, 0);\n\t\tclearBox();\n\t\trender();\n\t\tpost(split(text));\n\t\treturn rec;\n\t}', |
| 413 | }], |
| 414 | // The sentence saying where a sent note goes is not said. The element is |
| 415 | // still there and still in the right place, so the two checks either side of |
| 416 | // it stay green and only the one about the WORDS moves. |
| 417 | nopublic: [{ |
| 418 | file: 'js/improve.js', |
| 419 | find: "\t\tline.textContent = tOr('social.compose_public',", |
| 420 | with: "\t\tline.textContent = '';\n\t\tif (0) line.textContent = tOr('social.compose_public',", |
| 421 | }], |
| 422 | // Said to somebody who cannot send it. A tester with no voice is told their |
| 423 | // notes will be published, which is false — and a sentence that is false in |
| 424 | // one state is not believed in the other. |
| 425 | publicalways: [{ |
| 426 | file: 'js/improve.js', |
| 427 | find: '\t\tline.hidden = !(send && !send.hidden);', |
| 428 | with: '\t\tline.hidden = false;', |
| 429 | }], |
| 430 | // Said UNDER the button instead of above it, where a hand on its way to Send |
| 431 | // passes it. Every word of it is still right, which is the point: this is the |
| 432 | // break that tells the placement check from the wording check. |
| 433 | underneath: [{ |
| 434 | file: 'js/improve.js', |
| 435 | find: '\t\t\twrite.insertBefore(line, acts);\t\t// above the buttons, under the box', |
| 436 | with: '\t\t\tacts.parentNode.insertBefore(line, acts.nextSibling);', |
| 437 | }], |
| 438 | // One language short. English falls through correctly, so the panel looks |
| 439 | // right in German and says the English sentence — which is exactly how the |
| 440 | // last release put fifteen keys in `en.js` and nowhere else with nothing |
| 441 | // reporting it. Matched by SHAPE, so a later rewording of the German does not |
| 442 | // turn this break into a false abort. |
| 443 | i18ngap: [{ |
| 444 | file: 'i18n/de.js', |
| 445 | re: /\n\t'social\.public_note': '[^']*',/, |
| 446 | with: '', |
| 447 | }], |
| 448 | // One of the capp strings in seven catalogues and not the eighth. English |
| 449 | // falls through, so the dialog reads correctly in Spanish and nothing reports |
| 450 | // it — which is the whole failure mode, and why it is checked at all. |
| 451 | cappgap: [{ |
| 452 | file: 'i18n/es.js', |
| 453 | re: /\n\t'capp\.update_kept': '[^']*',/, |
| 454 | with: '', |
| 455 | }], |
| 456 | // One line appended that was never on the screen. The note is all there, so |
| 457 | // a check that asked whether the body CONTAINS the note would pass. |
| 458 | hidden: [{ |
| 459 | file: 'js/improve.js', |
| 460 | find: '\t\treturn ctx ? (body + \'\\n\\n\' + ctx) : body;', |
| 461 | with: '\t\treturn ctx ? (body + \'\\n\\n\' + ctx + \'\\n\\nseen: \' + navigator.userAgent) : body;', |
| 462 | }], |
| 463 | // A fifth field, and the two real ones left exactly right. This is the whole |
| 464 | // reason the field set is asserted at all: with an envelope on the wire, |
| 465 | // "the note is in there" stopped being the same claim as "and nothing else |
| 466 | // is". |
| 467 | smuggle: [{ |
| 468 | file: 'js/improve.js', |
| 469 | find: '\t\tif (parts.build) f.set(\'build\', parts.build);', |
| 470 | with: '\t\tif (parts.build) f.set(\'build\', parts.build);\n\t\tf.set(\'via\', navigator.userAgent);', |
| 471 | }], |
| 472 | // The line is built when the panel opens and never again. Every OTHER check |
| 473 | // stays green under it -- the row is still on the screen, still on the wire, |
| 474 | // still gone when the row is closed -- and 2c reddens on its own, which is |
| 475 | // what makes it a check about staleness rather than about the row existing. |
| 476 | stalecontext: [{ |
| 477 | file: 'js/improve.js', |
| 478 | find: '\t\tif (contextOff()) return body;\n\t\tdrawContext();', |
| 479 | with: '\t\tif (contextOff()) return body;', |
| 480 | }], |
| 481 | // The row is closed, and the line goes anyway: off the screen, on the wire. |
| 482 | stickycontext: [{ |
| 483 | file: 'js/improve.js', |
| 484 | find: '\t\tif (contextOff()) return body;', |
| 485 | with: '\t\tif (contextOff() && false) return body;', |
| 486 | }], |
| 487 | // The row is closed, the LINE is honestly gone, and the build identifier it |
| 488 | // was showing goes anyway. Deliberately not the same break as the one above: |
| 489 | // a single "closing does nothing" edit would redden both checks and prove |
| 490 | // neither. |
| 491 | buildsticky: [{ |
| 492 | file: 'js/improve.js', |
| 493 | find: '\t\treturn { title: title, body: body, build: contextOff() ? \'\' : _build };', |
| 494 | with: '\t\treturn { title: title, body: body, build: _build };', |
| 495 | }], |
| 496 | // ── 8h. The tool pair, and the door it goes through ────────────── |
| 497 | // |
| 498 | // A daimon reads the listing with `social_read`, and a page is not a listing: |
| 499 | // `loadList(false)` fetches PAGE records and the panel offers a button for |
| 500 | // the rest, which a tool call cannot press. This is that world. |
| 501 | toolonepage: [{ |
| 502 | file: 'js/improve.js', |
| 503 | find: '\t\t\tvar steps = 0;\n\t\t\twhile (_order.length < limit && !_list.done && steps++ < 8) {', |
| 504 | with: '\t\t\tvar steps = 0;\n\t\t\twhile (false) {', |
| 505 | }], |
| 506 | // Composing publishes. The consent question is then about a payload that has |
| 507 | // already gone, which is the whole of what compose-then-commit exists to stop. |
| 508 | eagercompose: [{ |
| 509 | file: 'js/improve.js', |
| 510 | find: "\t\tvar token = 'd' + (++_draftN) + '-' + Math.random().toString(36).slice(2, 10);", |
| 511 | with: "\t\tif (act === 'propose') { await through(store(payload.title + (payload.body ? '\\n' + payload.body : ''), 0), payload); }\n" |
| 512 | + "\t\tvar token = 'd' + (++_draftN) + '-' + Math.random().toString(36).slice(2, 10);", |
| 513 | }], |
| 514 | // The tool builds its own request instead of going through the panel's one |
| 515 | // door. The proposal still opens and the panel's own Send is untouched, so |
| 516 | // every check but the field set stays green -- which is exactly the shape of |
| 517 | // the defect: a surface a daimon reaches that means something slightly |
| 518 | // different from what the user's button means. |
| 519 | toolsecond: [{ |
| 520 | file: 'js/improve.js', |
| 521 | find: '\t\t\tvar a = await through(rec, { title: d.title, body: d.body, build: d.build });', |
| 522 | with: "\t\t\tvar tf = new URLSearchParams();\n" |
| 523 | + "\t\t\ttf.set('title', d.title); tf.set('body', d.body); tf.set('tool', '1');\n" |
| 524 | + "\t\t\tvar a = await ask(route(''), { method: 'POST', " |
| 525 | + "headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: tf.toString() });", |
| 526 | }], |
| 527 | // A token left spendable. A yes about one publication becomes a licence to |
| 528 | // publish it again, which is the per-host memory mistake this panel must not |
| 529 | // repeat: what is approved here is a payload, not a destination. |
| 530 | tokenreuse: [{ |
| 531 | file: 'js/improve.js', |
| 532 | find: "\t\tvar held = _drafts[String(token || '')];\n\t\tdelete _drafts[String(token || '')];", |
| 533 | with: "\t\tvar held = _drafts[String(token || '')];", |
| 534 | }], |
| 535 | // A failed note is queued and tried again. This is the failure the whole |
| 536 | // design refuses, and it is invisible from the panel. |
| 537 | retry: [{ |
| 538 | file: 'js/improve.js', |
| 539 | find: '\t\t} else flash(keptAfter(a));\n\t\trender();\n\t\treturn rec;', |
| 540 | with: '\t\t} else { flash(keptAfter(a)); setTimeout(function () { post(parts); }, 2500); }\n\t\trender();\n\t\treturn rec;', |
| 541 | }], |
| 542 | // A note that could not be sent, and no way to carry it out by hand. The row |
| 543 | // still says it is kept, so everything about check 4a stays green. |
| 544 | nocopy: [{ |
| 545 | file: 'js/improve.js', |
| 546 | find: '\t\t\tfoot.appendChild(button(\'imp-note-copy\', \'improve-copy\', t(\'common.copy\'), t(\'common.copy\')));', |
| 547 | with: '\t\t\tif (n.sent) foot.appendChild(button(\'imp-note-copy\', \'improve-copy\', t(\'common.copy\'), t(\'common.copy\')));', |
| 548 | }], |
| 549 | // The words go somewhere else as well. Everything about the proposal request |
| 550 | // stays correct, so checks 1 to 4 all still pass. |
| 551 | leak: [{ |
| 552 | file: 'js/improve.js', |
| 553 | find: '\t\treturn await ask(route(\'\'), {\n\t\t\tmethod: \'POST\',\n\t\t\theaders: { \'Content-Type\': \'application/x-www-form-urlencoded\' },\n\t\t\tbody: f.toString(),\n\t\t});', |
| 554 | with: '\t\ttry { fetch(\'/api/telemetry\', { method: \'POST\', body: JSON.stringify({ e: parts.body }) }); } catch (e) {}\n' |
| 555 | + '\t\treturn await ask(route(\'\'), {\n\t\t\tmethod: \'POST\',\n\t\t\theaders: { \'Content-Type\': \'application/x-www-form-urlencoded\' },\n\t\t\tbody: f.toString(),\n\t\t});', |
| 556 | }], |
| 557 | // A vote carries the proposal's title, "for the operator's convenience". The |
| 558 | // `d` is still right, so a check on the direction alone would pass. |
| 559 | votetext: [{ |
| 560 | file: 'js/improve.js', |
| 561 | find: '\t\t\tbody: body,\n\t\t});\n\t\tif (!a.ok) { _list.err = a; drawProps(); return false; }\n\t\tabsorb(cleanProp(a.data));', |
| 562 | with: '\t\t\tbody: body + \'&title=\' + encodeURIComponent(rec.title),\n\t\t});\n\t\tif (!a.ok) { _list.err = a; drawProps(); return false; }\n\t\tabsorb(cleanProp(a.data));', |
| 563 | }], |
| 564 | // A vote the forge refused, drawn as though it had been counted. This is the |
| 565 | // old client-side queue coming back in its mildest form, and it is the exact |
| 566 | // shape §9 rejected: two stores of truth, and the panel showing the one that |
| 567 | // is wrong. |
| 568 | votequeue: [{ |
| 569 | file: 'js/improve.js', |
| 570 | // Anchored through the line ABOVE it: `loadOne` refuses in exactly the |
| 571 | // same words, so the shorter anchor matched twice and the break landed |
| 572 | // nowhere -- which is a check proving nothing, dressed as a check. |
| 573 | find: '\t\t\tbody: body,\n\t\t});\n\t\tif (!a.ok) { _list.err = a; drawProps(); return false; }', |
| 574 | with: '\t\t\tbody: body,\n\t\t});\n\t\tif (!a.ok) { _list.err = a; rec.mine = (rec.mine === want ? null : want); drawProps(); return false; }', |
| 575 | }], |
| 576 | // A vote control drawn over a record that has no tally: a zero where the |
| 577 | // forge has said nothing at all. This is the defect class the whole rewrite |
| 578 | // was about, reintroduced one control at a time. |
| 579 | alwaysvote: [{ |
| 580 | file: 'js/improve.js', |
| 581 | find: '\t\tif (!p.votes) return;', |
| 582 | with: '\t\tif (!p.votes) p = Object.assign({}, p, { votes: { for: 0, against: 0 } });', |
| 583 | }], |
| 584 | // "I was not asked" drawn as "I have not voted". Everything a voiced reader |
| 585 | // sees is unchanged, so every other vote check stays green. |
| 586 | minesame: [{ |
| 587 | file: 'js/improve.js', |
| 588 | find: '\t\tif (Object.prototype.hasOwnProperty.call(p, \'mine\')) {\n\t\t\trec.asked = true;', |
| 589 | with: '\t\tif (true) {\n\t\t\trec.asked = true;', |
| 590 | }], |
| 591 | // ABSENT READ AS ZERO. `changed` is coerced like every other number, so an |
| 592 | // answer that said nothing about when a proposal was last revised becomes |
| 593 | // "revised at the epoch" -- older than every proposal there is. The record |
| 594 | // looks perfectly ordinary and the panel draws identically; what is lost is |
| 595 | // the difference between "not answered" and "answered, and it is old", which |
| 596 | // is the difference anything comparing the field would rest on. Written to |
| 597 | // match `minesame` above, because it is the same rule on a different field. |
| 598 | coercechanged: [{ |
| 599 | file: 'js/improve.js', |
| 600 | find: '\t\t\tchanged: (typeof p.changed === \'number\') ? Math.max(0, whole(p.changed)) : null,', |
| 601 | with: '\t\t\tchanged: Math.max(0, whole(p.changed)),', |
| 602 | }], |
| 603 | // The drawing half of the same rule, on its own. `minesame` breaks the |
| 604 | // RECORD and the drawing goes with it, because the drawing is downstream; |
| 605 | // this one leaves the record exactly right and breaks only what is drawn, |
| 606 | // which is what establishes that the drawing check is not merely echoing |
| 607 | // the record check. |
| 608 | minedraw: [{ |
| 609 | file: 'js/improve.js', |
| 610 | find: '\t\tif (!p.asked) {', |
| 611 | with: '\t\tif (false) {', |
| 612 | }], |
| 613 | // Every proposal drawn Open, whatever it says. The list is the right length |
| 614 | // and every title is right. |
| 615 | flatstate: [{ |
| 616 | file: 'js/improve.js', |
| 617 | find: '\t\t\tstate: STATES[p.state] ? p.state : \'open\',', |
| 618 | with: '\t\t\tstate: \'open\',', |
| 619 | }], |
| 620 | // Voting closes the proposal you were reading — the answer vanishing along |
| 621 | // with the question. This is what the first build of the panel did. |
| 622 | closeonvote: [{ |
| 623 | file: 'js/improve.js', |
| 624 | find: '\t\t\tbody.hidden = !_open[String(p.n)];', |
| 625 | with: '\t\t\tbody.hidden = true;', |
| 626 | }], |
| 627 | // One refusal swallowed. Scoped to `internal` alone: a break that silenced |
| 628 | // them all would redden nine checks and prove one. |
| 629 | saidnothing: [{ |
| 630 | file: 'js/improve.js', |
| 631 | find: '\t\t\treturn tOr(\'social.err_internal\', \'Something went wrong at the forge. This is not your fault.\');', |
| 632 | with: '\t\t\treturn \'\';', |
| 633 | }], |
| 634 | // A sentence that is false when the repository is private. `absent` covers |
| 635 | // both cases permanently, so this is not a nicety of wording: it is the |
| 636 | // privacy rule, undone by a kindness. |
| 637 | absentleak: [{ |
| 638 | file: 'js/improve.js', |
| 639 | find: '\t\t\treturn tOr(\'social.err_absent\', \'This repository is not available to you.\');', |
| 640 | with: '\t\t\treturn tOr(\'social.err_absent_broken\', \'There is no such repository.\');', |
| 641 | }], |
| 642 | // Every throttle said the same way, so a tester refused for the address is |
| 643 | // told it was their own voice. The token is still branched on, so the |
| 644 | // presence checks all stay green. |
| 645 | becauseblind: [{ |
| 646 | file: 'js/improve.js', |
| 647 | find: '\t\t\tif (a.because === \'address\') {', |
| 648 | with: '\t\t\tif (false) {', |
| 649 | }], |
| 650 | // `from=0` on the wire. There is no value of `from` that says "nothing below |
| 651 | // this", and zero means back to the newest — so this is the request that |
| 652 | // turns a walk into a circle. Two sites, because the guard is layered and |
| 653 | // removing one leaves the other holding. |
| 654 | pagezero: [ |
| 655 | { |
| 656 | file: 'js/improve.js', |
| 657 | find: '\t\t\tif (!(_list.lowest > 1)) { _list.done = true; drawProps(); return false; }', |
| 658 | with: '\t\t\tif (!(_list.lowest > 0)) { _list.done = true; drawProps(); return false; }', |
| 659 | }, |
| 660 | { |
| 661 | file: 'js/improve.js', |
| 662 | find: '\t\tif (lowest === null || lowest <= 1) _list.done = true;', |
| 663 | with: '\t\tif (lowest === null) _list.done = true;', |
| 664 | }, |
| 665 | ], |
| 666 | // The belt comes off: a walk that does not descend goes on offering to show |
| 667 | // more, for ever, against a forge that reads `from` the wrong way round. The |
| 668 | // two cheaper rules still hold against a forge that reads it correctly, so |
| 669 | // ordinary paging stays green and only the wrong-forge check moves. |
| 670 | nowrap: [{ |
| 671 | file: 'js/improve.js', |
| 672 | find: '\t\tif (more && lowest !== null && _list.lowest !== null && lowest >= _list.lowest) {\n\t\t\t_list.done = true;\n\t\t}', |
| 673 | with: '\t\tif (false) { _list.done = true; }', |
| 674 | }], |
| 675 | // The line that says nothing will tell you when a proposal is answered. |
| 676 | // There is no change feed on either side; a panel that does not say so is a |
| 677 | // panel a tester waits at. |
| 678 | nolive: [{ |
| 679 | file: 'js/improve.js', |
| 680 | find: '\t\t\tasAt.textContent = tOr(\'social.live_note\',\n' |
| 681 | + '\t\t\t\t\'These are read from the forge as you look at them. Nothing tells you when a proposal is answered; look again to find out.\');', |
| 682 | with: '\t\t\tasAt.textContent = \'\';', |
| 683 | }], |
| 684 | // The panel calls a chip something the guide does not. Broken in the CATALOGUE |
| 685 | // and not in the markup, because the markup's English is only a fallback: |
| 686 | // `data-i18n` paints the table's word over it on the first apply. |
| 687 | renamechip: [{ |
| 688 | file: 'i18n/en.js', |
| 689 | find: "\t'social.proposals': 'Proposals',", |
| 690 | with: "\t'social.proposals': 'Feedback',", |
| 691 | }], |
| 692 | }; |
| 693 | |
| 694 | if (BREAK && !BREAKS[BREAK]) { |
| 695 | console.error(`unknown break '${BREAK}'; one of: ${Object.keys(BREAKS).join(', ')}`); |
| 696 | process.exit(2); |
| 697 | } |
| 698 | |
| 699 | /// Apply one edit to a source file, or stop. Nothing is served that was not |
| 700 | /// verified to differ from what it started as. |
| 701 | function edit(src, spec, what) { |
| 702 | if (spec.re) { |
| 703 | const all = src.match(new RegExp(spec.re.source, spec.re.flags.replace('g', '') + 'g')); |
| 704 | const n = all ? all.length : 0; |
| 705 | if (n !== 1) { |
| 706 | console.error(`${what}: the shape ${spec.re} matches ${n} time(s) in ${spec.file}, ` |
| 707 | + 'so nothing was changed and the run below would prove nothing.'); |
| 708 | process.exit(2); |
| 709 | } |
| 710 | return src.replace(spec.re, spec.with); |
| 711 | } |
| 712 | const n = src.split(spec.find).length - 1; |
| 713 | if (n !== 1) { |
| 714 | console.error(`${what}: the anchor appears ${n} times in ${spec.file}, ` |
| 715 | + 'so nothing was changed and the run below would prove nothing.'); |
| 716 | process.exit(2); |
| 717 | } |
| 718 | return src.replace(spec.find, spec.with); |
| 719 | } |
| 720 | |
| 721 | /// The file as it should be served: this run's break on top of what is on disk. |
| 722 | const FILES = new Map(); // path under www/ -> the text to serve |
| 723 | |
| 724 | function build() { |
| 725 | requireSeams(); |
| 726 | if (!BREAK) return; |
| 727 | for (const spec of BREAKS[BREAK]) { |
| 728 | const p = spec.file; |
| 729 | const src = FILES.get(p) ?? fs.readFileSync(path.join(WWW, p), 'utf8'); |
| 730 | FILES.set(p, edit(src, spec, `break '${BREAK}'`)); |
| 731 | } |
| 732 | } |
| 733 | build(); |
| 734 | |
| 735 | // ── The gateway, stood in for ──────────────────────────────────────── |
| 736 | // |
| 737 | // `gateway/src/handlers/improve.rs` builds the upstream request-target, refuses |
| 738 | // what it will not forward, translates `x-daimond-voice` into the forge's |
| 739 | // `x-ore-voice`, and hands the answer back VERBATIM. All four are reproduced |
| 740 | // here from that file. What is deliberately NOT reproduced is the per-tester |
| 741 | // meter, which is Rust's own and is tested there: a second, different copy of a |
| 742 | // limiter is a thing to disagree with the real one. |
| 743 | |
| 744 | /// Which forge the panel's requests are pointed at, and at which repository. |
| 745 | /// The panel names one repository and cannot be made to name another — that is |
| 746 | /// the contract — so the STAND-IN rewrites it, exactly as a gateway pointed at a |
| 747 | /// different forge would. The panel stays honest and every refusal stays |
| 748 | /// reachable. |
| 749 | let forge = FORGE.main; |
| 750 | /// What the `changed` hop in `improveRoute` does, or null for nothing: |
| 751 | /// `{ n, to }`, where a `to` of `undefined` takes the field off and a number |
| 752 | /// puts that number there. |
| 753 | let changedHop = null; |
| 754 | let asRepo = ''; // '' means "whatever the panel asked for" |
| 755 | |
| 756 | const NAME = /^[A-Za-z0-9_-]+$/; |
| 757 | |
| 758 | /// The path `upstream_path()` builds. `format=json` is written HERE and never |
| 759 | /// taken from the caller. |
| 760 | function upstreamPath(u, repo) { |
| 761 | const q = u.searchParams; |
| 762 | const n = q.get('n'); |
| 763 | const voting = q.get('vote') === '1'; |
| 764 | let p = `/${q.get('account')}/${repo}/proposals`; |
| 765 | if (n !== null) p += '/' + n + (voting ? '/vote' : ''); |
| 766 | p += '?format=json'; |
| 767 | if (n === null) { |
| 768 | const state = q.get('state'); |
| 769 | if (state !== null) p += '&state=' + state; |
| 770 | const from = q.get('from'); |
| 771 | if (from !== null) p += '&from=' + from; |
| 772 | const limit = q.get('limit'); |
| 773 | if (limit !== null) p += '&limit=' + limit; |
| 774 | } |
| 775 | return p; |
| 776 | } |
| 777 | |
| 778 | /// EVERY request the page made, whatever its address. Check 5 reads all of it. |
| 779 | const wire = []; |
| 780 | /// Every request that reached the improve route, in order. |
| 781 | const asked = []; |
| 782 | |
| 783 | const HDR = 'x-daimond-voice'; |
| 784 | |
| 785 | async function improveRoute(r) { |
| 786 | const req = r.request(); |
| 787 | const u = new URL(req.url()); |
| 788 | const q = u.searchParams; |
| 789 | const method = req.method(); |
| 790 | const body = req.postData() || ''; |
| 791 | const headers = req.headers(); |
| 792 | asked.push({ url: req.url(), method, body, query: Object.fromEntries(q), headers }); |
| 793 | |
| 794 | const refuse = (status, sentence) => r.fulfill({ |
| 795 | status, contentType: 'application/json', |
| 796 | body: JSON.stringify({ ok: false, error: sentence }), |
| 797 | }); |
| 798 | |
| 799 | // The gateway's own refusals, in its own order. |
| 800 | const account = q.get('account') || ''; |
| 801 | const repo = asRepo || q.get('repo') || ''; |
| 802 | if (!NAME.test(account)) return refuse(400, 'An account is letters, digits, \'-\' and \'_\'.'); |
| 803 | if (!NAME.test(repo)) return refuse(400, 'A repository is letters, digits, \'-\' and \'_\'.'); |
| 804 | const n = q.get('n'); |
| 805 | if (n !== null && !/^[0-9]+$/.test(n)) return refuse(400, '\'n\' is a proposal number.'); |
| 806 | const limit = q.get('limit'); |
| 807 | if (limit !== null && !(/^[0-9]+$/.test(limit) && Number(limit) >= 1 && Number(limit) <= 200)) { |
| 808 | return refuse(400, '\'limit\' is between 1 and 200.'); |
| 809 | } |
| 810 | const voice = headers[HDR]; |
| 811 | if (method === 'POST' && !voice) { |
| 812 | return refuse(401, 'Writing on the forge needs your voice, which Daimond sends with the ' |
| 813 | + 'request and never keeps here.'); |
| 814 | } |
| 815 | |
| 816 | const out = { 'accept': 'application/json' }; |
| 817 | // The forge's own spelling. The translation is the gateway's, so that the |
| 818 | // panel is not coupled to the forge's vocabulary. |
| 819 | if (voice) out['x-ore-voice'] = (voice === SECRET ? MOCK_SECRET : voice); |
| 820 | if (method === 'POST') out['content-type'] = headers['content-type'] || 'application/x-www-form-urlencoded'; |
| 821 | |
| 822 | let res, text; |
| 823 | try { |
| 824 | res = await fetch(`http://127.0.0.1:${forge.port}` + upstreamPath(u, repo), { |
| 825 | method, headers: out, body: method === 'POST' ? body : undefined, |
| 826 | }); |
| 827 | text = await res.text(); |
| 828 | } catch (e) { |
| 829 | return r.fulfill({ status: 502, contentType: 'application/json', |
| 830 | body: JSON.stringify({ ok: false, error: 'The forge could not be reached just now.' }) }); |
| 831 | } |
| 832 | // ── THE ONE FIELD THIS FILE CHANGES ON THE WAY PAST ────────────── |
| 833 | // |
| 834 | // The forge always answers `changed`, so the case `cleanProp` is written for |
| 835 | // — an answer that says NOTHING about it — cannot be had by asking a forge |
| 836 | // for it, and a check driven only against the real shape proves nothing |
| 837 | // about the line that matters. `absorb` keeps a record across a listing, so |
| 838 | // this hop is the only way the panel can be handed a silent answer at all. |
| 839 | // |
| 840 | // Confined to one field on one proposal, and named, so a reader can see the |
| 841 | // whole of what is not the forge's own bytes. Everything else stays verbatim. |
| 842 | if (changedHop) { |
| 843 | try { |
| 844 | const j = JSON.parse(text); |
| 845 | const list = Array.isArray(j.proposals) ? j.proposals |
| 846 | : (j.proposal ? [j.proposal] : []); |
| 847 | for (const one of list) { |
| 848 | if (Number(one.number) !== changedHop.n) continue; |
| 849 | if (changedHop.to === undefined) delete one.changed; |
| 850 | else one.changed = changedHop.to; |
| 851 | } |
| 852 | text = JSON.stringify(j); |
| 853 | } catch (e) { /* not JSON: there is nothing to take off */ } |
| 854 | } |
| 855 | // Verbatim, status and body. The forge answers canonical JSON precisely so |
| 856 | // that two ends agreeing on the value agree on the bytes. |
| 857 | return r.fulfill({ |
| 858 | status: res.status, |
| 859 | contentType: res.headers.get('content-type') || 'application/json', |
| 860 | body: text, |
| 861 | }); |
| 862 | } |
| 863 | |
| 864 | const json = (body, status = 200) => ({ |
| 865 | status, contentType: 'application/json', body: JSON.stringify(body), |
| 866 | }); |
| 867 | |
| 868 | async function stub(page) { |
| 869 | for (const [p, body] of FILES) { |
| 870 | const type = p.endsWith('.html') ? 'text/html' : 'application/javascript'; |
| 871 | await page.route('**/' + p, r => r.fulfill({ status: 200, contentType: type, body })); |
| 872 | } |
| 873 | if (FILES.has('index.html')) { |
| 874 | await page.route(u => u.pathname === '/' || u.pathname === '/index.html', |
| 875 | r => r.fulfill({ status: 200, contentType: 'text/html', body: FILES.get('index.html') })); |
| 876 | } |
| 877 | |
| 878 | // Every request, recorded before anything else answers it. This is what |
| 879 | // check 5 reads, and it is deliberately blind to the address. |
| 880 | page.on('request', req => { |
| 881 | let body = ''; |
| 882 | try { body = req.postData() || ''; } catch (e) { body = ''; } |
| 883 | wire.push({ url: req.url(), method: req.method(), body }); |
| 884 | }); |
| 885 | |
| 886 | await page.route(u => u.pathname === '/api/improve', improveRoute); |
| 887 | |
| 888 | await page.route('**/api/telemetry', r => r.fulfill(json({ ok: true }))); |
| 889 | await page.route('**/api/account', r => r.fulfill(json({ ok: true }))); |
| 890 | await page.route('**/api/auth/challenge', r => r.fulfill(json({ ok: true, challenge: 'chal-imp', challenge_id: 'cid-1' }))); |
| 891 | await page.route('**/api/auth/verify', r => r.fulfill(json({ ok: true }))); |
| 892 | await page.route('**/api/balance', r => r.fulfill(json({ ok: true, credits_minor: 0, currency: 'usd', entries: [] }))); |
| 893 | await page.route('**/api/licence', r => r.fulfill(json({ ok: true, licence: false, currency: 'usd' }))); |
| 894 | } |
| 895 | |
| 896 | // ── The guide, read as the contract it is ──────────────────────────── |
| 897 | |
| 898 | const GUIDE = fs.readFileSync(path.join(WWW, 'guide', 'social.html'), 'utf8'); |
| 899 | |
| 900 | function guideSection() { |
| 901 | const i = GUIDE.indexOf('<section id="social-panel">'); |
| 902 | const j = GUIDE.indexOf('</section>', i); |
| 903 | if (i === -1 || j === -1) { |
| 904 | console.error('the guide has no §"The Social panel"; check 9 would prove nothing.'); |
| 905 | process.exit(2); |
| 906 | } |
| 907 | return GUIDE.slice(i, j); |
| 908 | } |
| 909 | |
| 910 | function glossaryTerms() { |
| 911 | const out = new Set(); |
| 912 | for (const m of GUIDE.matchAll(/id="term-([a-z-]+)"/g)) out.add(m[1].replace(/-/g, ' ')); |
| 913 | ['top bar', 'rail', 'stage', 'dock', 'panel', 'crystal', 'daimon', 'worker', |
| 914 | 'note box', 'proposal', 'chip row'].forEach(w => out.add(w)); |
| 915 | return out; |
| 916 | } |
| 917 | |
| 918 | const SECTION = guideSection(); |
| 919 | const TERMS = glossaryTerms(); |
| 920 | |
| 921 | const GUIDE_LABELS = [...new Set( |
| 922 | [...SECTION.matchAll(/<span class="ui">([^<]+)<\/span>/g)].map(m => m[1].trim()))]; |
| 923 | |
| 924 | const GUIDE_NOUNS = [...new Set( |
| 925 | [...SECTION.matchAll(/<strong>([^<]+)<\/strong>/g)] |
| 926 | .map(m => m[1].trim().replace(/\.$/, '')) |
| 927 | .filter(s => s.split(/\s+/).length <= 2))]; |
| 928 | |
| 929 | // ── Driving ────────────────────────────────────────────────────────── |
| 930 | |
| 931 | const sleep = (ms) => new Promise(r => setTimeout(r, ms)); |
| 932 | |
| 933 | // A voice shaped like a real minted one is 43 characters of the Hematite64 |
| 934 | // alphabet. The mock's fixtures are three obvious strings, and `mock-voice-ada` |
| 935 | // is fourteen characters — under `DaimondVoice.MIN`, which is sixteen, and |
| 936 | // deliberately so: sixteen catches a truncated paste. So the fixture is padded |
| 937 | // to something the panel will accept and the mock is told about it below. |
| 938 | // allowlist secret |
| 939 | const SECRET = 'mock-voice-ada-0000000000000'; |
| 940 | /// What the mock knows that secret as. |
| 941 | /// |
| 942 | /// THE STAND-IN TRANSLATES, and it is worth saying why rather than quietly |
| 943 | /// pasting the mock's own string above. `voice.js` refuses a secret under |
| 944 | /// sixteen characters, deliberately: the forge mints forty-three and a |
| 945 | /// truncated paste is exactly what that floor catches. The mock's fixtures are |
| 946 | /// three obvious strings and `mock-voice-ada` is fourteen. So the browser holds |
| 947 | /// a secret shaped like a real one, and the gateway stand-in maps it at the |
| 948 | /// hop — which is the piece that translates headers anyway. What the BROWSER |
| 949 | /// sent is asserted below to be its own secret, unaltered. |
| 950 | const MOCK_SECRET = 'mock-voice-ada'; |
| 951 | |
| 952 | await startForges(); |
| 953 | |
| 954 | const s = await open({ name: 'improve', profile: PROFILE, signIn: false, connect: false, route: stub }); |
| 955 | const { page } = s; |
| 956 | |
| 957 | const { signInAs } = await import('./harness.mjs'); |
| 958 | await signInAs(s, 'improve'); |
| 959 | await page.waitForTimeout(1500); |
| 960 | |
| 961 | /// The proposal opens, comments and votes that reached the route, told apart the |
| 962 | /// way the gateway tells them apart: by the query, never by the body. |
| 963 | const opens = () => asked.filter(a => a.method === 'POST' && a.query.n === undefined); |
| 964 | const votes = () => asked.filter(a => a.method === 'POST' && a.query.vote === '1'); |
| 965 | const comments = () => asked.filter(a => a.method === 'POST' && a.query.n !== undefined && a.query.vote === undefined); |
| 966 | |
| 967 | /// A form body, as fields. Compared field by field rather than as a string: the |
| 968 | /// encoding is `URLSearchParams`'s at both ends, and what is being asserted is |
| 969 | /// the CHARACTERS, not the escaping. |
| 970 | const fields = (raw) => { |
| 971 | const out = {}; |
| 972 | for (const [k, v] of new URLSearchParams(raw)) out[k] = v; |
| 973 | return out; |
| 974 | }; |
| 975 | |
| 976 | try { |
| 977 | await page.evaluate(() => { |
| 978 | window.DaimondPanels.show('social'); |
| 979 | if (window.DaimondImprove) window.DaimondImprove.onOpen(); |
| 980 | }); |
| 981 | await page.waitForTimeout(600); |
| 982 | |
| 983 | const panel = page.locator('#panel-social'); |
| 984 | const panels = await panel.count(); |
| 985 | check('the panel is on screen, exactly once', panels === 1, `${panels} found`); |
| 986 | |
| 987 | // ── 1. Writing is not sending, and there is no Keep ────────── |
| 988 | // |
| 989 | // Note-capture merged into the Proposals view: the box is there, and its |
| 990 | // primary verb is Post (the button still carries `.imp-send`). There is no |
| 991 | // Keep -- a note that is not posted just sits in the box as a draft, and |
| 992 | // nothing is queued until a Post that could not go leaves one waiting. |
| 993 | const NOTE_1 = 'The closer on the Everything row put the whole rail away\n' |
| 994 | + 'I expected it to close that row. quokka-marker-one'; |
| 995 | |
| 996 | // Post is not offered before there is a voice to post under. A control that |
| 997 | // does nothing when pressed teaches people to distrust every control. Counted |
| 998 | // as well as asked-hidden: an absent locator reports itself hidden, so "not |
| 999 | // shown here" and "never built" look identical from here. |
| 1000 | check('the Post button is there to be hidden in the first place', |
| 1001 | await page.locator('#improve-acts .imp-send').count() === 1); |
| 1002 | check('with no voice, Post is not offered at all', |
| 1003 | await page.locator('#improve-acts .imp-send').isHidden()); |
| 1004 | |
| 1005 | check('the disclosure exists, to be hidden in the first place', |
| 1006 | await page.locator('#improve-public').count() === 1); |
| 1007 | check('and with no voice and no Post, nobody is told their notes are published', |
| 1008 | await page.locator('#improve-public').isHidden()); |
| 1009 | |
| 1010 | await page.fill('#improve-box', NOTE_1); |
| 1011 | await page.waitForTimeout(700); |
| 1012 | check('writing a note sends nothing', opens().length === 0, |
| 1013 | `${opens().length} request(s) left the page`); |
| 1014 | check('and merely writing queues nothing: a draft in the box is not a note waiting to send', |
| 1015 | await page.evaluate(() => window.DaimondImprove.notes().length) === 0); |
| 1016 | await page.fill('#improve-box', ''); |
| 1017 | |
| 1018 | // ── A voice is set, from the Settings view where it now lives ── |
| 1019 | await page.evaluate(() => window.DaimondSocial.show('settings')); |
| 1020 | await page.waitForTimeout(200); |
| 1021 | await page.click('[data-act="improve-voice-open"]'); |
| 1022 | await page.waitForTimeout(200); |
| 1023 | await page.fill('#improve-voice-in', SECRET); |
| 1024 | await page.click('[data-act="improve-voice-save"]'); |
| 1025 | await page.waitForTimeout(600); |
| 1026 | check('a voice can be set in the Settings view', |
| 1027 | await page.evaluate(() => window.DaimondVoice.has()) === true); |
| 1028 | check('and the secret is not left in the field it was typed into', |
| 1029 | await page.evaluate(() => { |
| 1030 | const i = document.getElementById('improve-voice-in'); |
| 1031 | return !i || !i.value; |
| 1032 | })); |
| 1033 | await page.evaluate(() => window.DaimondSocial.show('proposals')); |
| 1034 | await page.waitForTimeout(200); |
| 1035 | check('and Post is offered once there is a voice', |
| 1036 | await page.locator('#improve-acts .imp-send').isVisible()); |
| 1037 | |
| 1038 | // ── 1b. What Send does is said before it is pressed ────────── |
| 1039 | // |
| 1040 | // Read off the page in one go, so that what is asserted about the sentence and |
| 1041 | // what is asserted about where it sits are the same sentence in the same |
| 1042 | // moment. `isVisible()` as well, because an element drawn with nothing in it |
| 1043 | // has no box: a disclosure that says nothing is not a disclosure that is |
| 1044 | // merely quiet. |
| 1045 | const disclosed = await page.evaluate(() => { |
| 1046 | const p = document.getElementById('improve-public'); |
| 1047 | const send = document.querySelector('#panel-social #improve-acts .imp-send'); |
| 1048 | const box = document.getElementById('improve-box'); |
| 1049 | if (!p || !send || !box) return null; |
| 1050 | const a = p.getBoundingClientRect(), b = send.getBoundingClientRect(), |
| 1051 | c = box.getBoundingClientRect(); |
| 1052 | return { |
| 1053 | text: (p.textContent || '').replace(/\s+/g, ' ').trim(), |
| 1054 | inBox: !!p.closest('#panel-social .imp-write'), |
| 1055 | aboveSend: a.bottom <= b.top, |
| 1056 | belowNote: a.top >= c.bottom, |
| 1057 | }; |
| 1058 | }); |
| 1059 | const shown = await page.locator('#improve-public').isVisible(); |
| 1060 | |
| 1061 | // The three facts the forge's own page and this one have to agree on, plus the |
| 1062 | // host. Every one of them is something a person would be surprised by |
| 1063 | // afterwards, which is the whole test of whether it belongs beside the button: |
| 1064 | // ANYONE, with NO account, and the voice name goes with it. |
| 1065 | // TERSE now (#6): the whole address and the "no account" detail moved to the |
| 1066 | // Post button's tooltip; the line under the box keeps the one fact a person must |
| 1067 | // read before pressing -- it goes out in public, under their name. |
| 1068 | const SAYS = [ |
| 1069 | ['says anyone can read it', /\banyone\b/i], |
| 1070 | ['says the voice name goes too', /voice name/i], |
| 1071 | ]; |
| 1072 | const unsaid = disclosed ? SAYS.filter(([, re]) => !re.test(disclosed.text)).map(([what]) => what) : ['nothing is drawn']; |
| 1073 | check('once Post is offered, the box says, tersely, what Post does', |
| 1074 | shown && unsaid.length === 0, |
| 1075 | (shown ? '' : 'not on the screen; ') + (unsaid.join(', ') || JSON.stringify(disclosed && disclosed.text))); |
| 1076 | |
| 1077 | // Above Send and under the note, which is where a hand on its way to the |
| 1078 | // button passes it. Under the button it would be read, at best, afterwards. |
| 1079 | check('and it is in the box, under the note and above Send', |
| 1080 | !!disclosed && disclosed.inBox && disclosed.aboveSend && disclosed.belowNote, |
| 1081 | JSON.stringify(disclosed)); |
| 1082 | |
| 1083 | // ── 2. What left is what you read ──────────────────────────── |
| 1084 | const NOTE_2 = 'The Diamonds chip does not fill when the panel opens\n' |
| 1085 | + 'It stays empty until something else is pressed. quokka-marker-two'; |
| 1086 | await page.fill('#improve-box', NOTE_2); |
| 1087 | await page.waitForTimeout(500); |
| 1088 | |
| 1089 | // Read the screen BEFORE pressing, because pressing clears the box. This is |
| 1090 | // the oracle: two strings the user could have read off the panel. |
| 1091 | const onScreen = await page.evaluate(() => { |
| 1092 | const box = document.getElementById('improve-box'); |
| 1093 | const row = document.getElementById('improve-with'); |
| 1094 | const line = document.getElementById('improve-with-text'); |
| 1095 | return { |
| 1096 | box: box ? box.value.trim() : '', |
| 1097 | ctx: (row && !row.hidden) ? (line ? line.textContent.trim() : '') : '', |
| 1098 | shown: !!(row && !row.hidden), |
| 1099 | }; |
| 1100 | }); |
| 1101 | check('the row saying what goes with the note is on screen and says something', |
| 1102 | onScreen.shown && onScreen.ctx.length > 10, onScreen.ctx); |
| 1103 | check('and it names the build, the palette and the panels that are open', |
| 1104 | /build/i.test(onScreen.ctx) && /palette/i.test(onScreen.ctx) && /panels open/i.test(onScreen.ctx), |
| 1105 | onScreen.ctx); |
| 1106 | |
| 1107 | const before = opens().length; |
| 1108 | await page.click('.imp-send'); |
| 1109 | await page.waitForTimeout(1500); |
| 1110 | check('Send sends — so the two silences above were the design and not a dead button', |
| 1111 | opens().length === before + 1, `${opens().length - before} request(s)`); |
| 1112 | |
| 1113 | const sent = opens()[opens().length - 1] || { body: '' }; |
| 1114 | const f2 = fields(sent.body); |
| 1115 | const want = onScreen.box + '\n\n' + onScreen.ctx; |
| 1116 | check('and the request put back together is character-for-character what was on screen', |
| 1117 | (f2.title || '') + '\n' + (f2.body || '') === want, |
| 1118 | `sent ${JSON.stringify(((f2.title || '') + '\n' + (f2.body || '')).slice(0, 160))}\n want ${JSON.stringify(want.slice(0, 160))}`); |
| 1119 | check('and the request carries THOSE FIELDS AND NO OTHERS', |
| 1120 | Object.keys(f2).sort().join(',') === 'body,build,title', |
| 1121 | Object.keys(f2).sort().join(',') || '(none)'); |
| 1122 | |
| 1123 | // #3: a note that becomes a proposal LEAVES -- there is no sent row and no |
| 1124 | // notes list for one to sit in. The queue is empty (the send took it) and the |
| 1125 | // proposal it became is now in the list. |
| 1126 | check('the note left the queue: it became a proposal', |
| 1127 | await page.evaluate(() => window.DaimondImprove.notes().length) === 0); |
| 1128 | check('and the proposal it became is in the list', |
| 1129 | await page.evaluate(() => window.DaimondImprove.forge.props().some(p => p.n === 13))); |
| 1130 | |
| 1131 | // ── 2c. And that line is true AT THE PRESS ─────────────────── |
| 1132 | // |
| 1133 | // The two facts in it that MOVE under a panel that is already open are the |
| 1134 | // palette and the set of panels open, and on a desktop neither disturbs the |
| 1135 | // Social panel: somebody switches palette from the appearance menu, opens |
| 1136 | // something else from a chip, and the row goes on saying what was true when |
| 1137 | // the panel opened. Eighteen notes went out in one day carrying that line. |
| 1138 | // |
| 1139 | // Driven through the app's own doors -- `DaimondTheme.set` is what the |
| 1140 | // appearance menu calls and `DaimondPanels.show` is what a chip presses -- |
| 1141 | // and then `outgoing()` is asked IN THE SAME EVALUATE, with no click, no |
| 1142 | // focus and no resize in between. That is the point of asking it this way: a |
| 1143 | // row kept in step by listeners cannot answer this one, because nothing this |
| 1144 | // check does is a thing a listener could have heard. What passes it is the |
| 1145 | // line being COMPUTED at the moment of the press. |
| 1146 | await page.fill('#improve-box', 'The palette in the line is not the palette on the screen\n' |
| 1147 | + 'quokka-marker-context'); |
| 1148 | await page.waitForTimeout(300); |
| 1149 | const moved = await page.evaluate(() => { |
| 1150 | const wasTheme = DaimondTheme.get(); |
| 1151 | const other = DaimondTheme.list().find(t => t !== wasTheme); |
| 1152 | DaimondTheme.set(other); |
| 1153 | const shut = DaimondPanels.panels().find(p => p.id !== 'social' && !DaimondPanels.isOpen(p.id)); |
| 1154 | let opened = '', openedId = ''; |
| 1155 | if (shut) { |
| 1156 | DaimondPanels.show(shut.id); |
| 1157 | if (DaimondPanels.isOpen(shut.id)) { opened = shut.label; openedId = shut.id; } |
| 1158 | } |
| 1159 | const line = document.getElementById('improve-with-text'); |
| 1160 | return { |
| 1161 | was: wasTheme, |
| 1162 | theme: other, |
| 1163 | panel: opened, |
| 1164 | panelId: openedId, |
| 1165 | // The read is LAST, so everything above it happened first. |
| 1166 | out: window.DaimondImprove.outgoing(), |
| 1167 | row: line ? (line.textContent || '').trim() : '', |
| 1168 | }; |
| 1169 | }); |
| 1170 | check('the line that would go names the palette that is in force NOW', |
| 1171 | moved.out.includes('palette ' + moved.theme) && !moved.out.includes('palette ' + moved.was), |
| 1172 | `${JSON.stringify(moved.out)} — was ${moved.was}, now ${moved.theme}`); |
| 1173 | check('and the panel opened since the row was drawn is named in it', |
| 1174 | !!moved.panel && moved.out.includes(moved.panel), |
| 1175 | `${JSON.stringify(moved.out)} — opened ${JSON.stringify(moved.panel)}`); |
| 1176 | // And the screen agrees with the wire, which is the property the redraw must |
| 1177 | // not have bought at the cost of: a line computed for the request and never |
| 1178 | // shown would be exactly the `hidden` break above. |
| 1179 | check('and the row on the screen is showing those same characters', |
| 1180 | moved.row.length > 10 && moved.out.endsWith(moved.row), |
| 1181 | `${JSON.stringify(moved.row)}`); |
| 1182 | |
| 1183 | // Put the screen back the way the later checks expect to find it. |
| 1184 | await page.evaluate((prev) => { |
| 1185 | try { DaimondTheme.set(prev.was); } catch (e) { /* no theme service */ } |
| 1186 | try { if (prev.panelId) DaimondPanels.hide(prev.panelId); } catch (e) { /* no panels */ } |
| 1187 | try { DaimondPanels.show('social'); } catch (e) { /* no panels */ } |
| 1188 | }, moved); |
| 1189 | await page.fill('#improve-box', ''); |
| 1190 | await page.waitForTimeout(300); |
| 1191 | |
| 1192 | // ── 3. Closing the row takes the line off the wire ─────────── |
| 1193 | const NOTE_3 = 'The divider above the admin panel will not go back\n' |
| 1194 | + 'A double-click does nothing to it. quokka-marker-three'; |
| 1195 | await page.fill('#improve-box', NOTE_3); |
| 1196 | await page.waitForTimeout(400); |
| 1197 | await page.click('.imp-with-off'); |
| 1198 | await page.waitForTimeout(300); |
| 1199 | const rowGone = await page.locator('#improve-with').isHidden(); |
| 1200 | check('the closer on that row closes it', rowGone); |
| 1201 | |
| 1202 | const before3 = opens().length; |
| 1203 | await page.click('.imp-send'); |
| 1204 | await page.waitForTimeout(1500); |
| 1205 | const sent3 = opens()[opens().length - 1] || { body: '' }; |
| 1206 | const f3 = fields(sent3.body); |
| 1207 | check('and the line it was showing is off the wire, not merely off the screen', |
| 1208 | opens().length === before3 + 1 |
| 1209 | && (f3.title || '') + '\n' + (f3.body || '') === NOTE_3, |
| 1210 | JSON.stringify(((f3.title || '') + '\n' + (f3.body || '')).slice(0, 200))); |
| 1211 | check('and the build identifier that row was showing is off it too', |
| 1212 | !('build' in f3), Object.keys(f3).sort().join(',')); |
| 1213 | |
| 1214 | // ── 4. A Post the forge refuses WAITS in the queue, not retried on its own ── |
| 1215 | // |
| 1216 | // The note the forge would not take stays in the queue (offline or refused, the |
| 1217 | // holding is the same), and the reconnect flush -- not a timer -- is the only |
| 1218 | // thing that will try it again. It is drawn as waiting, with Copy to carry it out |
| 1219 | // by hand, and nothing here fires an `online` event, so nothing retries. |
| 1220 | forge = FORGE.main; asRepo = '_throttled-writes'; |
| 1221 | const NOTE_4 = 'The paperclip is missing from file rows\n' |
| 1222 | + 'The Workspace panel draws none of them. quokka-marker-four'; |
| 1223 | await page.fill('#improve-box', NOTE_4); |
| 1224 | await page.waitForTimeout(400); |
| 1225 | const before4 = opens().length; |
| 1226 | await page.click('.imp-send'); |
| 1227 | await page.waitForTimeout(1500); |
| 1228 | check('a note the forge refuses was still tried once', |
| 1229 | opens().length === before4 + 1, `${opens().length - before4} attempt(s)`); |
| 1230 | |
| 1231 | check('and it stayed in the queue rather than vanishing', |
| 1232 | await page.evaluate(() => window.DaimondImprove.notes().some(n => /paperclip/.test(n.text)))); |
| 1233 | const keptRow = await page.locator('.imp-queue-row').first().innerText(); |
| 1234 | check('and its row says it is waiting, not that it went', |
| 1235 | /waiting/i.test(keptRow) && !/^sent/i.test(keptRow.trim()), keptRow.slice(0, 90)); |
| 1236 | check('and Copy is offered on it, so the words can be carried out by hand', |
| 1237 | await page.locator('.imp-queue-row').first().locator('.imp-note-copy').count() === 1); |
| 1238 | const saidBack = await page.locator('#improve-say').innerText(); |
| 1239 | check('and the panel says WHY, rather than one sentence for every fault', |
| 1240 | /too many/i.test(saidBack) && /waiting/i.test(saidBack), saidBack); |
| 1241 | |
| 1242 | const after4 = opens().length; |
| 1243 | await sleep(4000); |
| 1244 | check('and nothing tries again on its own — only a reconnect flushes the queue', |
| 1245 | opens().length === after4, `${opens().length - after4} further attempt(s) in 4s`); |
| 1246 | asRepo = ''; |
| 1247 | |
| 1248 | // ── 5. The words appear in exactly one request ─────────────── |
| 1249 | const carrying = (marker) => wire.filter(r => |
| 1250 | (r.body && r.body.indexOf(marker) !== -1) || r.url.indexOf(marker) !== -1); |
| 1251 | for (const marker of ['quokka-marker-two', 'quokka-marker-three']) { |
| 1252 | const hits = carrying(marker); |
| 1253 | check(`the words of a sent note are in exactly one request, and it is /api/improve — ${marker}`, |
| 1254 | hits.length === 1 && hits[0].url.indexOf('/api/improve') !== -1, |
| 1255 | hits.map(h => h.method + ' ' + h.url).join(' | ') || 'none'); |
| 1256 | } |
| 1257 | const keptOnly = carrying('quokka-marker-one'); |
| 1258 | check('and the words of a KEPT note are in no request at all', |
| 1259 | keptOnly.length === 0, keptOnly.map(h => h.url).join(' | ')); |
| 1260 | |
| 1261 | // ── 6, 7, 8. Proposals, read from the forge ────────────────── |
| 1262 | await page.evaluate(() => window.DaimondImprove.show('proposals')); |
| 1263 | await page.waitForTimeout(1200); |
| 1264 | |
| 1265 | const live = await page.locator('#improve-asat').innerText(); |
| 1266 | check('the list says these are read as you look, and that nothing will tell you', |
| 1267 | /forge/i.test(live) && /look/i.test(live), live.slice(0, 120)); |
| 1268 | |
| 1269 | const drawn = await page.evaluate(() => window.DaimondImprove.listing()); |
| 1270 | check('the listing was read, newest first, and says how many there are', |
| 1271 | drawn.total >= 12 && drawn.shown.length >= 12 && drawn.shown[0] > drawn.shown[1], |
| 1272 | JSON.stringify(drawn).slice(0, 160)); |
| 1273 | |
| 1274 | // 8. BY NAME: this row, and what it says about itself. The corpus is seeded, |
| 1275 | // so proposal 3 is `accepted` and its tally is the corpus's own. |
| 1276 | const three = page.locator('.imp-prop[data-prop="3"]'); |
| 1277 | const threeState = await three.getAttribute('data-state'); |
| 1278 | const threeRow = await three.locator('.imp-prop-row').innerText(); |
| 1279 | const threeRec = await page.evaluate(() => window.DaimondImprove.proposal(3)); |
| 1280 | check('the proposal that is being done says so, and says its own tally', |
| 1281 | threeState === 'accepted' && threeRow.indexOf(String(threeRec.votes.for)) !== -1, |
| 1282 | `state=${threeState} row=${JSON.stringify(threeRow)} votes=${JSON.stringify(threeRec.votes)}`); |
| 1283 | |
| 1284 | const seven = page.locator('.imp-prop[data-prop="7"]'); |
| 1285 | check('and the one that is finished is drawn as done, not as open', |
| 1286 | await seven.getAttribute('data-state') === 'done', |
| 1287 | await seven.getAttribute('data-state')); |
| 1288 | |
| 1289 | await seven.locator('.imp-prop-row').click(); |
| 1290 | await page.waitForTimeout(900); |
| 1291 | const sevenBody = await page.evaluate(() => |
| 1292 | document.querySelector('.imp-prop[data-prop="7"] .imp-prop-body').textContent.replace(/\s+/g, ' ')); |
| 1293 | const sevenRec = await page.evaluate(() => window.DaimondImprove.proposal(7)); |
| 1294 | check('opening it reads it in full, and it names the mark that closed it', |
| 1295 | sevenBody.indexOf(sevenRec.mark) !== -1 && sevenBody.indexOf(sevenRec.body.slice(0, 24)) !== -1, |
| 1296 | sevenBody.slice(0, 140)); |
| 1297 | // Contract §5, on the surface and not in a help page: a reader looking at a |
| 1298 | // proposal closed by a change to the code is the reader who asks whether a |
| 1299 | // note followed it. |
| 1300 | check('and it says out loud that a note follows content only above 64 bytes', |
| 1301 | /64 bytes/.test(sevenBody) && /move/i.test(sevenBody), |
| 1302 | sevenBody.indexOf('64 bytes') === -1 ? 'not said' : 'said'); |
| 1303 | check('and the body it read is the forge\'s own, not a placeholder', |
| 1304 | sevenBody.indexOf(sevenRec.body.slice(0, 24)) !== -1, sevenRec.body.slice(0, 40)); |
| 1305 | |
| 1306 | // 7b. `mine` NULL: a voice asked, and has not voted. Two buttons, neither on. |
| 1307 | const voteBtns = seven.locator('.imp-vote'); |
| 1308 | check('a voiced reader is offered both ways to vote, with neither pressed', |
| 1309 | await voteBtns.count() === 2 |
| 1310 | && await seven.locator('.imp-vote.on').count() === 0, |
| 1311 | `${await voteBtns.count()} buttons, ${await seven.locator('.imp-vote.on').count()} pressed`); |
| 1312 | |
| 1313 | // 7d. `changed` ABSENT IS NOT `changed` ZERO. |
| 1314 | // |
| 1315 | // The same rule as `mine`/`asked` above, on the field that says when a |
| 1316 | // proposal was last revised, and it is worth its own three readings because |
| 1317 | // nothing on the screen would show it going wrong. `whole()` turns an absent |
| 1318 | // number into 0, and a 0 here is a real reading -- revised at the epoch, |
| 1319 | // which is older than every proposal there is. Anything that compared the |
| 1320 | // field would then find every tile stale for ever, or, with the comparison |
| 1321 | // the other way, none of them ever; and both look exactly like a cache that |
| 1322 | // is not being invalidated, which is the week somebody spends before finding |
| 1323 | // the line. |
| 1324 | // |
| 1325 | // THREE READINGS, because two would not separate the facts. A forge that |
| 1326 | // answers the field, a forge silent about it, and a forge answering a real |
| 1327 | // zero: the first two must differ, and the last two must differ, and it is |
| 1328 | // the second pair that the coercion destroys. |
| 1329 | { |
| 1330 | const reload = async () => { |
| 1331 | await page.evaluate(() => { window.DaimondImprove.reset(); return window.DaimondImprove.load(false); }); |
| 1332 | await page.waitForTimeout(1200); |
| 1333 | return page.evaluate(() => window.DaimondImprove.proposal(7)); |
| 1334 | }; |
| 1335 | changedHop = null; |
| 1336 | const said = await reload(); |
| 1337 | check('a forge that answers `changed` is read as the number it sent', |
| 1338 | !!said && typeof said.changed === 'number' && said.changed > 0, |
| 1339 | JSON.stringify(said && said.changed)); |
| 1340 | |
| 1341 | changedHop = { n: 7, to: undefined }; // the field taken off entirely |
| 1342 | const silent = await reload(); |
| 1343 | check('AND AN ANSWER SILENT ABOUT IT IS NULL, not the epoch — "never told" is not a date', |
| 1344 | !!silent && silent.changed === null, JSON.stringify(silent && silent.changed)); |
| 1345 | |
| 1346 | changedHop = { n: 7, to: 0 }; // a real zero, which is a different fact |
| 1347 | const zero = await reload(); |
| 1348 | check('while a forge that really answers 0 is read as 0, so the two stay apart', |
| 1349 | !!zero && zero.changed === 0, JSON.stringify(zero && zero.changed)); |
| 1350 | check('and the two are not the same record', |
| 1351 | !!silent && !!zero && silent.changed !== zero.changed, |
| 1352 | `${JSON.stringify(silent && silent.changed)} vs ${JSON.stringify(zero && zero.changed)}`); |
| 1353 | |
| 1354 | changedHop = null; |
| 1355 | await reload(); |
| 1356 | } |
| 1357 | |
| 1358 | // 6. The vote itself. |
| 1359 | // |
| 1360 | // EVERY INTERACTION INSIDE THAT ROW RE-OPENS IT FIRST if something shut it. |
| 1361 | // Whether VOTING shuts it is a check of its own, below, and it is measured |
| 1362 | // with no re-opening in between -- but a row shut before the vote was even |
| 1363 | // cast turns the click into a Playwright timeout, and the run then ENDS |
| 1364 | // THERE, reporting nothing about that check and nothing about the twenty |
| 1365 | // after it. A break that crashes the run is a break that proves nothing, and |
| 1366 | // a crash reads like a red for the wrong reason. |
| 1367 | const openSeven = async () => { |
| 1368 | if (await seven.locator('.imp-prop-body').isVisible()) return; |
| 1369 | await seven.locator('.imp-prop-row').click(); |
| 1370 | await page.waitForTimeout(400); |
| 1371 | }; |
| 1372 | await openSeven(); |
| 1373 | const beforeVote = votes().length; |
| 1374 | await seven.locator('.imp-vote[data-dir="do"]').click(); |
| 1375 | await page.waitForTimeout(1000); |
| 1376 | check('a vote is cast when it is pressed', |
| 1377 | votes().length === beforeVote + 1, `${votes().length - beforeVote}`); |
| 1378 | const lastVote = votes()[votes().length - 1] || { body: '' }; |
| 1379 | check('and its WHOLE body is the one field the forge reads, and says which way', |
| 1380 | lastVote.body === 'd=1', JSON.stringify(lastVote.body)); |
| 1381 | |
| 1382 | const afterVote = await page.evaluate(() => window.DaimondImprove.proposal(7)); |
| 1383 | check('and the tally the panel draws came back with the answer, not from a guess', |
| 1384 | afterVote.mine === 1 && afterVote.votes.for === sevenRec.votes.for + 1, |
| 1385 | `mine=${afterVote.mine} for=${afterVote.votes.for} was=${sevenRec.votes.for}`); |
| 1386 | check('and the button is drawn as yours', |
| 1387 | await seven.locator('.imp-vote[data-dir="do"].on').count() === 1); |
| 1388 | |
| 1389 | // Voting redraws the list. The proposal being voted on must still be open |
| 1390 | // afterwards: the button that was pressed is INSIDE it. |
| 1391 | check('and the proposal being voted on is still open to read', |
| 1392 | await seven.locator('.imp-prop-body').isVisible()); |
| 1393 | |
| 1394 | // Pressing the side you already chose takes it back off — `d=0`, which is |
| 1395 | // the only way back, and never an absent field. |
| 1396 | await openSeven(); |
| 1397 | await seven.locator('.imp-vote[data-dir="do"]').click(); |
| 1398 | await page.waitForTimeout(1000); |
| 1399 | const withdrawn = votes()[votes().length - 1] || { body: '' }; |
| 1400 | check('pressing it again withdraws, and says so with a value rather than a silence', |
| 1401 | withdrawn.body === 'd=0', JSON.stringify(withdrawn.body)); |
| 1402 | const afterOff = await page.evaluate(() => window.DaimondImprove.proposal(7)); |
| 1403 | check('and the tally goes back down, from the answer', |
| 1404 | afterOff.mine === null && afterOff.votes.for === sevenRec.votes.for, |
| 1405 | `mine=${afterOff.mine} for=${afterOff.votes.for}`); |
| 1406 | check('NO REQUEST EVER SENT A VOTE WITHOUT A DIRECTION', |
| 1407 | votes().every(v => /(^|&)d=(1|-1|0)($|&)/.test(v.body)), |
| 1408 | votes().map(v => v.body).join(' | ')); |
| 1409 | |
| 1410 | // A VOTE THE FORGE REFUSED IS NOT DRAWN AS THOUGH IT HAD BEEN COUNTED. |
| 1411 | // §9 puts the tally on the forge precisely so there is ONE store of truth; |
| 1412 | // a client that moved its own copy on a refusal would be showing the one |
| 1413 | // that is wrong, and nothing on the screen would say so. `_throttled-votes` |
| 1414 | // refuses votes and leaves the other budget alone, which is the shape a |
| 1415 | // separate vote budget has. |
| 1416 | const beforeRefused = await page.evaluate(() => window.DaimondImprove.proposal(7)); |
| 1417 | asRepo = '_throttled-votes'; |
| 1418 | await openSeven(); |
| 1419 | await seven.locator('.imp-vote[data-dir="do"]').click(); |
| 1420 | await page.waitForTimeout(1000); |
| 1421 | asRepo = ''; |
| 1422 | const afterRefused = await page.evaluate(() => window.DaimondImprove.proposal(7)); |
| 1423 | check('a vote the forge refused moves nothing, and the refusal is said', |
| 1424 | afterRefused.mine === beforeRefused.mine |
| 1425 | && afterRefused.votes.for === beforeRefused.votes.for |
| 1426 | && await page.locator('#improve-props .imp-err[data-why="throttled"]').count() === 1, |
| 1427 | `mine=${afterRefused.mine} for=${afterRefused.votes.for} ` |
| 1428 | + `was mine=${beforeRefused.mine} for=${beforeRefused.votes.for}`); |
| 1429 | |
| 1430 | // And the browser sent ITS OWN secret, on its own header. The stand-in |
| 1431 | // translates at the hop; what left the page must be what the page holds. |
| 1432 | // The property is that what LEFT is the browser's own secret, unaltered -- |
| 1433 | // not that every write carried one, which is a different claim and is the |
| 1434 | // "Send is not offered without a voice" check's. Asserting both here made a |
| 1435 | // break about Keep redden this one too. |
| 1436 | const voiced = asked.filter(a => a.headers[HDR]); |
| 1437 | check('every request that carried a voice carried the browser\'s own, on x-daimond-voice', |
| 1438 | voiced.length > 0 && voiced.every(a => a.headers[HDR] === SECRET), |
| 1439 | `${voiced.length} voiced request(s)`); |
| 1440 | |
| 1441 | // ── 11. Saying something back ──────────────────────────────── |
| 1442 | // |
| 1443 | await openSeven(); |
| 1444 | const SAY = 'It also happens on a narrow window. quokka-marker-five'; |
| 1445 | await seven.locator('.imp-reply').fill(SAY); |
| 1446 | await page.waitForTimeout(200); |
| 1447 | const beforeSaid = comments().length; |
| 1448 | await seven.locator('[data-act="improve-comment"]').click(); |
| 1449 | await page.waitForTimeout(1200); |
| 1450 | check('a comment goes when the button beside it is pressed', |
| 1451 | comments().length === beforeSaid + 1, `${comments().length - beforeSaid}`); |
| 1452 | const said = comments()[comments().length - 1] || { body: '' }; |
| 1453 | const fs5 = fields(said.body); |
| 1454 | check('and it carries exactly what was in that box, and nothing else', |
| 1455 | fs5.said === SAY && Object.keys(fs5).join(',') === 'said', |
| 1456 | JSON.stringify(said.body).slice(0, 160)); |
| 1457 | const afterSaid = await page.evaluate(() => window.DaimondImprove.proposal(7)); |
| 1458 | check('and the discussion the panel draws came back with the answer', |
| 1459 | afterSaid.discussion.some(d => d.said === SAY), |
| 1460 | String(afterSaid.discussion.length) + ' entries'); |
| 1461 | |
| 1462 | await shot(s, 'improve-proposals' + (BREAK ? '-' + BREAK : '')); |
| 1463 | |
| 1464 | // ── 7a. `mine` ABSENT: the request carried no voice at all ─── |
| 1465 | // |
| 1466 | // The same forge, the same repository, the same records — and no voice. The |
| 1467 | // answer then carries no `mine` AT ALL, which is a different fact from |
| 1468 | // `mine: null`, and the panel has to draw it differently or it offers an |
| 1469 | // unvoted button to somebody who cannot vote. |
| 1470 | await page.evaluate(() => { window.DaimondVoice.clear(); window.DaimondImprove.reset(); }); |
| 1471 | await page.evaluate(() => window.DaimondImprove.load(false)); |
| 1472 | await page.waitForTimeout(1200); |
| 1473 | const unvoiced = await page.evaluate(() => window.DaimondImprove.proposal(7)); |
| 1474 | check('a read with no voice works at all — a public repository needs none', |
| 1475 | !!unvoiced, JSON.stringify(unvoiced && unvoiced.n)); |
| 1476 | check('and the answer carries no `mine`, which is not the same as "has not voted"', |
| 1477 | unvoiced && unvoiced.asked === false, JSON.stringify(unvoiced && unvoiced.asked)); |
| 1478 | const unvoicedVotes = await page.evaluate(() => { |
| 1479 | const e = document.querySelector('.imp-prop[data-prop="7"] .imp-votes'); |
| 1480 | return e ? e.textContent.replace(/\s+/g, ' ').trim() : ''; |
| 1481 | }); |
| 1482 | check('so no vote button is drawn, and a line says why instead', |
| 1483 | await page.locator('.imp-prop[data-prop="7"] .imp-vote').count() === 0 |
| 1484 | && /voice/i.test(unvoicedVotes), |
| 1485 | JSON.stringify(unvoicedVotes)); |
| 1486 | check('and the tally is still shown, because reading one needs no voice', |
| 1487 | /\d/.test(unvoicedVotes), JSON.stringify(unvoicedVotes)); |
| 1488 | |
| 1489 | // ── 7c. DARK: a forge that answers no tally at all ─────────── |
| 1490 | // |
| 1491 | // Both claims here are ABSENCES, and an absence is the one thing a panel that |
| 1492 | // drew nothing at all agrees with. Measured on 2026-08-16: with |
| 1493 | // `#improve-props` emptied by hand at this point in the run, and again with |
| 1494 | // the id renamed, BOTH `count() === 0` assertions stayed true. They passed on |
| 1495 | // a blank screen, twice. So each is paired with the drawing that has to have |
| 1496 | // happened for the absence to mean anything -- the rows themselves, and their |
| 1497 | // titles -- exactly as 7a above pairs its own `count() === 0` with a line the |
| 1498 | // panel had to have painted. The rows drew for the same reason: this is a |
| 1499 | // listing the forge answered, only without a tally on it. |
| 1500 | forge = FORGE.dark; |
| 1501 | await page.evaluate(() => { window.DaimondImprove.reset(); return window.DaimondImprove.load(false); }); |
| 1502 | await page.waitForTimeout(1200); |
| 1503 | const darkRows = await page.locator('#improve-props .imp-prop').count(); |
| 1504 | const darkTitles = await page.evaluate(() => |
| 1505 | [...document.querySelectorAll('#improve-props .imp-prop-title')] |
| 1506 | .map(n => (n.textContent || '').trim()).filter(Boolean).length); |
| 1507 | check('against a forge whose listing carries no tally, the rows still draw', |
| 1508 | darkRows > 0 && darkTitles === darkRows, |
| 1509 | `${darkRows} row(s), ${darkTitles} of them titled`); |
| 1510 | check('and NO vote control is drawn on any of them', |
| 1511 | darkRows > 0 && await page.locator('#improve-props .imp-votes').count() === 0, |
| 1512 | `${await page.locator('#improve-props .imp-votes').count()} drawn across ${darkRows} row(s)`); |
| 1513 | check('and the rows carry no tally value either, rather than a zero nothing counted', |
| 1514 | darkRows > 0 |
| 1515 | && await page.locator('#improve-props .imp-prop-row .imp-prop-tally').count() === 0, |
| 1516 | `${await page.locator('#improve-props .imp-prop-row .imp-prop-tally').count()} tallies ` |
| 1517 | + `across ${darkRows} row(s)`); |
| 1518 | forge = FORGE.main; |
| 1519 | |
| 1520 | // Put the voice back for what follows. |
| 1521 | await page.evaluate(async (sec) => { await window.DaimondVoice.set(sec); }, SECRET); |
| 1522 | |
| 1523 | // ── 8b. The nine refusals ──────────────────────────────────── |
| 1524 | // |
| 1525 | // Each reached BY NAME through the mock, which makes every branch of §3.1 |
| 1526 | // reachable on demand: a client that has never seen a refusal has not |
| 1527 | // handled one. |
| 1528 | const refusal = async (repo, token) => { |
| 1529 | asRepo = repo; |
| 1530 | await page.evaluate(() => { window.DaimondImprove.reset(); return window.DaimondImprove.load(false); }); |
| 1531 | await page.waitForTimeout(700); |
| 1532 | const box = page.locator('#improve-props .imp-err'); |
| 1533 | const n = await box.count(); |
| 1534 | const why = n ? await box.getAttribute('data-why') : ''; |
| 1535 | const text = n ? (await box.innerText()).trim() : ''; |
| 1536 | asRepo = ''; |
| 1537 | return { n, why, text }; |
| 1538 | }; |
| 1539 | |
| 1540 | const REFUSALS = [ |
| 1541 | ['_absent', 'absent'], |
| 1542 | ['_unvoiced', 'unvoiced'], |
| 1543 | ['_unknown', 'unknown'], |
| 1544 | ['_unpermitted', 'unpermitted'], |
| 1545 | ['_malformed', 'malformed'], |
| 1546 | ['_no_proposal', 'no_proposal'], |
| 1547 | ['_internal', 'internal'], |
| 1548 | ['_throttled-address', 'throttled'], |
| 1549 | ]; |
| 1550 | const seen = {}; |
| 1551 | for (const [repo, token] of REFUSALS) { |
| 1552 | const got = await refusal(repo, token); |
| 1553 | seen[token + ':' + repo] = got.text; |
| 1554 | check(`the refusal '${token}' is understood and SAID`, |
| 1555 | got.n === 1 && got.why === token && got.text.length > 10, |
| 1556 | `why=${got.why} said=${JSON.stringify(got.text.slice(0, 80))}`); |
| 1557 | } |
| 1558 | // The ninth. No path the panel takes sends a method the forge does not |
| 1559 | // answer, so it is reached through a forge that refuses that way — and a |
| 1560 | // token nobody has met is a token nobody has handled. |
| 1561 | forge = FORGE.unsup; |
| 1562 | { |
| 1563 | const got = await refusal('daimond', 'unsupported'); |
| 1564 | check('the refusal \'unsupported\' is understood and SAID', |
| 1565 | got.n === 1 && got.why === 'unsupported' && got.text.length > 10, |
| 1566 | `why=${got.why} said=${JSON.stringify(got.text.slice(0, 80))}`); |
| 1567 | } |
| 1568 | forge = FORGE.main; |
| 1569 | |
| 1570 | // `absent` covers BOTH "no such repository" and "this repository is |
| 1571 | // private", permanently. So the sentence has to be true in both cases: |
| 1572 | // "there is no such repository" is false when it is private, and "this |
| 1573 | // repository is private" republishes exactly what is being withheld. |
| 1574 | const absentSaid = seen['absent:_absent'] || ''; |
| 1575 | check('and `absent` is said in words that are true whether it is missing OR private', |
| 1576 | absentSaid.length > 10 |
| 1577 | && !/no such/i.test(absentSaid) |
| 1578 | && !/does not exist/i.test(absentSaid) |
| 1579 | && !/private/i.test(absentSaid), |
| 1580 | JSON.stringify(absentSaid)); |
| 1581 | // And the private repository really is byte-identical at the far end, so a |
| 1582 | // panel could not tell them apart even if it wanted to. |
| 1583 | const privateSaid = (await refusal('_private', 'absent')).text; |
| 1584 | check('and a private repository is refused in exactly the same words', |
| 1585 | privateSaid === absentSaid, JSON.stringify(privateSaid)); |
| 1586 | |
| 1587 | // `because` is branched on, so a tester refused for the ADDRESS is not told |
| 1588 | // it was their own voice. |
| 1589 | const byAddress = seen['throttled:_throttled-address'] || ''; |
| 1590 | const byVoice = (await refusal('_throttled-voice', 'throttled')).text; |
| 1591 | check('and a throttle says which limit it was, without naming what it was spent on', |
| 1592 | byAddress !== byVoice |
| 1593 | && /address/i.test(byAddress) |
| 1594 | && !/submission|proposal|note|vote/i.test(byAddress) |
| 1595 | && !/submission|proposal|note|vote/i.test(byVoice), |
| 1596 | JSON.stringify(byAddress) + ' | ' + JSON.stringify(byVoice)); |
| 1597 | |
| 1598 | // ── 8c. Paging ─────────────────────────────────────────────── |
| 1599 | forge = FORGE.pages; |
| 1600 | await page.evaluate(() => { window.DaimondImprove.reset(); return window.DaimondImprove.load(false); }); |
| 1601 | await page.waitForTimeout(1000); |
| 1602 | let steps = 0; |
| 1603 | while (steps < 12) { |
| 1604 | const more = page.locator('#improve-props [data-act="improve-more"]'); |
| 1605 | if (await more.count() === 0) break; |
| 1606 | await more.click(); |
| 1607 | await page.waitForTimeout(800); |
| 1608 | steps++; |
| 1609 | } |
| 1610 | const walked = await page.evaluate(() => window.DaimondImprove.listing()); |
| 1611 | check('the walk downwards ends, and ends having drawn every proposal', |
| 1612 | walked.done === true && walked.shown.length === 50 && walked.total === 50, |
| 1613 | `${walked.shown.length} of ${walked.total}, done=${walked.done}, ${steps} step(s)`); |
| 1614 | check('and the button to show more is gone rather than offering an empty page', |
| 1615 | await page.locator('#improve-props [data-act="improve-more"]').count() === 0); |
| 1616 | check('AND NOT ONE REQUEST ASKED FOR from=0, which means back to the newest', |
| 1617 | asked.every(a => a.query.from !== '0'), |
| 1618 | asked.filter(a => a.query.from === '0').map(a => a.url).join(' | ')); |
| 1619 | |
| 1620 | // A forge that reads `from` as a LOWER bound hands the same page back for |
| 1621 | // ever, and every answer along the way looks perfectly valid. The walk has to |
| 1622 | // END anyway, or the panel offers to show more until somebody gives up. |
| 1623 | forge = FORGE.wrong; |
| 1624 | await page.evaluate(() => { window.DaimondImprove.reset(); return window.DaimondImprove.load(false); }); |
| 1625 | await page.waitForTimeout(1000); |
| 1626 | const firstPage = (await page.evaluate(() => window.DaimondImprove.listing())).shown.length; |
| 1627 | await page.locator('#improve-props [data-act="improve-more"]').click(); |
| 1628 | await page.waitForTimeout(1000); |
| 1629 | const wrapped = await page.evaluate(() => window.DaimondImprove.listing()); |
| 1630 | check('against a forge that reads `from` the wrong way round, the walk STOPS rather than circling', |
| 1631 | wrapped.done === true && wrapped.shown.length === firstPage, |
| 1632 | `done=${wrapped.done}, ${firstPage} then ${wrapped.shown.length}`); |
| 1633 | check('and it stops offering to show more', |
| 1634 | await page.locator('#improve-props [data-act="improve-more"]').count() === 0); |
| 1635 | // ── 8h. The tool pair goes through the panel's own door ────── |
| 1636 | // |
| 1637 | // B9's Improve half. `Tool::SocialRead` and `Tool::SocialSend` reach this |
| 1638 | // panel through `window.DaimondSocial`, and the whole claim worth making |
| 1639 | // about them is that they are the SAME door the user's own button is: a |
| 1640 | // surface a daimon reaches that meant something slightly different from what |
| 1641 | // the button means would be B9's fault shape inverted. |
| 1642 | // |
| 1643 | // A LISTING IS NOT A PAGE. Still against the fifty-proposal forge, because |
| 1644 | // the difference only exists on a repository with more than one page of them |
| 1645 | // -- and this tool's own description tells a daimon to read the proposals |
| 1646 | // first so it does not open a second one about something already there. |
| 1647 | forge = FORGE.pages; |
| 1648 | await page.evaluate(() => { window.DaimondImprove.reset(); }); |
| 1649 | const toolSaw = await page.evaluate( |
| 1650 | () => window.DaimondSocial.read(JSON.stringify({ view: 'proposals', limit: 50 }))); |
| 1651 | const toolRows = String(toolSaw).split('\n').filter((l) => /^#\d+\s/.test(l)); |
| 1652 | check('a daimon reading the proposals reaches every one of them, not merely the first page', |
| 1653 | toolRows.length === 50, `${toolRows.length} of 50 — ${String(toolSaw).slice(0, 140)}`); |
| 1654 | |
| 1655 | forge = FORGE.main; |
| 1656 | |
| 1657 | // COMPOSING PUTS NOTHING ON THE WIRE. The user is shown what would be |
| 1658 | // published and answers about THAT; a call that took the model's arguments |
| 1659 | // and asked on the way past would mean the person approved a rendering and |
| 1660 | // the app sent a rebuild of it. |
| 1661 | const beforeTool = opens().length; |
| 1662 | const draftRaw = await page.evaluate(() => window.DaimondSocial.compose(JSON.stringify({ |
| 1663 | act: 'propose', |
| 1664 | title: 'The tool pair opens a proposal through the panel', |
| 1665 | body: 'Composed by social_send and not yet published. quokka-marker-tool', |
| 1666 | }))); |
| 1667 | await page.waitForTimeout(400); |
| 1668 | const draft = (() => { try { return JSON.parse(draftRaw); } catch (e) { return {}; } })(); |
| 1669 | check('composing what a daimon would publish puts nothing whatever on the wire', |
| 1670 | opens().length === beforeTool, |
| 1671 | `${opens().length - beforeTool} request(s) — ${String(draftRaw).slice(0, 160)}`); |
| 1672 | check('and what it hands back to be shown carries the characters that would go', |
| 1673 | typeof draft.shown === 'string' && draft.shown.indexOf('quokka-marker-tool') >= 0 |
| 1674 | && typeof draft.token === 'string' && draft.token.length > 0, |
| 1675 | String(draftRaw).slice(0, 200)); |
| 1676 | |
| 1677 | // AND COMMITTING GOES THROUGH `through()`, which is the panel's own one door. |
| 1678 | // Asserted on the FIELD SET, exactly as check 2 asserts it of the button: a |
| 1679 | // second door that happened to send the right two fields as well would be a |
| 1680 | // second place to keep right. |
| 1681 | const toolSaid = await page.evaluate((tok) => window.DaimondSocial.commit(tok), draft.token); |
| 1682 | await page.waitForTimeout(1200); |
| 1683 | check('and committing it opens exactly one proposal', |
| 1684 | opens().length === beforeTool + 1, |
| 1685 | `${opens().length - beforeTool} request(s) — ${String(toolSaid).slice(0, 140)}`); |
| 1686 | const toolSent = opens()[opens().length - 1] || { body: '' }; |
| 1687 | check('through the panel\'s own door: the same field set the user\'s own button produces', |
| 1688 | Object.keys(fields(toolSent.body)).sort().join(',') === 'body,build,title', |
| 1689 | Object.keys(fields(toolSent.body)).sort().join(',') || '(none)'); |
| 1690 | |
| 1691 | // A TOKEN IS SPENT ONCE. A yes about one publication left spendable is a |
| 1692 | // licence to publish it again, which is a memory of consent -- and consent |
| 1693 | // here is bound to the bytes, never remembered. |
| 1694 | const beforeAgain = opens().length; |
| 1695 | const again = await page.evaluate((tok) => window.DaimondSocial.commit(tok), draft.token); |
| 1696 | await page.waitForTimeout(800); |
| 1697 | check('and the yes is spent: the same token publishes nothing a second time', |
| 1698 | opens().length === beforeAgain && /^Refused:/.test(String(again)), |
| 1699 | `${opens().length - beforeAgain} request(s) — ${String(again).slice(0, 140)}`); |
| 1700 | |
| 1701 | // ── 9. The panel's words are the guide's words ─────────────── |
| 1702 | await page.evaluate(() => { window.DaimondImprove.reset(); return window.DaimondImprove.load(false); }); |
| 1703 | await page.waitForTimeout(1000); |
| 1704 | |
| 1705 | const chipState = async () => page.evaluate(() => { |
| 1706 | const q = (v) => document.querySelector(`#panel-social .imp-chip[data-view="${v}"]`); |
| 1707 | const set = q('settings'), p = q('proposals'); |
| 1708 | const vis = (id) => { const e = document.getElementById(id); return !!(e && !e.hidden); }; |
| 1709 | return { |
| 1710 | settings: !!(set && set.classList.contains('on')), props: !!(p && p.classList.contains('on')), |
| 1711 | settingsView: vis('social-settings'), propsView: vis('improve-props-view'), |
| 1712 | }; |
| 1713 | }); |
| 1714 | const onProps = await chipState(); |
| 1715 | check('the Proposals chip is filled while the proposals are showing, and Settings is not', |
| 1716 | onProps.props && !onProps.settings && onProps.propsView && !onProps.settingsView, |
| 1717 | JSON.stringify(onProps)); |
| 1718 | |
| 1719 | await page.evaluate(() => window.DaimondImprove.show('settings')); |
| 1720 | await page.waitForTimeout(400); |
| 1721 | const onSettings = await chipState(); |
| 1722 | check('and pressing the other chip swaps which is filled and which view shows', |
| 1723 | onSettings.settings && !onSettings.props && onSettings.settingsView && !onSettings.propsView, |
| 1724 | JSON.stringify(onSettings)); |
| 1725 | await page.evaluate(() => window.DaimondImprove.show('proposals')); |
| 1726 | await page.waitForTimeout(300); |
| 1727 | |
| 1728 | // Both views' text, because the guide describes both and one is hidden at |
| 1729 | // any moment. `textContent` rather than `innerText`: a hidden view has no |
| 1730 | // rendered text, and what is being checked is the WORDS the panel is built |
| 1731 | // from, not what happens to be painted this second. |
| 1732 | const words = () => page.evaluate(() => { |
| 1733 | const p = document.getElementById('panel-social'); |
| 1734 | return p ? p.textContent.replace(/\s+/g, ' ') : ''; |
| 1735 | }); |
| 1736 | |
| 1737 | // SAMPLED IN EACH STATE, AND UNIONED, because four of the guide's labels |
| 1738 | // are MUTUALLY EXCLUSIVE with any one state the panel can be in: |
| 1739 | // |
| 1740 | // `Set a voice` drawn only while NO voice is held. By this point one |
| 1741 | // is, and the panel correctly says `Replace the voice`. |
| 1742 | // `Save the voice` drawn only while the paste field is open. |
| 1743 | // `Say it` drawn only inside an expanded proposal, and only with |
| 1744 | // a voice -- the forge refuses an unvoiced comment. |
| 1745 | // `I lost my voice — re-issue` drawn only once the forge has said a voice |
| 1746 | // exists on ANOTHER device (`provision` answered `already`) |
| 1747 | // and none is held here -- the one state where a re-issue |
| 1748 | // is the way out. Reached below by standing in for that |
| 1749 | // answer and pressing `Get my voice`. |
| 1750 | // |
| 1751 | // One snapshot was asked for three states' worth of labels and reported all |
| 1752 | // three missing from the panel. They were in the panel; the check was in one |
| 1753 | // state. Deleting them from the list was the other way out of that, and it |
| 1754 | // would have gutted the check: this exists so the guide cannot describe a |
| 1755 | // control that is not there, and a list narrowed to whatever one state |
| 1756 | // happens to show is exactly the guide-drifts-from-app fault going unseen. |
| 1757 | const sampled = []; |
| 1758 | sampled.push(await words()); // as it stands: voiced, nothing expanded |
| 1759 | |
| 1760 | // A proposal opened. `Say it` and its box live inside one and nowhere else. |
| 1761 | await page.evaluate(() => window.DaimondImprove.show('proposals')); |
| 1762 | await page.waitForTimeout(400); |
| 1763 | const anyRow = page.locator('#improve-props .imp-prop .imp-prop-row').first(); |
| 1764 | if (await anyRow.count()) { await anyRow.click(); await page.waitForTimeout(900); } |
| 1765 | sampled.push(await words()); |
| 1766 | |
| 1767 | // The paste field open. `Save the voice` is the button beside it. It lives in |
| 1768 | // the Settings view now. |
| 1769 | await page.evaluate(() => window.DaimondImprove.show('settings')); |
| 1770 | await page.waitForTimeout(400); |
| 1771 | await page.click('[data-act="improve-voice-open"]'); |
| 1772 | await page.waitForTimeout(300); |
| 1773 | sampled.push(await words()); |
| 1774 | await page.click('[data-act="improve-voice-cancel"]'); |
| 1775 | await page.waitForTimeout(200); |
| 1776 | |
| 1777 | // And with no voice at all, which is what a first-time reader of the guide |
| 1778 | // has. Set through `DaimondVoice` rather than through the panel: the panel's |
| 1779 | // own Forget asks first, and a dialog answered here would be a second thing |
| 1780 | // this check was silently proving. |
| 1781 | await page.evaluate(() => { window.DaimondVoice.clear(); window.DaimondImprove.render(); }); |
| 1782 | await page.waitForTimeout(400); |
| 1783 | sampled.push(await words()); |
| 1784 | |
| 1785 | // And the `already` state: the forge holds a voice on another device that has |
| 1786 | // not synced here. `Get my voice` posts to `/api/voice/provision`; standing in |
| 1787 | // for the gateway's answer with `already:true` drives the panel into the one |
| 1788 | // state the destructive `I lost my voice — re-issue` control lives in. The guide |
| 1789 | // names it (§"The Social panel"), so the union has to reach it. |
| 1790 | await page.route('**/api/voice/provision', (r) => r.fulfill({ |
| 1791 | status: 200, contentType: 'application/json', |
| 1792 | body: JSON.stringify({ provisioned: true, already: true }), |
| 1793 | })); |
| 1794 | await page.click('[data-act="improve-voice-get"]'); |
| 1795 | await page.waitForTimeout(400); |
| 1796 | sampled.push(await words()); |
| 1797 | await page.unroute('**/api/voice/provision'); |
| 1798 | |
| 1799 | // Put it back before anything downstream reads the panel: everything after |
| 1800 | // this point assumes the voiced panel the checks above left behind. |
| 1801 | await page.evaluate(async (sec) => { |
| 1802 | await window.DaimondVoice.set(sec); window.DaimondImprove.render(); |
| 1803 | }, SECRET); |
| 1804 | await page.waitForTimeout(400); |
| 1805 | |
| 1806 | const panelWords = sampled.join(' '); |
| 1807 | const missing = GUIDE_LABELS.filter(w => panelWords.indexOf(w) === -1); |
| 1808 | check(`every label the guide names is in the panel (${GUIDE_LABELS.length} checked)`, |
| 1809 | missing.length === 0, missing.map(m => JSON.stringify(m)).join(', ')); |
| 1810 | |
| 1811 | const known = (n) => TERMS.has(n) || TERMS.has(n.replace(/s$/, '')); |
| 1812 | const coined = GUIDE_NOUNS.filter(n => !known(n.toLowerCase())); |
| 1813 | check(`and every part the guide names in bold is a word the glossary defines (${GUIDE_NOUNS.length} checked)`, |
| 1814 | coined.length === 0, coined.map(m => JSON.stringify(m)).join(', ')); |
| 1815 | |
| 1816 | check('the panel does not call its box the composer', |
| 1817 | panelWords.toLowerCase().indexOf('composer') === -1); |
| 1818 | |
| 1819 | // ── 1e. And the disclosure is said in all eight languages ──── |
| 1820 | // |
| 1821 | // Asked of the RUNNING panel in each language rather than of the catalogues on |
| 1822 | // disk, because a key in a table nothing reads is not a sentence anybody sees: |
| 1823 | // this panel draws every one of its own words, so a language change reaches |
| 1824 | // them only through the surface it registers with i18n.js. |
| 1825 | // |
| 1826 | // A missing key falls through to the English, which still names the host — so |
| 1827 | // the host alone would pass over exactly the gap being looked for. What is |
| 1828 | // asserted is BOTH: the host is there, and the sentence is not the English one |
| 1829 | // wearing another language's flag. |
| 1830 | const englishSaid = disclosed ? disclosed.text : ''; |
| 1831 | const inEach = []; |
| 1832 | for (const code of ['de', 'es', 'fr', 'ja', 'ko', 'pt-BR', 'zh-Hans']) { |
| 1833 | await page.evaluate(c => window.DaimondI18n.setLocale(c), code); |
| 1834 | await page.waitForTimeout(350); |
| 1835 | inEach.push([code, await page.evaluate(() => { |
| 1836 | const p = document.getElementById('improve-public'); |
| 1837 | return p ? (p.textContent || '').replace(/\s+/g, ' ').trim() : ''; |
| 1838 | })]); |
| 1839 | } |
| 1840 | await page.evaluate(() => window.DaimondI18n.setLocale('en')); |
| 1841 | await page.waitForTimeout(350); |
| 1842 | |
| 1843 | const untranslated = inEach.filter(([, s]) => !s || s === englishSaid) |
| 1844 | .map(([c, s]) => c + ': ' + (s ? JSON.stringify(s.slice(0, 40)) : 'nothing')); |
| 1845 | check(`the disclosure is said in all eight languages (${inEach.length + 1} checked)`, |
| 1846 | untranslated.length === 0, untranslated.join(' | ')); |
| 1847 | // And the strings this lane put in the catalogues alongside it are in all |
| 1848 | // eight too. READ OFF DISK and not off the screen, and the difference is |
| 1849 | // stated rather than glossed: this proves the ENTRY EXISTS and keeps its |
| 1850 | // placeholder, not that the sentence reaches anybody — the sweep above is the |
| 1851 | // one that proves reaching, and it can only do it for the surface this file |
| 1852 | // drives. The capp dialogs belong to another lane's verifier. |
| 1853 | // |
| 1854 | // It is here at all because the fallback that draws them, `tOr(key, english)`, |
| 1855 | // is silent by design: a key in no table paints correct English and nothing |
| 1856 | // anywhere reports it. Fifteen keys reached `en.js` only in the last release |
| 1857 | // and a whole `voice.*` family is in no table at all, both found by reading |
| 1858 | // rather than by any check. |
| 1859 | const CATALOGUE = [ |
| 1860 | ['social.public_note', '{host}'], |
| 1861 | ['capp.legacy_body', '{name}'], |
| 1862 | ['capp.legacy_ok', ''], |
| 1863 | ['capp.update_kept', '{files}'], |
| 1864 | ['capp.update_failed', '{why}'], |
| 1865 | ['capp.page_reset_confirm', '{name}'], |
| 1866 | ]; |
| 1867 | const gaps = []; |
| 1868 | for (const code of ['en', 'de', 'es', 'fr', 'ja', 'ko', 'pt-BR', 'zh-Hans']) { |
| 1869 | const p = 'i18n/' + code + '.js'; |
| 1870 | const src = FILES.get(p) ?? fs.readFileSync(path.join(WWW, p), 'utf8'); |
| 1871 | for (const [key, ph] of CATALOGUE) { |
| 1872 | const m = src.match(new RegExp("'" + key.replace('.', '\\.') + "':\\s*'((?:[^'\\\\]|\\\\.)*)'")); |
| 1873 | if (!m) gaps.push(code + ' has no ' + key); |
| 1874 | else if (ph && !m[1].includes(ph)) gaps.push(code + ' dropped ' + ph + ' from ' + key); |
| 1875 | } |
| 1876 | } |
| 1877 | check(`every string this lane added is in all eight catalogues, placeholders and all (${CATALOGUE.length * 8} checked)`, |
| 1878 | gaps.length === 0, gaps.join(' | ')); |
| 1879 | |
| 1880 | check('and English came back afterwards, so nothing below is read in another language', |
| 1881 | (await page.evaluate(() => { |
| 1882 | const p = document.getElementById('improve-public'); |
| 1883 | return p ? (p.textContent || '').replace(/\s+/g, ' ').trim() : ''; |
| 1884 | })) === englishSaid); |
| 1885 | |
| 1886 | // ── 10. And it exists on a phone ───────────────────────────── |
| 1887 | await page.setViewportSize({ width: 390, height: 844 }); |
| 1888 | await page.waitForTimeout(600); |
| 1889 | await page.evaluate(() => { if (window.DaimondSheet) window.DaimondSheet.open('social'); }); |
| 1890 | await page.waitForTimeout(900); |
| 1891 | // The note box lives in the Proposals view now; show it before measuring, or a |
| 1892 | // box in a hidden view reports no size. |
| 1893 | await page.evaluate(() => window.DaimondSocial.show('proposals')); |
| 1894 | await page.waitForTimeout(300); |
| 1895 | const phone = await page.evaluate(() => { |
| 1896 | const box = document.getElementById('improve-box'); |
| 1897 | const send = document.querySelector('#improve-acts .imp-send'); |
| 1898 | const b = box ? box.getBoundingClientRect() : null; |
| 1899 | const t = send ? send.getBoundingClientRect() : null; |
| 1900 | return { |
| 1901 | inSheet: !!(box && box.closest('#msheet')), |
| 1902 | box: b ? { w: Math.round(b.width), h: Math.round(b.height) } : null, |
| 1903 | send: t ? { w: Math.round(t.width), h: Math.round(t.height), right: Math.round(t.right) } : null, |
| 1904 | width: window.innerWidth, |
| 1905 | }; |
| 1906 | }); |
| 1907 | check('on a phone the panel rises as a sheet', phone.inSheet, JSON.stringify(phone)); |
| 1908 | check('and the note box is a box you could write in', |
| 1909 | !!phone.box && phone.box.w > 240 && phone.box.h > 60, JSON.stringify(phone.box)); |
| 1910 | check('and Send is on the screen rather than off the right of it', |
| 1911 | !!phone.send && phone.send.w > 30 && phone.send.right <= phone.width, |
| 1912 | JSON.stringify(phone.send) + ' in ' + phone.width); |
| 1913 | await shot(s, 'improve-phone' + (BREAK ? '-' + BREAK : '')); |
| 1914 | await page.setViewportSize({ width: 1500, height: 950 }); |
| 1915 | |
| 1916 | // ── The secret is nowhere it should not be ─────────────────── |
| 1917 | const leaked = wire.filter(r => (r.url + ' ' + r.body).indexOf(SECRET) !== -1 |
| 1918 | && r.url.indexOf('/api/improve') === -1); |
| 1919 | check('the voice is in no URL and in no body anywhere on the wire', |
| 1920 | leaked.length === 0 && asked.every(a => a.url.indexOf(SECRET) === -1), |
| 1921 | leaked.map(h => h.url).join(' | ')); |
| 1922 | |
| 1923 | const errs = errors(s).filter(e => |
| 1924 | !/Failed to load resource/.test(e) |
| 1925 | && !(/blocked by CORS policy/.test(e) && /\/guide\//.test(e))); |
| 1926 | check('nothing above was reached by way of an unhandled error', errs.length === 0, |
| 1927 | errs.slice(0, 3).join(' | ')); |
| 1928 | |
| 1929 | await shot(s, 'improve' + (BREAK ? '-' + BREAK : '')); |
| 1930 | } finally { |
| 1931 | await s.close(); |
| 1932 | stopForges(); |
| 1933 | } |
| 1934 | |
| 1935 | console.log(`\nopens: ${opens().length} votes: ${votes().length} comments: ${comments().length}` |
| 1936 | + ` improve requests: ${asked.length} requests seen: ${wire.length}`); |
| 1937 | if (BREAK) { |
| 1938 | console.log(`\nbreak '${BREAK}': ${bad.length} check(s) failed` |
| 1939 | + (bad.length ? ' — ' + bad.join('; ') : ' — NOTHING FAILED, so the checks above prove nothing')); |
| 1940 | process.exit(bad.length ? 0 : 1); // a break MUST fail something |
| 1941 | } |
| 1942 | console.log(bad.length === 0 ? '\nall checks passed' : `\n${bad.length} check(s) FAILED`); |
| 1943 | process.exit(bad.length === 0 ? 0 : 1); |