1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475 | /**
* The four modal overlays (Phase 5d) — the fifth UI golden vector in this repo,
* and the one that retires the last `innerHTML` string template and the last
* `esc` call site.
*
* What it replaces was ~300 lines of "assign a string to `overlay.innerHTML`,
* then re-query your own markup by id to attach nine listeners and to write the
* three fields the template could not carry safely". `show-identity-sheet!`
* alone was 166 of them, with two promise callbacks reaching back into the DOM
* to set a node's text and then to remove that node.
*
* FOUR THINGS THIS FILE HAS TO PROVE, and only the first is what a snapshot
* test usually proves:
*
* 1. THE OVERLAYS ARE VALUES. `project` reads a PeerInfo bag and room.cljs's
* SelfIdentityInfo; `step` is (sheet, event) -> [sheet', effects] with the
* effects as data; `view` is state -> hiccup. The discrimination block
* re-renders rather than reading the fixture, so it would still fail
* against a fixture recorded from a broken view.
*
* 2. NO SEED, ANYWHERE. The identity card's whole purpose is an Ed25519
* private key, and it is the one string that must never reach a value a
* snapshot prints. `identity-sheet` never reads `me.seed`, the copy sheet
* does not carry its own text, and both are written onto their field by a
* mount hook in app/ui instead. The structural block asserts the absence
* across every state, every snapshot and the committed fixture.
*
* 3. THE MOUNT HOOKS FIRE EXACTLY ONCE PER OPEN. Those two hooks are the
* `:rev` hazard Phase 5c found, in its other direction: their effect
* depends on something outside the hiccup, and `reconcile*` bails on an
* unchanged node. Here the answer is structural rather than a revision
* counter — no overlay can be re-opened while it is still open, because
* opening one requires clicking something the overlay covers — and the
* block at the bottom asks the real reconciler to confirm it: fires on
* mount, silent through a `reveal` and an `armed`, fires again after a
* close-and-reopen.
*
* 4. A DISMISSED OVERLAY IS GONE, NOT HIDDEN. This is the one with a bug
* behind it: the composer's `#input` sits in the DOM behind the first-run
* name prompt from the moment `build!` runs, and a prompt that merely
* hides itself leaves a focus trap and a click target over a live screen.
* `view` of an empty stack is an empty seq, and the probe checks the DOM
* rather than the string.
*
* NEVER REGENERATE test/vectors/sheets-view.json. There is no generator script
* and there must not be one. See the fixture's own `_` block.
*/
import test from "node:test";
import assert from "node:assert/strict";
import { installDom } from "./harness/dom.mjs";
import { installMiniDom, makeRoot, byId, kids } from "./harness/minidom.mjs";
import { readVector } from "./helpers/load.mjs";
installDom();
const V = await import("../view-dist/viewlib.js");
const VEC = readVector("sheets-view");
const S = VEC.states;
/** The EDN for a named stack of overlays. */
const stack = (name) => `[${VEC.stacks[name].map((n) => S[n]).join(" ")}]`;
// ---- the fixture -----------------------------------------------------------
for (const [name, c] of Object.entries(VEC.cases)) {
test(`project: ${name}`, () => {
const got =
c.kind === "askName" ? V.sheetsAskName(c.current)
: c.kind === "peer" ? V.sheetsPeer(c.peer, c.p, c.verdict)
: c.kind === "identity" ? V.sheetsIdentity(c.me, c.name)
: V.sheetsCopy();
assert.equal(got, S[name]);
});
}
for (const [i, [from, event, to, effects]] of VEC.derived.entries()) {
test(`derived ${i}: ${from} ${event} -> ${to}`, () => {
// every state in the fixture is either projected or REACHED; none is
// asserted into existence
assert.equal(V.sheetsStep(S[from], event), `[${S[to]} ${effects}]`);
});
}
for (const [i, [name, event, expected]] of VEC.steps.entries()) {
test(`step ${i}: ${name} ${event}`, () => {
assert.equal(V.sheetsStep(S[name], event), expected);
});
}
for (const name of Object.keys(VEC.stacks)) {
test(`hiccup snapshot: ${name}`, () => {
assert.equal(V.sheetsView(stack(name)), VEC.hiccup[name]);
});
}
for (const [name, html] of Object.entries(VEC.html)) {
test(`markup snapshot: ${name}`, () => {
assert.equal(V.sheetsHtml(stack(name)), html);
});
}
// ---- discrimination: the snapshot must be able to SEE the state ------------
const differ = (a, b, why) => assert.notEqual(V.sheetsView(stack(a)), V.sheetsView(stack(b)), why);
test("the snapshot distinguishes an open overlay from none", () => {
// THE property the name prompt turns on: an empty stack renders to nothing,
// so there is no node left to focus, to click or to trap a caret.
differ("empty", "ask", "an overlay must appear");
assert.equal(V.sheetsView("[]"), "()");
});
test("the snapshot distinguishes a prefilled name field", () => {
differ("ask", "ask-prefilled", "`:value` is what the imperative code wrote by hand");
});
test("the snapshot distinguishes a peer who announced an identity from one who did not", () => {
differ("peer", "peer-anon", "no fingerprint means no verify button at all");
});
test("the snapshot distinguishes a verified peer", () => {
// the button's own label is the toggle's whole state, and it used to be read
// off a verdict object that was MUTATED in place (Phase 5c ended that)
differ("peer", "peer-verified", "the verify button must say which way it goes");
});
test("the snapshot distinguishes a peer whose key CHANGED", () => {
// the TOFU alarm, and the one paragraph in this screen that is a warning
differ("peer", "peer-key-changed", "the `.warn` paragraph must appear");
assert.ok(V.sheetsView(stack("peer-key-changed")).includes("[:p.warn "));
assert.ok(!V.sheetsView(stack("peer")).includes("[:p.warn "));
});
test("the snapshot distinguishes WHICH peer the card is for", () => {
differ("peer", "peer-hostile", "the name and the fingerprint are per-peer");
});
test("the snapshot distinguishes a browser with no Ed25519", () => {
differ("identity", "identity-no-key", "no key means no save, copy, reveal or import");
});
test("the snapshot distinguishes a suite-identity conflict", () => {
differ("identity", "identity-conflict", "the chooser must appear");
});
test("the snapshot distinguishes an ARMED two-tap button", () => {
// both of these are destructive and both replaced a native confirm(); the
// arming used to be a classList.add plus a textContent write on the node
differ("identity-conflict", "conflict-armed", "arming `use suite identity` must show");
differ("identity", "identity-import-armed", "…and so must arming `use this identity`");
});
test("the snapshot distinguishes the conflict fingerprint's three states", () => {
// pending, resolved, and the node the imperative code REMOVED on failure
differ("identity-conflict", "conflict-fp", "the resolved fingerprint must land");
differ("identity-conflict", "conflict-failed", "a rejected fromSeed must drop the node");
differ("conflict-fp", "conflict-failed", "resolved and failed are not the same screen");
});
test("the snapshot distinguishes a revealed identity key", () => {
differ("identity", "identity-revealed", "type=password vs type=text is the whole feature");
});
test("the snapshot distinguishes YOUR display name", () => {
differ("identity", "identity-hostile", "the rename field is prefilled from it");
// and a rename MOVES it: the imperative code compared the field against
// `chat/my-name` live, so a `:me-name` frozen at open would have swallowed a
// rename back to the original. The credential form's username follows too.
differ("identity", "identity-renamed", "a rename must move both fields");
assert.ok(V.sheetsView(stack("identity-renamed")).includes(':value "sueta · bogdan"'));
});
test("the snapshot distinguishes a STACK from its bottom sheet alone", () => {
// the manual-copy fallback opens over the identity card — that is the only
// route to it — and closing it has to give the card back
differ("identity", "identity+copy", "a pushed overlay must appear");
differ("copy", "identity+copy", "…without losing the one underneath");
});
test("keeping the local identity leaves exactly the card with no conflict", () => {
// not a coincidence worth tolerating: `[:keep]` removes the chooser and
// nothing else, so the state must be EQUAL to the one that never had it
assert.equal(S["conflict-kept"], S.identity);
});
test("the recorded fixture is itself discriminating", () => {
// Not a tautology: this reads the COMMITTED snapshots, so a fixture recorded
// from a view that ignored `:armed` or `:reveal?` would fail here even though
// the checks above (which re-render) still passed.
const pairs = [
["empty", "ask"],
["ask", "ask-prefilled"],
["peer", "peer-anon"],
["peer", "peer-verified"],
["peer", "peer-key-changed"],
["peer", "peer-hostile"],
["identity", "identity-no-key"],
["identity", "identity-conflict"],
["identity-conflict", "conflict-armed"],
["identity", "identity-import-armed"],
["identity-conflict", "conflict-fp"],
["identity-conflict", "conflict-failed"],
["conflict-fp", "conflict-failed"],
["identity", "identity-revealed"],
["identity", "identity-hostile"],
["identity", "identity-renamed"],
["identity", "identity+copy"],
["copy", "identity+copy"],
];
for (const [a, b] of pairs) assert.notEqual(VEC.hiccup[a], VEC.hiccup[b], `${a} vs ${b}`);
});
// ---- structural properties -------------------------------------------------
test("no snapshot contains an opaque function", () => {
const all = [
...Object.keys(VEC.stacks).map((n) => V.sheetsView(stack(n))),
...Object.values(VEC.hiccup),
...Object.values(S),
];
for (const out of all) assert.ok(!out.includes("#object"), `an opaque function: ${out.slice(0, 140)}`);
});
test("no state, no snapshot and no committed fixture line contains an identity seed", () => {
// THE rule this screen adds to the handle-registry rule: a private key is
// content, so nothing would break if it rode into the state — but the state
// is what a snapshot prints, a golden vector commits and an error report may
// carry. `identity-sheet` never reads `me.seed`; the copy sheet does not
// carry its text; both are written onto their field by a mount hook instead.
// no STATE mentions it at all — not the key, not the value
for (const [name, s] of Object.entries(S)) {
assert.ok(!s.includes("seed"), `\`seed\` is in the ${name} state: ${s.slice(0, 120)}`);
}
// and nothing anywhere is seed-SHAPED. `:fill-seed` survives this on
// purpose: it is the name of the hook that writes the key onto the node,
// which is precisely the mechanism keeping the key out of everything here.
const haystack = [
...Object.values(S),
...Object.values(VEC.hiccup),
...Object.values(VEC.html),
JSON.stringify(VEC.cases),
].join("\n");
const runs = [...haystack.matchAll(/[A-Za-z0-9_-]{43}/g)].map((m) => m[0]);
assert.deepEqual(runs, [], "a seed-shaped string is in a snapshot");
// the projection genuinely ignores it: the same `me` with and without a seed
// must project identically
const fp = { emoji: "🐝🌙⭐", hex: "9f8e 7d6c 5b4a" };
assert.equal(
V.sheetsIdentity({ fp, pub: "PUBME", profile: {} }, "iulia"),
V.sheetsIdentity({ fp, pub: "PUBME", profile: {}, seed: "x".repeat(43) }, "iulia"),
);
});
test("the snapshot is not vacuous", () => {
// every id the e2e suite and app/ui reach for by hand
// the live renders and the committed fixture, checked SEPARATELY: joining
// them into one haystack lets either half carry the other, which is the
// hollow shape this project has produced seven of.
const live = Object.keys(VEC.stacks).map((n) => V.sheetsView(stack(n))).join("\n");
const fixture = Object.values(VEC.hiccup).join("\n");
for (const fragment of [
"[:input#name-in ", // e2e: fill(#name-in) on every one of eight scripts
"[:button#name-ok.primary ", // e2e: click(#name-ok)
"[:button#verify-btn.primary ", // the out-of-band verification toggle
"[:form#id-save ", // e2e/mesh: click(#id-save button[type=submit])
"[:form#id-rename.new-room-form ",
"[:form#id-import ",
"[:button#id-copy.ghost ",
"[:button#id-reveal.ghost ",
"[:button#id-adopt.primary ",
"[:button#id-keep.ghost ",
"[:button#sheet-close.ghost ",
"[:input.copy-out ",
"[:div#conf-fp.fp-emoji ",
]) {
assert.ok(live.includes(fragment), `missing from every rendered snapshot: ${fragment}`);
assert.ok(fixture.includes(fragment), `missing from every committed snapshot: ${fragment}`);
}
// and app/ui reads two fields back out of the DOM by these exact selectors
assert.ok(V.sheetsHtml(stack("identity")).includes('<form id="id-rename"'));
assert.ok(V.sheetsHtml(stack("identity")).includes('name="username"'));
});
test("the name prompt has no way out but a name", () => {
// deliberate: every other overlay closes on a backdrop click and offers a
// close button; this one offers neither, because the room boot is awaiting it
const ask = V.sheetsView(stack("ask"));
assert.ok(!ask.includes(":overlay-click"), "the name prompt must not close on a backdrop click");
assert.ok(!ask.includes("sheet-close"), "…nor offer a close button");
for (const n of ["peer", "identity", "copy"]) {
assert.ok(V.sheetsView(stack(n)).includes(":overlay-click"), `${n} lost its backdrop close`);
assert.ok(V.sheetsView(stack(n)).includes("sheet-close"), `${n} lost its close button`);
}
});
test("every overlay in a stack is keyed by its kind", () => {
const keys = (edn) => [...edn.matchAll(/:replicant\/key "([^"]+)"/g)].map((m) => m[1]);
assert.deepEqual(keys(V.sheetsView(stack("identity+copy"))), ["identity", "copy"]);
assert.deepEqual(keys(V.sheetsView(stack("ask"))), ["ask-name"]);
assert.deepEqual(keys(V.sheetsView("[]")), []);
assert.deepEqual(keys(VEC.hiccup["identity+copy"]), ["identity", "copy"]);
});
// ---- XSS is structural, not diligent ---------------------------------------
test("a hostile peer name reaches the card as TEXT", () => {
// It comes off the wire and app/chat clamps its LENGTH, not its content. In
// the template this replaces it was the ONE field that could not be
// interpolated, so it was written with textContent afterwards; here it is
// simply a child.
const hiccup = V.sheetsView(stack("peer-hostile"));
assert.ok(hiccup.includes(JSON.stringify("<img src=x onerror=alert(1)>")));
assert.ok(!hiccup.includes(":innerHTML"), "no hiccup node may carry raw HTML");
const html = V.sheetsHtml(stack("peer-hostile"));
assert.ok(!html.includes("<img src=x"), "the name was rendered as markup");
assert.ok(html.includes("<img src=x onerror=alert(1)>"));
});
test("a hostile display name reaches the rename field as a VALUE", () => {
const html = V.sheetsHtml(stack("identity-hostile"));
assert.ok(!html.includes("<b>iulia</b>"), "the display name was rendered as markup");
assert.ok(html.includes("<b>iulia</b>"));
});
test("every translated string in these sheets is escaped by construction", () => {
// The 34 `esc` calls this screen retires were all wrapped around `t` lookups
// — a catalog that ever carried markup was the risk, and hiccup removes the
// question. Proven by feeding the markup through the one field that takes an
// arbitrary string and reading the markup back.
for (const name of Object.keys(VEC.html)) {
assert.ok(!/<(img|script|b)\b/.test(VEC.html[name].replace(/</g, "")), name);
}
});
// ---- what the real renderer does -------------------------------------------
/** Depth-first find by attribute — the password field has no id. */
const byAttr = (el, a, v) => {
for (const c of el.childNodes) {
if (c.__kind !== "element") continue;
if (c.getAttribute(a) === v) return c;
const found = byAttr(c, a, v);
if (found) return found;
}
return null;
};
const hookOps = () => V.sheetsHooks().map((h) => /^\[(:[a-z-]+)/.exec(h)[1]);
test("a dismissed overlay is GONE from the DOM, not hidden", () => {
// The bug this is written against: the composer's `#input` sits behind the
// first-run prompt from the moment `build!` runs, and a prompt that hides
// itself instead of leaving keeps a focus target and a click shield over a
// live screen. app/ui paints the stack BEFORE it resolves the promise, so
// this has to be true of an empty render and not merely of a class.
const restore = installMiniDom();
try {
const root = makeRoot();
V.sheetsRender(root, stack("ask"));
assert.equal(kids(root).length, 1);
assert.ok(byId(root, "name-in"), "the prompt did not render");
V.sheetsRender(root, "[]");
assert.equal(kids(root).length, 0, "the overlay is still in the DOM");
assert.equal(byId(root, "name-in"), null);
assert.equal(root.textContent, "", "the overlay left text behind");
} finally {
restore();
}
});
test("the fill hooks fire once per open — on mount, never on a re-render", () => {
// The `:rev` lesson from Phase 5c, in its other direction. These two hooks
// write a SECRET onto a field, so a hook that fires on every frame would
// overwrite what the user typed; a hook that never fires again after a
// re-open would leave the previous open's text in the field.
const restore = installMiniDom();
try {
const root = makeRoot();
V.sheetsHooks(); // whatever an earlier test left behind
V.sheetsRender(root, stack("identity"));
assert.deepEqual(hookOps(), [":fill-seed"], "the seed must be filled on mount");
V.sheetsRender(root, stack("identity"));
assert.deepEqual(hookOps(), [], "an identical render must not re-fill");
V.sheetsRender(root, stack("identity-revealed"));
assert.deepEqual(hookOps(), [], "revealing the field must not re-fill it");
V.sheetsRender(root, stack("identity-import-armed"));
assert.deepEqual(hookOps(), [], "arming a button must not re-fill it");
V.sheetsRender(root, "[]");
V.sheetsRender(root, stack("identity"));
assert.deepEqual(hookOps(), [":fill-seed"], "re-opening the card must fill it again");
V.sheetsRender(root, "[]");
V.sheetsRender(root, stack("copy"));
assert.deepEqual(hookOps(), [":fill-copy"], "the manual-copy field must be filled on mount");
V.sheetsRender(root, "[]");
V.sheetsRender(root, stack("ask"));
assert.deepEqual(hookOps(), [":focus-soon"], "the name field must be focused on mount");
} finally {
restore();
}
});
test("revealing the identity key does not wipe the field the hook filled", () => {
// `reveal` changes that input's `type`, which replicant reconciles in place —
// the element, and the `.value` the mount hook put on it, must survive.
const restore = installMiniDom();
try {
const root = makeRoot();
V.sheetsRender(root, stack("identity"));
const pw = byAttr(root, "autocomplete", "new-password");
assert.ok(pw);
pw.value = "the-seed-goes-here"; // stand in for what [:fill-seed] does
assert.equal(pw.getAttribute("type"), "password");
V.sheetsRender(root, stack("identity-revealed"));
assert.equal(byAttr(root, "autocomplete", "new-password"), pw, "the field was rebuilt");
assert.equal(pw.getAttribute("type"), "text", "revealing did nothing");
assert.equal(pw.value, "the-seed-goes-here", "revealing wiped the key");
} finally {
restore();
}
});
test("dropping the conflict chooser leaves every sibling below it in place", () => {
// The one nil transition in these views: `(when conflict? …)` becomes nil
// when the user keeps the local identity. replicant keeps nils positionally,
// which is what stops the six children below it shifting up by one.
const restore = installMiniDom();
try {
const root = makeRoot();
V.sheetsRender(root, stack("identity-conflict"));
const save = byId(root, "id-save");
const rename = byId(root, "id-rename");
assert.ok(byId(root, "id-adopt") && save && rename);
V.sheetsRender(root, stack("conflict-kept"));
assert.equal(byId(root, "id-adopt"), null, "the chooser is still there");
assert.equal(byId(root, "conf-fp"), null);
assert.equal(byId(root, "id-save"), save, "the save form was rebuilt");
assert.equal(byId(root, "id-rename"), rename, "the rename form was rebuilt");
assert.equal(byId(root, "sheet-close").textContent, "close");
} finally {
restore();
}
});
test("pushing the copy fallback keeps the identity card underneath it", () => {
// Keyed by kind, so the card below is MOVED-over rather than rebuilt — which
// is what makes closing the fallback give the card back with its filled
// fields intact rather than a fresh one.
const restore = installMiniDom();
try {
const root = makeRoot();
V.sheetsRender(root, stack("identity"));
const save = byId(root, "id-save");
V.sheetsRender(root, stack("identity+copy"));
assert.equal(kids(root).length, 2, "the fallback did not stack");
assert.equal(byId(root, "id-save"), save, "the card underneath was rebuilt");
assert.ok(byId(root, "sheet-close"));
V.sheetsRender(root, stack("identity"));
assert.equal(kids(root).length, 1, "the fallback did not close");
assert.equal(byId(root, "id-save"), save, "closing the fallback rebuilt the card");
} finally {
restore();
}
});
|