Oregami
Repositories/oxedyne/daimond

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.
264import fs from 'node:fs';
265import path from 'node:path';
266import { spawn } from 'node:child_process';
267import { fileURLToPath } from 'node:url';
268import { open, shot, scratch, errors } from './harness.mjs';
269
270const HERE = path.dirname(fileURLToPath(import.meta.url));
271const WWW = path.join(HERE, '..', 'www');
272
273const BREAK = (() => {
274 const i = process.argv.indexOf('--break');
275 return i > 0 ? String(process.argv[i + 1] || '') : '';
276})();
277
278const PROFILE = scratch('pw', 'improve' + (BREAK ? '-' + BREAK : ''));
279fs.rmSync(PROFILE, { recursive: true, force: true });
280
281const ok = [], bad = [];
282const 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
289const 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
305const started = [];
306
307async 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
315async 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
336function 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.
345process.on('exit', stopForges);
346for (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
365const 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.
389function 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.
407const 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
694if (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.
701function 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.
722const FILES = new Map(); // path under www/ -> the text to serve
723
724function 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}
733build();
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.
749let 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.
753let changedHop = null;
754let asRepo = ''; // '' means "whatever the panel asked for"
755
756const NAME = /^[A-Za-z0-9_-]+$/;
757
758/// The path `upstream_path()` builds. `format=json` is written HERE and never
759/// taken from the caller.
760function 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.
779const wire = [];
780/// Every request that reached the improve route, in order.
781const asked = [];
782
783const HDR = 'x-daimond-voice';
784
785async 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
864const json = (body, status = 200) => ({
865 status, contentType: 'application/json', body: JSON.stringify(body),
866});
867
868async 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
898const GUIDE = fs.readFileSync(path.join(WWW, 'guide', 'social.html'), 'utf8');
899
900function 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
910function 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
918const SECTION = guideSection();
919const TERMS = glossaryTerms();
920
921const GUIDE_LABELS = [...new Set(
922 [...SECTION.matchAll(/<span class="ui">([^<]+)<\/span>/g)].map(m => m[1].trim()))];
923
924const 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
931const 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
939const 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.
950const MOCK_SECRET = 'mock-voice-ada';
951
952await startForges();
953
954const s = await open({ name: 'improve', profile: PROFILE, signIn: false, connect: false, route: stub });
955const { page } = s;
956
957const { signInAs } = await import('./harness.mjs');
958await signInAs(s, 'improve');
959await 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.
963const opens = () => asked.filter(a => a.method === 'POST' && a.query.n === undefined);
964const votes = () => asked.filter(a => a.method === 'POST' && a.query.vote === '1');
965const 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.
970const fields = (raw) => {
971 const out = {};
972 for (const [k, v] of new URLSearchParams(raw)) out[k] = v;
973 return out;
974};
975
976try {
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
1935console.log(`\nopens: ${opens().length} votes: ${votes().length} comments: ${comments().length}`
1936 + ` improve requests: ${asked.length} requests seen: ${wire.length}`);
1937if (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}
1942console.log(bad.length === 0 ? '\nall checks passed' : `\n${bad.length} check(s) FAILED`);
1943process.exit(bad.length === 0 ? 0 : 1);