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 | /**
* The message list, the thread panel and the composer (Phase 5b) — the second
* UI golden vector in this repo, and the one over the screen the app IS.
*
* Same premise as test/lobby-view.test.mjs: `sueta.app.msgs/view` is a pure
* function from state to hiccup and hiccup is DATA, so a screen can be pinned
* by a fixture in `node --test` with no browser, no jsdom and no Playwright.
* It stays data only because every handler in it is data too —
* `{:on {:click [:react "m1" "🔥"]}}`, never a closure.
*
* WHAT THIS FILE ADDS OVER THE LOBBY'S, and why it is longer.
*
* The lobby had one pure function. This screen has two, and the boundary
* between them is where the bugs would live:
*
* `project` reads the store's JS message objects and answers Clojure data.
* Ordering, the thread/root split, the reply tally, reaction
* tallies, the blob lookup by CID and the Intl time all happen
* HERE, and `states` in the fixture is exactly what it answered.
* `view` turns that value into hiccup. The badge, the chip's `on`, the
* avatar and the action sheet are all derived HERE, from state
* that says nothing about them — which is the whole reason
* ticking "verified" is now one update instead of forty.
*
* So the DISCRIMINATION block below has to prove BOTH halves can be seen. It
* checks nine pairs of states that must render differently, including the
* three the screen exists to get right —
*
* · whether an author is verified, unverified, key-changed, or still
* resolving (four states, three of them a strict superset of the last)
* · WHICH row's long-press sheet is open, and in WHICH of the two lists
* · whether a reaction chip is mine
*
* — and none of those assertions read the fixture at all: they re-render and
* compare, so they would still fail if msgs-view.json were deleted and
* re-recorded from a broken view.
*
* NEVER REGENERATE test/vectors/msgs-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 { readVector } from "./helpers/load.mjs";
installDom();
const V = await import("../view-dist/viewlib.js");
const VEC = readVector("msgs-view");
const S = VEC.states;
const msgsFor = (name) => (VEC.cases[name].messages === "all" ? VEC.messages : []);
// ---- the fixture -----------------------------------------------------------
for (const name of Object.keys(VEC.cases)) {
test(`project: ${name}`, () => {
assert.equal(V.msgsProject(msgsFor(name), VEC.cases[name].ctx), S[name]);
});
}
for (const [name, state] of Object.entries(S)) {
test(`hiccup snapshot: ${name}`, () => {
assert.equal(V.msgsView(state), VEC.hiccup[name]);
});
}
for (const [name, html] of Object.entries(VEC.html)) {
test(`markup snapshot: ${name}`, () => {
assert.equal(V.msgsHtml(S[name]), html);
});
}
test("the thread panel renders exactly when a thread is open", () => {
for (const [name, state] of Object.entries(S)) {
const got = V.msgsThreadView(state);
if (name in VEC.thread) {
assert.equal(got, VEC.thread[name], name);
assert.equal(V.msgsThreadHtml(state), VEC.threadHtml[name], name);
} else {
// no `:thread` in the state ⇒ nil, which is what `replicant.dom/render`
// takes as "this container has no children"
assert.equal(got, "nil", `${name} rendered a thread panel it has no thread for`);
}
}
});
test("the composer, both of them", () => {
assert.equal(V.msgsComposer(false), VEC.composer.room);
assert.equal(V.msgsComposer(true), VEC.composer.thread);
assert.equal(V.msgsComposerHtml(false), VEC.composerHtml.room);
assert.equal(V.msgsComposerHtml(true), VEC.composerHtml.thread);
});
test("the link splitter", () => {
for (const [text, expected] of Object.entries(VEC.textRuns)) {
assert.equal(V.msgsTextRuns(text), expected, JSON.stringify(text));
}
});
test("the author colour hash", () => {
for (const [author, expected] of Object.entries(VEC.colors)) {
assert.equal(V.msgsColorFor(author), expected, author);
}
});
// ---- discrimination: the snapshot must be able to SEE the state ------------
const differ = (a, b, why) => assert.notEqual(V.msgsView(S[a]), V.msgsView(S[b]), why);
test("the snapshot distinguishes an empty room from a full one", () => {
differ("list", "empty", "the empty-room hint must move the snapshot");
});
test("the empty-room hint depends on whether anybody else is here", () => {
// Both have nothing to show; only one of them says so. This is the pair the
// original `rows.length === 0 && peers.size === 0` guard decides, and it is
// invisible to any assertion that only checks a non-empty render.
differ("empty", "quiet", "the hint must not appear while a peer is in the room");
});
test("the snapshot distinguishes a verified author", () => {
differ("list", "unverified", "a verified tick must move the snapshot");
});
test("the snapshot distinguishes a key change from a verification", () => {
differ("unverified", "key-changed", "the TOFU alarm must move the snapshot");
differ("list", "key-changed", "…and it must not be confused with the tick");
});
test("the snapshot distinguishes an authorship badge that has not resolved", () => {
// `note-author!` claims the pub synchronously and fills the fingerprint in
// when the derivation lands, so "known author, no fingerprint yet" is a real
// state on every room boot — and it must render as no badge, not as `undefined`.
differ("list", "pending", "an unresolved fingerprint must move the snapshot");
});
test("the snapshot distinguishes WHICH row's action sheet is open", () => {
// The case that measured reagent's opaque-subcomponent limitation in the
// spike, restated for this screen: per-row state must survive into the tree.
differ("actions-m1", "actions-m2", "per-row state must survive into the snapshot");
differ("list", "actions-m1", "opening the sheet at all must move the snapshot");
});
test("the snapshot distinguishes WHICH LIST the action sheet is open in", () => {
// `:actions-for` is [id in-thread?], so the same message showing in both the
// room list and the thread panel cannot sprout two sheets.
differ("actions-m1", "actions-m1-thread", "the sheet belongs to one list, not one id");
assert.notEqual(
V.msgsThreadView(S["actions-m1-thread"]),
V.msgsThreadView(S["thread-m1"]),
"and the thread panel must be able to see its own sheet",
);
});
test("the snapshot distinguishes a reaction chip that is mine", () => {
differ("list", "not-mine-chip", "`on` must be derived from :self-author, not baked in");
});
test("the snapshot distinguishes an image whose blob is gone", () => {
differ("list", "blob-revoked", "a revoked blob URL must move the snapshot");
});
test("the snapshot distinguishes a chosen accent from the hashed one", () => {
differ("list", "no-accents", "hue/glyph must reach the avatar");
});
test("the thread panel distinguishes a missing root", () => {
assert.notEqual(
V.msgsThreadView(S["thread-m1"]),
V.msgsThreadView(S["thread-gone"]),
"a thread whose root has not replicated yet renders without it",
);
});
test("the recorded fixture is itself discriminating", () => {
// Not a tautology: this reads the COMMITTED snapshots, so a fixture recorded
// from a view that ignored `:trust` or `:actions-for` would fail here even
// though the checks above (which re-render) still passed.
const pairs = [
["list", "unverified"],
["unverified", "key-changed"],
["list", "pending"],
["actions-m1", "actions-m2"],
["actions-m1", "actions-m1-thread"],
["list", "not-mine-chip"],
["list", "blob-revoked"],
["list", "no-accents"],
["empty", "quiet"],
];
for (const [a, b] of pairs) assert.notEqual(VEC.hiccup[a], VEC.hiccup[b], `${a} vs ${b}`);
assert.notEqual(VEC.thread["thread-m1"], VEC.thread["thread-gone"]);
});
// ---- structural properties -------------------------------------------------
test("no snapshot contains an opaque function", () => {
// The single most important structural property of this view layer, and the
// one the renderer was chosen for: EVERY handler in the hiccup is data. One
// inline `(fn …)` prints as `#object[Function]`, and from then on the
// snapshot cannot see whatever that closure decides — which on THIS screen
// would be every react, every thread open and every send.
const all = [
...Object.values(S).map((s) => V.msgsView(s)),
...Object.values(S).map((s) => V.msgsThreadView(s)),
V.msgsComposer(false),
V.msgsComposer(true),
...Object.values(VEC.hiccup),
...Object.values(VEC.thread),
VEC.composer.room,
VEC.composer.thread,
];
for (const out of all) assert.ok(!out.includes("#object"), `an opaque function: ${out.slice(0, 120)}`);
});
test("every row is keyed, in both lists", () => {
// A list without keys re-creates every node on any insert, which on this
// screen means losing the scroll anchor and every hover state on each frame.
const keys = (edn) => [...edn.matchAll(/:replicant\/key "([^"]+)"/g)].map((m) => m[1]);
const rowKeys = (edn) => keys(edn).filter((k) => k.startsWith("m"));
// re-rendered, so this is a check on the CODE…
assert.deepEqual(rowKeys(V.msgsView(S.list)), ["m1", "m2", "m3", "m4", "m5", "m6", "m8", "m9"]);
// m7 is a reply to m1 and belongs to the thread panel, not the room list;
// m8's thread root does not exist, so it stays in the room list
assert.deepEqual(rowKeys(V.msgsThreadView(S["thread-m1"])), ["m1", "m7"]);
// …and these two are a check on the FIXTURE, which a re-render cannot make
assert.deepEqual(rowKeys(VEC.hiccup.list), ["m1", "m2", "m3", "m4", "m5", "m6", "m8", "m9"]);
assert.deepEqual(rowKeys(VEC.thread["thread-m1"]), ["m1", "m7"]);
});
test("the composer never emits a value, and never an autofocus", () => {
// THE rule for an uncontrolled zone. Replicant writes `.value` whenever that
// attribute changes, and writing it — even the identical string — moves the
// caret in some browsers. The textarea's content belongs to whoever is
// typing in it; app/ui clears it on the node after a send.
for (const out of [V.msgsComposer(false), V.msgsComposer(true)]) {
assert.ok(!out.includes(":value"), `the composer carries a :value: ${out}`);
assert.ok(!out.includes(":autofocus"), "the composer must not steal focus on every render");
}
// …and the ids the e2e suite and app/ui both address it by
for (const id of ["input", "send-btn", "attach-btn", "file-in"]) {
assert.ok(V.msgsComposer(false).includes(`"${id}"`), `missing #${id}`);
assert.ok(V.msgsComposer(true).includes(`"thread-${id}"`), `missing #thread-${id}`);
}
});
test("the snapshot is not vacuous", () => {
const out = V.msgsView(S.list);
for (const fragment of [
'[:span.msg-name "ana"]', // content reached the tree
'{:click [:react "m1" "🔥"]}', // and so did the intent behind a chip
':replicant/key "m1"', // rows are keyed
"[:div.msg-bubble", // the e2e suite reads this by class
"[:div.img-box", // …and waits on this one
"[:button.thread-btn", // …and this one
"[:button.hb", // …and clicks these
':href "https://ardegazu.ro/x"', // a link became a link
]) {
assert.ok(out.includes(fragment), `missing from the snapshot: ${fragment}`);
}
});
// ---- XSS is structural, not diligent ---------------------------------------
test("a hostile name and a hostile message reach the view as TEXT", () => {
// Both are attacker-supplied in the only sense that matters: a message's
// `name` and `text` come off the replicated log, and app/chat clamps their
// LENGTH but not their content — deliberately, because it never had to.
// `esc` was the thing that made that safe on this screen, and `esc` is gone
// from every path this file covers.
const hiccup = V.msgsView(S.list);
assert.ok(hiccup.includes(`[:span.msg-name ${JSON.stringify(VEC.hostileName)}]`));
assert.ok(hiccup.includes(JSON.stringify(VEC.hostileText)));
assert.ok(!hiccup.includes(":innerHTML"), "no hiccup node may carry raw HTML");
// Rendered, both are escaped rather than parsed. Note what this does and
// does not prove: `replicant.string` is a TEST path, so it pins that they are
// treated as text, not that the shipping path escapes them — the shipping
// path does not escape anything, it calls createTextNode, which is the
// stronger property and the reason `esc` could go.
const html = V.msgsHtml(S.list);
assert.ok(!html.includes("<img src=x"), "the name was rendered as markup");
assert.ok(!html.includes("<script>"), "the message was rendered as markup");
assert.ok(html.includes("<img src=x onerror=alert(1)>"));
assert.ok(html.includes("<script>alert(1)</script>"));
// a URL in the text becomes an <a>, and only ever an <a>
assert.equal((html.match(/<a /g) ?? []).length, 2);
assert.ok(!V.msgsHtml(S.list).includes("javascript:"));
});
test("markup in a message is a link's TEXT, never its structure", () => {
// The one place this screen builds an element out of message content. The
// URL is used twice — as `href` and as the visible text — so a `"` in it
// would be the classic attribute break-out if anything concatenated.
const runs = V.msgsTextRuns('https://evil.example/"><script>x</script>');
assert.ok(runs.includes(":href"), "it is still parsed as a link");
assert.ok(!runs.includes("#object"));
// the regex stops at the quote, which is what keeps the href a single token
assert.ok(runs.includes('"https://evil.example/'));
});
|