chat / client / test / lobby-view.test.mjs
  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
/**
 * The first UI golden vector in this repo — the lobby (Phase 5a).
 *
 * The premise the whole of Phase 5 rests on: a screen can be pinned by a
 * fixture in `node --test`, with no browser, no jsdom and no Playwright,
 * because `sueta.app.lobby/view` is a pure function from state to hiccup and
 * hiccup is DATA. It stays data only because the event handlers in it are data
 * too — `{:on {:click [:open "aaa"]}}`, never a closure. A snapshot full of
 * `#object[Function]` cannot tell two states apart, and that is exactly what
 * disqualified reagent when the renderer was chosen (dev/docs/CLJS.md).
 *
 * WHY THIS FILE IS LONGER THAN "assert it matches the fixture".
 *
 * This project has produced seven gates that reported success while testing
 * nothing, so a UI vector has to earn its green. A snapshot test is worthless
 * unless the snapshot CHANGES when the state changes, and "it is non-empty" is
 * not that. So the DISCRIMINATION block below is the load-bearing half: pairs
 * of states that must produce different snapshots, including the two the whole
 * lobby exists to get right —
 *
 *   · which row's forget button is armed, and
 *   · which row is being renamed
 *
 * — checked BOTH between "none" and "one", and between "row A" and "row B".
 * The second form is the one that catches a renderer, or a fixture, that
 * flattens per-row state into something the snapshot cannot see. Those
 * assertions do not read the fixture at all: they would still fail if
 * lobby-view.json were deleted and re-recorded from a broken view.
 *
 * NEVER REGENERATE test/vectors/lobby-view.json. There is no generator script
 * and there must not be one — the fixture was produced once, read line by line
 * against the screen it describes, and committed. A snapshot refreshed from the
 * implementation it is meant to constrain records the bug instead of catching
 * it. Change the screen on purpose → hand-edit the fixture, reading every moved
 * line as you go, exactly as test/helpers/facade.mjs prescribes for the façade.
 *
 * Determinism: the view is TOTAL over its state — `:now` is a field, not a call
 * to `js/Date.now`, so "2h ago" is pinned rather than sampled. The build is
 * :viewlib (shadow-cljs.edn), whose deps are exact-pinned, so attribute order
 * in the rendered markup is fixed too.
 */
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("lobby-view");
const S = VEC.states;

// ---- the fixture -----------------------------------------------------------

test("init narrows a stored room list to what the screen shows", () => {
  assert.equal(V.lobbyInit(VEC.init.rooms, VEC.init.now), VEC.init.state);
});

for (const [name, state] of Object.entries(S)) {
  test(`hiccup snapshot: ${name}`, () => {
    assert.equal(V.lobbyView(state), VEC.hiccup[name]);
  });
}

for (const [name, html] of Object.entries(VEC.html)) {
  test(`markup snapshot: ${name}`, () => {
    assert.equal(V.lobbyHtml(S[name]), html);
  });
}

for (const [i, [stateName, event, expected]] of VEC.steps.entries()) {
  test(`step ${i}: ${stateName} ${event}`, () => {
    assert.equal(V.lobbyStep(S[stateName], event), expected);
  });
}

// ---- discrimination: the snapshot must be able to SEE the state ------------

const differ = (a, b, why) => assert.notEqual(V.lobbyView(S[a]), V.lobbyView(S[b]), why);

test("the snapshot distinguishes armed from not-armed", () => {
  differ("list", "armed-b2", "arming a forget button must move the snapshot");
});

test("the snapshot distinguishes WHICH row is armed", () => {
  differ("armed-b1", "armed-b2", "per-row state must survive into the snapshot");
});

test("the snapshot distinguishes a row being renamed", () => {
  differ("list", "renaming-b2", "an in-place rename must move the snapshot");
});

test("the snapshot distinguishes WHICH row is being renamed", () => {
  // The exact case that measured reagent's opaque-subcomponent limitation.
  differ("renaming-b1", "renaming-b2", "per-row state must survive into the snapshot");
});

test("the snapshot distinguishes a rejected invite link", () => {
  differ("list", "join-bad", "the bad-link placeholder must move the snapshot");
});

test("the snapshot distinguishes an empty room list", () => {
  differ("empty", "list");
});

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` would fail here even though the checks
  // above (which re-render) still passed.
  assert.notEqual(VEC.hiccup["armed-b1"], VEC.hiccup["armed-b2"]);
  assert.notEqual(VEC.hiccup["renaming-b1"], VEC.hiccup["renaming-b2"]);
  assert.notEqual(VEC.hiccup.list, VEC.hiccup["join-bad"]);
});

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 and EVERY life-cycle hook in
  // the hiccup is data. One inline `(fn …)` — and the first draft of the rename
  // row had exactly one, for `.focus` — prints as `#object[Function]`, and from
  // then on the snapshot cannot see whatever that closure decides. Cheap to
  // check, and it fails the moment someone reaches for a closure again.
  for (const [name, state] of Object.entries(S)) {
    assert.ok(!V.lobbyView(state).includes("#object"), `${name} carries an opaque function`);
    assert.ok(!VEC.hiccup[name].includes("#object"), `the ${name} fixture records one`);
  }
});

test("the snapshot is not vacuous", () => {
  const out = V.lobbyView(S.list);
  for (const fragment of [
    '[:span.room-label "kitchen"]',   // content reached the tree
    '{:click [:open "b1"]}',          // and so did the intent behind a button
    ':replicant/key "b1"',            // rows are keyed, or a re-order re-creates them
    '[:input#join-link',              // the e2e suite fills this by id
    '[:button#new-room.primary',      // and clicks this one
  ]) {
    assert.ok(out.includes(fragment), `missing from the snapshot: ${fragment}`);
  }
});

// ---- XSS is structural, not diligent ---------------------------------------

test("a hostile room label reaches the view as TEXT, never as markup", () => {
  // The label is attacker-supplied in the only sense that matters: room lists
  // sync between a person's devices, and app/rooms.cljs sanitises length and
  // control characters but not angle brackets — deliberately, because it never
  // had to. `esc` was the thing that made that safe, and `esc` is gone.
  const hiccup = V.lobbyView(S.xss);
  const label = VEC.hostileLabel;

  // In the hiccup the label is a plain string in CHILD position. That is what
  // makes replicant emit a text node for it, and it is why deleting `esc` was
  // not a regression: there is no code path left that could concatenate it.
  assert.ok(hiccup.includes(`[:span.room-label ${JSON.stringify(label)}]`));
  assert.ok(!hiccup.includes(":innerHTML"), "no hiccup node may carry raw HTML");

  // And rendered, it is escaped rather than parsed. Note what this does and
  // does not prove: `replicant.string` is a TEST path, so this pins that the
  // label is treated as text, not that the shipping path escapes it — the
  // shipping path does not escape anything, it calls createTextNode, which is
  // the stronger property and the reason `esc` could be deleted at all.
  // (`replicant.string` also maps `"` to `'` rather than `"`, an
  // inherited hiccup quirk; harmless here, and another reason not to read this
  // renderer as the security boundary.)
  const html = V.lobbyHtml(S.xss);
  assert.ok(!html.includes("<img"), `the label was rendered as markup: ${html}`);
  assert.ok(html.includes("&lt;img src=x onerror=alert(1)&gt;"), "the label must appear escaped");
  assert.equal(html.match(/<span/g).length, 2, "the label must not have added elements");
});

// ---- the handle registry (the rule Phase 5c depends on) --------------------

test("a non-serialisable handle round-trips through the registry, never the view", () => {
  const stream = { fake: "MediaStream" };
  const rev0 = V.handleRevision();

  assert.equal(V.handleHook("peer-1", {}), null, "no handle yet → the hook is silent");

  const rev1 = V.putHandle("peer-1", stream);
  assert.ok(rev1 > rev0, "parking a handle must bump the revision");
  assert.equal(V.getHandle("peer-1"), stream);

  const node = {};
  assert.deepEqual(V.handleHook("peer-1", node), [node, stream]);

  assert.equal(V.putHandle("peer-1", stream), rev1, "re-parking the same object is not a change");

  const rev2 = V.forgetHandle("peer-1");
  assert.ok(rev2 > rev1, "dropping a handle must bump the revision");
  assert.equal(V.getHandle("peer-1"), null);
  assert.equal(V.forgetHandle("peer-1"), rev2, "dropping nothing is not a change");
});

static mirror of HEAD · about · clone: git clone https://git.ardegazu.ro/chat.git