chat / client / test / tiles-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
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
/**
 * The call tiles (Phase 5c) — the third UI golden vector in this repo, and the
 * one over the screen a vdom is supposed to be bad at.
 *
 * `ui.cljs/update-call` was ~90 lines of hand-written reconciler: a
 * `Map<id, {root, video}>` of live element records, a `want` Map beside it, a
 * delete pass, a create pass and a mutate-in-place pass. It existed because a
 * <video>'s `srcObject` is a live MediaStream — DOM IDENTITY, not content.
 * Rebuild the element and the picture flickers; detach and reattach it and the
 * picture flickers.
 *
 * So this file has to prove three separate things, and only the first is the
 * kind of thing a snapshot test usually proves:
 *
 *  1. THE VIEW IS A VALUE. `project` reads CallManager's JS roster; `view` is
 *     state -> hiccup; both are pinned by test/vectors/tiles-view.json, and the
 *     discrimination block below re-renders rather than reading the fixture, so
 *     it would still fail against a fixture recorded from a broken view.
 *
 *  2. THE STREAM MECHANISM WORKS. No MediaStream is in the view data at all —
 *     they live in app/view's handle registry keyed by tile id, and the view
 *     carries only the id and `:rev`, the registry's revision. That integer is
 *     load-bearing: replicant SKIPS a node whose hiccup is unchanged
 *     (`replicant.core/reconcile*`'s first clause), so without something
 *     changing, a stream arriving after its tile was drawn would never be
 *     attached and the tile would stay black. The hook-firing block asks the
 *     real renderer whether that is so.
 *
 *  3. A KEYED TILE KEEPS ITS ELEMENT. Through a re-render AND a re-order. The
 *     spike asked jsdom this; this asks a model DOM (harness/minidom.mjs
 *     explains why, and what that costs), and it asks the same questions of an
 *     UNKEYED render as a control — which must fail them, in exactly the shape
 *     the bug would take: the tile in position 1 says "bogdan" while the
 *     <video> in it is still showing ana.
 *
 * NEVER REGENERATE test/vectors/tiles-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, kids, firstTag } from "./harness/minidom.mjs";
import { readVector } from "./helpers/load.mjs";

installDom();
const V = await import("../view-dist/viewlib.js");

const VEC = readVector("tiles-view");
const S = VEC.states;

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

for (const [name, c] of Object.entries(VEC.cases)) {
  test(`project: ${name}`, () => {
    assert.equal(V.tilesProject(c.selfState, c.members, c.ctx), S[name]);
  });
}

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

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

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

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

test("the snapshot distinguishes a call nobody is in from one somebody is in", () => {
  // `#callbar` and `#tiles` are shown for BOTH — a call you can join is the
  // whole reason the bar exists — so "no tiles" and "a tile" is the difference.
  differ("idle", "watching", "a member's tile must appear before we join");
});

test("the snapshot distinguishes being in the call from watching it", () => {
  differ("watching", "duo", "joining must add a self tile");
  differ("idle", "solo", "…even when nobody else is there");
});

test("the snapshot distinguishes a muted microphone", () => {
  differ("duo", "duo-self-muted", "the 🔇 on the self label must move the snapshot");
  differ("duo", "duo-peer-muted", "…and so must a peer's");
});

test("the snapshot distinguishes a camera that is on", () => {
  // `.novideo` is what makes CSS show the avatar over the (blank) video, so
  // this is the difference between a face and an initial.
  differ("duo", "duo-self-cam", "our own camera must move the snapshot");
  differ("duo", "duo-peer-video", "…and so must a peer's");
});

test("the snapshot distinguishes an enlarged tile", () => {
  // `.big` used to be a `classList.toggle` on the node. Under a renderer that
  // owns those classes, that is state or it is nothing.
  differ("duo", "duo-big", "tapping a tile must be visible in the tree");
});

test("the snapshot distinguishes a chosen accent from the hashed one", () => {
  differ("duo", "duo-no-accents", "hue/glyph must reach the tile avatar");
});

test("the snapshot distinguishes A NEW HANDLE REVISION", () => {
  // THE case this whole design turns on. Two states with identical tiles and a
  // different `:rev`: if the snapshot cannot tell them apart, then neither can
  // replicant, and a MediaStream that arrives after its tile was drawn is never
  // attached — a permanently black tile with no error anywhere.
  differ("duo", "duo-rev", "a stream arriving must change the hook's data");
  differ("trio", "trio-rev", "…for every tile, not only the newest");
});

test("the snapshot distinguishes a re-ordered roster", () => {
  differ("trio", "trio-reordered", "the tile order must survive into the tree");
});

test("the recorded fixture is itself discriminating", () => {
  // Not a tautology: this reads the COMMITTED snapshots, so a fixture recorded
  // from a view that ignored `:rev` or `:big` would fail here even though the
  // checks above (which re-render) still passed.
  const pairs = [
    ["idle", "watching"],
    ["watching", "duo"],
    ["duo", "duo-self-muted"],
    ["duo", "duo-peer-muted"],
    ["duo", "duo-self-cam"],
    ["duo", "duo-peer-video"],
    ["duo", "duo-big"],
    ["duo", "duo-no-accents"],
    ["duo", "duo-rev"],
    ["trio", "trio-rev"],
    ["trio", "trio-reordered"],
  ];
  for (const [a, b] of pairs) assert.notEqual(VEC.hiccup[a], VEC.hiccup[b], `${a} vs ${b}`);
});

// ---- the callbar's inputs, which never reach the hiccup --------------------

test("the projection answers the callbar, whose nodes are not rendered here", () => {
  // `#callbar`, `#call-info` and the four buttons are still imperative markup
  // in app/ui (they are addressed by id from e2e/call.e2e.mjs). Their inputs
  // are derived HERE, so they are testable even though their nodes are not.
  const bar = (n) => {
    const s = S[n];
    return ["in-call?", "any-call?", "count", "mic-on?", "cam-on?"]
      .map((k) => new RegExp(`:${k.replace("?", "\\?")} ([^,}]+)`).exec(s)[1])
      .join(" ");
  };
  assert.equal(bar("idle"), "false false 0 false false");
  assert.equal(bar("watching"), "false true 1 false false");
  assert.equal(bar("solo"), "true true 1 true false");
  assert.equal(bar("duo"), "true true 2 true false");
  assert.equal(bar("duo-self-muted"), "true true 2 false false");
  assert.equal(bar("duo-self-cam"), "true true 2 true true");
  assert.equal(bar("trio"), "true true 3 true false");
});

// ---- structural properties -------------------------------------------------

test("no snapshot contains an opaque function", () => {
  // The property the renderer was chosen for, and on THIS screen it is not a
  // style point: the stream attachment IS a life-cycle hook, so an inline
  // `(fn …)` would print as `#object[Function]` and hide the entire mechanism
  // from every assertion above.
  const all = [...Object.values(S).map((s) => V.tilesView(s)), ...Object.values(VEC.hiccup)];
  for (const out of all) assert.ok(!out.includes("#object"), `an opaque function: ${out.slice(0, 140)}`);
});

test("every tile is keyed, self first, in roster order", () => {
  const keys = (edn) => [...edn.matchAll(/:replicant\/key "([^"]+)"/g)].map((m) => m[1]);
  // a check on the CODE…
  assert.deepEqual(keys(V.tilesView(S.trio)), ["self", "12D3KooWA", "12D3KooWB"]);
  assert.deepEqual(keys(V.tilesView(S["trio-reordered"])), ["self", "12D3KooWB", "12D3KooWA"]);
  assert.deepEqual(keys(V.tilesView(S.watching)), ["12D3KooWA"]);
  assert.deepEqual(keys(V.tilesView(S.idle)), []);
  // …and on the FIXTURE, which a re-render cannot make
  assert.deepEqual(keys(VEC.hiccup.trio), ["self", "12D3KooWA", "12D3KooWB"]);
});

test("only the self tile is muted and mirrored", () => {
  // An unmuted self tile is an audio feedback loop, and a mirrored peer is a
  // peer holding their coffee in the wrong hand. Both are per-tile facts that a
  // shared template gets wrong by omission.
  const out = V.tilesView(S.duo);
  const [self, peer] = out.split("[:div.tile ").slice(1);
  for (const bit of [':muted "muted"', ':class "mirror"']) {
    assert.ok(self.includes(bit), `the self tile is missing ${bit}`);
    assert.ok(!peer.includes(bit), `a peer's tile carries ${bit}`);
  }
  // and both are still autoplaying inline, which is what makes iOS play at all
  for (const tile of [self, peer]) {
    assert.ok(tile.includes(':autoplay "autoplay"'));
    assert.ok(tile.includes(':playsinline "playsinline"'));
  }
});

test("the stream hook is data, and carries exactly what the runtime needs", () => {
  // [op tile-id revision self?]. The id resolves against app/view's registry,
  // the revision is what makes the node change so the hook fires at all, and
  // `self?` is the mute the runtime sets as a property.
  assert.ok(V.tilesView(S.duo).includes(':replicant/on-render [:tile-stream "self" 0 true]'));
  assert.ok(V.tilesView(S.duo).includes(':replicant/on-render [:tile-stream "12D3KooWA" 0 false]'));
  assert.ok(V.tilesView(S["duo-rev"]).includes(':replicant/on-render [:tile-stream "self" 1 true]'));
});

test("no MediaStream, and nothing else unprintable, is anywhere in the state", () => {
  // The rule the renderer decision came with (dev/docs/CLJS.md): non-
  // serialisable handles never enter view data. If one ever did, it would
  // print as an object here — and the whole fixture would become unwritable.
  for (const s of Object.values(S)) {
    assert.ok(!s.includes("#object"), s.slice(0, 140));
    assert.ok(!s.includes("srcObject"), s.slice(0, 140));
    assert.ok(!s.includes("stream"), s.slice(0, 140));
  }
});

test("the snapshot is not vacuous", () => {
  const out = V.tilesView(S.trio);
  for (const fragment of [
    "[:div.tile ", //                    e2e/call.e2e.mjs reads this by class
    "[:video ", //                       …and waits on `.tile video` playing
    '[:div.tile-label "ana"]', //        …and reads member names out of these
    "[:div.tile-avatar ", //             the no-camera fallback
    ':on {:click [:tile-zoom "self"]}', // tapping a tile enlarges it
  ]) {
    assert.ok(out.includes(fragment), `missing from the snapshot: ${fragment}`);
  }
  // e2e/call.e2e.mjs waits for a `.tile-label` containing 🔇 and for a
  // `.tile.novideo .tile-label` — both of those live in these two states
  assert.ok(V.tilesView(S["duo-self-muted"]).includes('[:div.tile-label "🔇 iulia (you)"]'));
  assert.ok(V.tilesView(S["duo-peer-muted"]).includes('[:div.tile-label "🔇 ana"]'));
  assert.ok(V.tilesView(S.duo).includes(':class ["novideo"]'));
});

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

test("a hostile display name reaches the tile as TEXT", () => {
  // A call member's `name` comes off the wire (CallManager slices it to 40
  // chars and no further). This screen never had an `esc` — it built nodes and
  // set textContent — so what is proven here is that the conversion did not
  // introduce the sink the other screens are busy deleting.
  const hiccup = V.tilesView(S.hostile);
  assert.ok(hiccup.includes(JSON.stringify(VEC.cases.hostile.members[0][1].name)));
  assert.ok(!hiccup.includes(":innerHTML"), "no hiccup node may carry raw HTML");
  const html = V.tilesHtml(S.hostile);
  assert.ok(!html.includes("<img src=x"), "the name was rendered as markup");
  assert.ok(html.includes("&lt;img src=x onerror=alert(1)&gt;"));
  // the avatar takes its initial from the same string, so it is a sink too
  assert.ok(html.includes(">&lt;</div>"), "the avatar initial was not escaped");
});

// ---- does the real renderer keep the element? ------------------------------
//
// Everything above renders to a string. These two drive `replicant.dom` against
// harness/minidom.mjs, because "replicant keys it, so it is fine" is an
// assumption until something asks.

const videosOf = (root) => kids(root).map((tile) => firstTag(tile, "video"));
const labelsOf = (root) => kids(root).map((tile) => kids(tile).at(-1).textContent);

const probe = (keyed) => {
  const root = makeRoot();
  V.tilesRender(root, S.trio, keyed);
  V.tilesHooks(); // drain the mount hooks
  const before = videosOf(root);
  // stand in for what the :tile-stream hook does: park a live object on the
  // element. Distinct per peer, so a mix-up is legible rather than merely unequal.
  before.forEach((v, i) => {
    v.srcObject = { peer: ["self", "ana", "bogdan"][i] };
  });

  // a re-render that changes one attribute: bogdan mutes his microphone
  V.tilesRender(root, S["trio-b-muted"], keyed);
  const afterRerender = videosOf(root);
  const mutedLabel = labelsOf(root)[2];

  // …and then the roster re-orders: bogdan ahead of ana
  V.tilesRender(root, S["trio-reordered"], keyed);
  V.tilesHooks(); // this probe is not about the hooks; leave the log clean
  return {
    identityKeptAcrossRerender: afterRerender.every((v, i) => v === before[i]),
    attrUpdatedInPlace: mutedLabel,
    order: labelsOf(root),
    streamsInPosition: videosOf(root).map((v) => v.srcObject?.peer),
    nodesAreTheSameObjects: videosOf(root).every((v) => before.includes(v)),
  };
};

test("a KEYED tile keeps its <video> across a re-render and a re-order", () => {
  const restore = installMiniDom();
  try {
    const r = probe(true);
    assert.equal(r.identityKeptAcrossRerender, true, "a re-render must not rebuild the element");
    assert.equal(r.attrUpdatedInPlace, "🔇 bogdan", "the label must be updated in place");
    // `trio-reordered` has bogdan unmuted again, so the 🔇 goes as the order
    // changes — which is itself the attribute-update half, asserted once more
    assert.deepEqual(r.order, ["iulia (you)", "bogdan", "ana"], "the tiles must re-order");
    // THE assertion. The stream follows the KEY: bogdan's tile moved, and the
    // element carrying his picture moved with it.
    assert.deepEqual(r.streamsInPosition, ["self", "bogdan", "ana"]);
    assert.equal(r.nodesAreTheSameObjects, true, "the re-order must MOVE elements, not rebuild them");
  } finally {
    restore();
  }
});

test("an UNKEYED tile does not — which is what the key is for", () => {
  // The control, and the reason the check above is not vacuous. Same renderer,
  // same model DOM, same three states; only the keys are gone. Identity now
  // follows POSITION, so after the re-order the second tile says "bogdan" while
  // the <video> inside it is still showing ana. That is the bug in one line,
  // and if this test ever passes the same way the keyed one does, the harness
  // has stopped being able to see it.
  const restore = installMiniDom();
  try {
    const r = probe(false);
    assert.equal(r.identityKeptAcrossRerender, true, "positional reconciliation keeps nodes too");
    assert.deepEqual(r.order, ["iulia (you)", "bogdan", "ana"], "the labels re-order either way");
    assert.deepEqual(
      r.streamsInPosition,
      ["self", "ana", "bogdan"],
      "unkeyed, the streams stay where they were while the names move past them",
    );
    assert.notDeepEqual(r.streamsInPosition, ["self", "bogdan", "ana"]);
  } finally {
    restore();
  }
});

test("the stream hook fires when the revision moves, and NOT when nothing does", () => {
  // The other half of the `:rev` argument, asked of the real reconciler. A hook
  // that does not fire on a new stream is a black tile; a hook that fires every
  // frame re-attaches `srcObject` sixty times a second, which is the flicker.
  const restore = installMiniDom();
  try {
    const root = makeRoot();
    const ids = (hooks) => hooks.map((h) => /\[:tile-stream "([^"]+)"/.exec(h)[1]);
    V.tilesHooks(); // whatever an earlier test left behind

    V.tilesRender(root, S.trio, true);
    assert.deepEqual(ids(V.tilesHooks()), ["self", "12D3KooWA", "12D3KooWB"], "every tile attaches on mount");

    V.tilesRender(root, S.trio, true);
    assert.deepEqual(V.tilesHooks(), [], "an identical render must not re-attach anything");

    V.tilesRender(root, S["trio-rev"], true);
    assert.deepEqual(ids(V.tilesHooks()), ["self", "12D3KooWA", "12D3KooWB"], "a new revision re-runs every hook");

    V.tilesRender(root, S["trio-rev"], true);
    assert.deepEqual(V.tilesHooks(), [], "…and then goes quiet again");
  } finally {
    restore();
  }
});

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