chat / client / test / chrome-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
/**
 * The frame (Phase 5d) — the fourth UI golden vector in this repo, and the one
 * whose failure mode is not "it looks wrong" but "the app is gone".
 *
 * `app/chrome` renders `#app` itself, and SIX OTHER REPLICANT ROOTS live inside
 * the tree it owns: the message list, the thread list, both composers, the
 * roster and the call tiles — plus `#sheet` (the overlays) and `#toasts`. Every
 * one of them is written into the frame's hiccup as an EMPTY node, and the only
 * reason a frame repaint does not delete the entire screen is a property of the
 * reconciler:
 *
 *   `replicant.core/reconcile*` returns on its first clause when `unchanged?`
 *   — `=` over the whole sexp, children included — so a container written as a
 *   constant is never entered. And when a container's own ATTRIBUTES change
 *   (the roster's `hidden`, the callbar's, the thread panel's),
 *   `update-children` takes its "both empty, we're done" branch, because the
 *   old and new hiccup both declare zero children; what the DOM actually holds
 *   there is not its business.
 *
 * "The library probably does the right thing" is an assumption until something
 * asks, so the block at the bottom asks — against harness/minidom.mjs, with the
 * real `replicant.dom` — and it asks the same questions of a deliberately WRONG
 * frame that puts one child inside `#msgs`. That variant must DESTROY what
 * another renderer had put there. Without it, a model DOM too lenient to see
 * the bug would report the correct frame as safe for the wrong reason.
 *
 * NEVER REGENERATE test/vectors/chrome-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("chrome-view");
const S = VEC.states;

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

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

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

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

// ---- the reduced inputs are the real ones ----------------------------------

test("the frame projects from REAL app/roster and app/tiles projections", () => {
  // The fixture's `roster` and `call` maps carry only the keys app/chrome
  // reads, which is a claim about those two projections rather than about this
  // one. So it is checked: a genuine roster projection and a genuine tiles
  // projection, piped straight in, must give the state the fixture records.
  const roster = V.rosterProject(
    [["12D3KooWA", { state: "direct", name: "ana", id: { pub: "PUBA", fp: { emoji: "🦊🌊🎈" } } }]],
    `{:me-name "iulia" :me-fp "🐝🌙⭐" :signaling? true :verdicts {}}`,
  );
  const call = V.tilesProject(
    { micOn: true, camOn: false },
    [["12D3KooWA", { name: "ana", audio: true, video: false }]],
    `{:me-name "iulia" :accents {} :peer-pubs {} :big #{} :rev 0}`,
  );
  const state = V.chromeProject(roster, call, VEC.cases["in-call"].ctx);
  assert.equal(state, S["in-call"]);
});

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

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

test("the snapshot distinguishes the room name", () => {
  differ("idle", "relabelled", "`#room-label` was a textContent write; it is state now");
});

test("the snapshot distinguishes how many peers are here", () => {
  differ("idle", "peers", "the pill was a textContent write on `#peer-count`");
});

test("the snapshot distinguishes the three transport states", () => {
  // `.dot.ok` / `.dot.idle` / `.dot.down` are three different colours, and the
  // dot used to be a `className =` from a different function than the pill.
  differ("idle", "offline", "no transport at all must show");
  differ("idle", "peers-idle", "…and so must connected-but-alone");
});

test("the snapshot distinguishes the notification bell", () => {
  differ("idle", "bell", "🔔 vs 🔕 was a textContent write from render-bell!");
});

test("the snapshot distinguishes an open roster drawer", () => {
  // this one was a `classList.toggle` with NO state behind it at all: the DOM
  // was the only record of whether the drawer was open.
  differ("idle", "roster-open", "the peers-pill toggle must be visible in the tree");
});

test("the snapshot distinguishes an open thread panel", () => {
  differ("idle", "thread-open", "show-thread!/close-thread! must move the snapshot");
});

test("the snapshot distinguishes the full-screen image viewer", () => {
  differ("idle", "viewer", "the viewer was a replaceChildren + a classList toggle");
});

test("the snapshot distinguishes a call nobody is in from one somebody is in", () => {
  differ("idle", "watching", "the callbar and the tile strip appear before we join");
});

test("the snapshot distinguishes being in the call from watching it", () => {
  differ("watching", "in-call", "joining swaps the two join buttons for mic/cam/leave");
});

test("the snapshot distinguishes a muted microphone and a live camera", () => {
  differ("in-call", "muted", "🎤/🔇 and `.off` were two writes in update-call");
  differ("in-call", "cam-on", "the camera's `.off` must move the snapshot");
});

test("the snapshot distinguishes how many people are in the call", () => {
  differ("in-call", "trio", "`#call-info` was a textContent write");
});

test("the recorded fixture is itself discriminating", () => {
  // Not a tautology: this reads the COMMITTED snapshots, so a fixture recorded
  // from a view that ignored `:status` or `:mic-on?` would fail here even
  // though the checks above (which re-render) still passed.
  const pairs = [
    ["idle", "relabelled"],
    ["idle", "peers"],
    ["idle", "offline"],
    ["idle", "peers-idle"],
    ["idle", "bell"],
    ["idle", "roster-open"],
    ["idle", "thread-open"],
    ["idle", "viewer"],
    ["idle", "watching"],
    ["watching", "in-call"],
    ["in-call", "muted"],
    ["in-call", "cam-on"],
    ["in-call", "trio"],
  ];
  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.values(S).map((s) => V.chromeView(s)), ...Object.values(VEC.hiccup)];
  for (const out of all) assert.ok(!out.includes("#object"), `an opaque function: ${out.slice(0, 140)}`);
});

test("every foreign root is EMPTY in the hiccup, in every state", () => {
  // THE rule app/chrome's ns docs state, as an assertion. A child inside any of
  // these deletes the screen it hosts on the next repaint, and the identity
  // probe below shows exactly that happening.
  const HOSTS = [
    "[:div#roster.roster ",
    "[:div#tiles.tiles ",
    "[:main#msgs.msgs]",
    "[:div#composer.composer]",
    "[:main#thread-msgs.msgs]",
    "[:div#thread-composer.composer]",
    "[:div#sheet]",
    "[:div#toasts]",
  ];
  // BOTH the live view and the committed fixture: a check that read only the
  // fixture would pass against a view that had grown a child, which is exactly
  // the mistake this is here to catch.
  const every = Object.keys(S).flatMap((n) => [[`${n} (rendered)`, V.chromeView(S[n])],
                                               [`${n} (fixture)`, VEC.hiccup[n]]]);
  for (const [name, out] of every) {
    for (const host of HOSTS) assert.ok(out.includes(host), `${name} is missing ${host}`);
    // the two that carry a class have their attribute map and NOTHING else:
    // `{…}]` rather than `{…} [`
    for (const host of ["[:div#roster.roster ", "[:div#tiles.tiles "]) {
      const rest = out.slice(out.indexOf(host) + host.length);
      assert.equal(rest.slice(0, rest.indexOf("]") + 1).includes("["), false, `${name}: ${host} has a child`);
    }
  }
});

test("the snapshot is not vacuous", () => {
  // rendered and committed, checked separately — see sheets-view.test.mjs
  const out = V.chromeView(S["in-call"]);
  const fixture = VEC.hiccup["in-call"];
  for (const fragment of [
    '[:span#room-label "kitchen"]', //          e2e/mesh.e2e.mjs reads the label
    "[:button#peers-pill.peers-pill ", //       …and clicks this to open the roster
    "[:button#rooms-btn.icon-btn ", //          the lobby
    "[:button#invite-btn.icon-btn ", //         e2e/mesh copies the invite from here
    "[:button#call-join.cb.join ", //           e2e/call.e2e.mjs drives all four
    "[:button#call-mic.cb ",
    "[:button#call-leave.cb.danger ",
    "[:button#thread-close.icon-btn ", //       e2e/mesh closes the thread with this
    ':on {:click [:go-lobby]}',
    ':on {:click [:join-call true]}',
  ]) {
    assert.ok(out.includes(fragment), `missing from the rendered snapshot: ${fragment}`);
    assert.ok(fixture.includes(fragment), `missing from the committed snapshot: ${fragment}`);
  }
  // and the version, which is the string a user reads back over the phone
  assert.ok(out.includes("[:span.ver "), "the version pill is gone");
  assert.ok(fixture.includes("[:span.ver "), "the version pill is gone from the fixture");
});

test("the two hosts that must sit OUTSIDE `.app` do", () => {
  // `.app` carries `transform: translateY(var(--vvt))` for the iOS keyboard,
  // and a transform makes an element the containing block for its
  // `position: fixed` descendants — so an overlay or a toast rendered inside
  // it would be laid out against a 760px column instead of the viewport. The
  // imperative code appended them to the root; this asserts the hiccup says so.
  const out = V.chromeView(S.idle);
  const app = out.indexOf("[:div.app ");
  const sheet = out.indexOf("[:div#sheet]");
  const toasts = out.indexOf("[:div#toasts]");
  assert.ok(app >= 0 && sheet > app && toasts > sheet);
  // …and the frame is a SEQ of three siblings, not one node with two children:
  // `pr-str` prints a seq with parens
  assert.ok(out.startsWith("([:div.app "), out.slice(0, 40));
  assert.ok(out.endsWith("[:div#sheet] [:div#toasts])"), out.slice(-60));
});

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

test("a hostile room label reaches the header as TEXT", () => {
  // The room label is this device's own, but the room list SYNCS between a
  // user's devices, so it is attacker-reachable in the only sense that matters.
  // `esc` was what made it safe in the template this replaces.
  const hiccup = V.chromeView(S.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.chromeHtml(S.hostile);
  assert.ok(!html.includes("<img src=x"), "the label was rendered as markup");
  assert.ok(html.includes("&lt;img src=x onerror=alert(1)&gt;"));
});

test("the markup carries the classes the stylesheet selects on", () => {
  // The hiccup says `:class "ok"` on a node whose tag already says `.dot`;
  // whether that reaches the DOM as BOTH is a question only the markup answers,
  // and `.dot.ok` (not `.dot .ok`) is what public/style.css matches.
  const html = V.chromeHtml(S.idle);
  const cls = /<span id="status-dot" class="([^"]+)"/.exec(html)[1].split(" ").sort();
  assert.deepEqual(cls, ["dot", "ok"]);
  const bar = /<div id="callbar" class="([^"]+)"/.exec(html)[1].split(" ").sort();
  assert.deepEqual(bar, ["callbar", "hidden"]);
});

// ---- does a frame repaint leave the other seven renderers alone? -----------

/** Park a marker element inside each foreign root, the way another root would. */
const planted = (root) => {
  const out = {};
  for (const id of ["roster", "tiles", "msgs", "composer", "thread-msgs", "thread-composer", "sheet", "toasts"]) {
    const host = byId(root, id);
    assert.ok(host, `no #${id} in the frame`);
    const marker = globalThis.document.createElement("div");
    marker.setAttribute("data-marker", id);
    host.appendChild(marker);
    out[id] = marker;
  }
  return out;
};

const survivors = (root, markers) =>
  Object.fromEntries(
    Object.entries(markers).map(([id, marker]) => {
      const host = byId(root, id);
      return [id, host !== null && kids(host).length === 1 && kids(host)[0] === marker];
    }),
  );

test("a frame REPAINT does not disturb what the other renderers put inside it", () => {
  const restore = installMiniDom();
  try {
    const root = makeRoot();
    V.chromeRender(root, S.idle, false);
    const markers = planted(root);
    const msgsEl = byId(root, "msgs");
    const all = Object.fromEntries(Object.keys(markers).map((k) => [k, true]));

    // four repaints, each changing a DIFFERENT container's own attributes: the
    // roster drawer opens, the callbar and the tile strip appear, the thread
    // panel opens, the viewer opens. Every one of those was a classList.toggle
    // on a node that hosts somebody else's render — and the check runs after
    // each one, so a repaint that wipes a root cannot be undone by the next.
    const moved = {
      "roster-open": () => !byId(root, "roster").classList.contains("hidden"),
      "in-call": () => !byId(root, "callbar").classList.contains("hidden") &&
                       !byId(root, "tiles").classList.contains("hidden"),
      "thread-open": () => !byId(root, "thread").classList.contains("hidden"),
      viewer: () => kids(byId(root, "viewer")).length === 1,
    };
    for (const [s, didSomething] of Object.entries(moved)) {
      V.chromeRender(root, S[s], false);
      // the repaint really did something — otherwise this is a test that
      // replicant can do nothing
      assert.ok(didSomething(), `the ${s} repaint changed nothing`);
      assert.deepEqual(survivors(root, markers), all, `${s} cost a foreign root its children`);
    }

    // and the node identity holds too: app/ui caches `#msgs` in `_msgsEl` at
    // build time and measures its scroll on every render for the life of the tab
    assert.equal(byId(root, "msgs"), msgsEl, "#msgs was rebuilt; the cached element is now detached");
  } finally {
    restore();
  }
});

test("a frame that puts a child in `#msgs` DESTROYS the message list — the control", () => {
  // The mistake app/chrome's ns docs forbid, made on purpose, and the failure
  // is worth naming precisely: replicant addresses a node's children BY INDEX
  // against its own vdom. Give `#msgs` a child in one state and not in the
  // next, and the removal pass asks for child 0 of a container whose child 0 is
  // somebody else's — so the message list is what gets deleted, silently, with
  // no exception and no rejected promise.
  //
  // If this test ever passes the way the one above does, the harness has
  // stopped being able to see the bug and the check above proves nothing.
  const restore = installMiniDom();
  try {
    const root = makeRoot();
    V.chromeRender(root, S.idle, false);
    const markers = planted(root);

    V.chromeRender(root, S["roster-open"], true); //  #msgs gains a child
    assert.equal(byId(root, "msgs").textContent, "wrong", "the wrong frame did not render its child");
    V.chromeRender(root, S["in-call"], false); //     …and loses it again

    const after = survivors(root, markers);
    assert.equal(after.msgs, false, "the wrong frame left the message list alone");
    assert.equal(kids(byId(root, "msgs")).length, 0, "the marker should have been removed by index");
    assert.equal(after.roster, true, "…and the other roots are collateral, not the point");
  } finally {
    restore();
  }
});

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