chat / client / e2e / mesh.e2e.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
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
// End-to-end mesh test with real Chromium (WebRTC needs real UDP; the embedded
// preview browser can't do it). Requires `npm run dev` on :5173; the dev relay
// (deploy/relay --dev on :9090) is spawned BY this test so it can be killed and
// restarted for the relay-outage scenario — stop any externally-running relay
// first. Run: node e2e/mesh.e2e.mjs
import { chromium } from "playwright";
import { spawn } from "node:child_process";
import { fileURLToPath } from "node:url";
import { deflateSync } from "node:zlib";
import { buildForE2E } from "./build-for-e2e.mjs";
import { startDevRelay } from "./dev-relay.mjs";

const BASE = process.env.BASE_URL ?? "http://localhost:5173";

// A plain `npm run build` points the app at the PRODUCTION relay, so anything
// this script spawns would be bypassed. Build the dist these tests need.
if (!process.env.E2E_NO_BUILD) buildForE2E();
const HEADED = !!process.env.HEADED;
const RELAY_PATH = fileURLToPath(new URL("../../deploy/relay/relay.mjs", import.meta.url));

let relayProc = null;
let relayExternal = false;
async function startRelay() {
  const started = await startDevRelay(RELAY_PATH, { banner: "sueta relay up" });
  const up = !started.external;
  const proc = started.proc;
  if (up) relayProc = proc;
  else relayExternal = true;
  return up;
}
await startRelay();
if (relayExternal) console.log("⚠️ dev relay already running externally — relay-outage scenario will be skipped");

// minimal valid 64x64 red PNG for the image-share test
function tinyPNG() {
  const S = 64;
  const crcT = [...Array(256)].map((_, n) => {
    let c = n;
    for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
    return c >>> 0;
  });
  const crc = (b) => {
    let c = 0xffffffff;
    for (const x of b) c = crcT[(c ^ x) & 0xff] ^ (c >>> 8);
    return (c ^ 0xffffffff) >>> 0;
  };
  const chunk = (t, d) => {
    const len = Buffer.alloc(4);
    len.writeUInt32BE(d.length);
    const td = Buffer.concat([Buffer.from(t), d]);
    const cc = Buffer.alloc(4);
    cc.writeUInt32BE(crc(td));
    return Buffer.concat([len, td, cc]);
  };
  const ihdr = Buffer.alloc(13);
  ihdr.writeUInt32BE(S, 0);
  ihdr.writeUInt32BE(S, 4);
  ihdr[8] = 8;
  ihdr[9] = 2; // RGB
  const raw = Buffer.alloc(S * (S * 3 + 1));
  for (let y = 0; y < S; y++) {
    raw[y * (S * 3 + 1)] = 0;
    for (let x = 0; x < S; x++) raw.writeUIntBE(0xcc3344, y * (S * 3 + 1) + 1 + x * 3, 3);
  }
  return Buffer.concat([
    Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
    chunk("IHDR", ihdr),
    chunk("IDAT", deflateSync(raw)),
    chunk("IEND", Buffer.alloc(0)),
  ]);
}

const fail = (msg) => {
  console.error("❌ " + msg);
  process.exit(1);
};
const ok = (msg) => console.log("✅ " + msg);

const browser = await chromium.launch({
  headless: !HEADED,
  args: [
    "--disable-features=WebRtcHideLocalIpsWithMdns", // real host IPs → loopback works
    "--autoplay-policy=no-user-gesture-required",
  ],
});

async function openPeer(name, url) {
  const ctx = await browser.newContext({ permissions: [] });
  const page = await ctx.newPage();
  page.on("pageerror", (e) => console.log(`[${name}] pageerror:`, e.message));
  await page.goto(url);
  await page.fill("#name-in", name);
  await page.click("#name-ok");
  return page;
}

const secret = "E2eTestRoom" + Math.random().toString(36).slice(2, 10).padEnd(32, "x");
// use a proper 43-char fragment by letting the app generate one instead:
const first = await (async () => {
  const ctx = await browser.newContext();
  const page = await ctx.newPage();
  await page.goto(BASE);
  await page.click("#new-room"); // lobby → start a new room (label prompt auto-dismissed)
  await page.waitForFunction(() => location.hash.length > 40);
  const url = await page.evaluate(() => location.href);
  await page.fill("#name-in", "alice");
  await page.click("#name-ok");
  return { page, url };
})();

const alice = first.page;
const roomURL = first.url;
console.log("room:", roomURL);

const bob = await openPeer("bob", roomURL);

// 1. peers connect: discovery → membership beacon → ready (+ webrtc upgrade)
for (const [who, page] of [["alice", alice], ["bob", bob]]) {
  await page
    .waitForFunction(() => window.__sueta?.net?.debugState().some((p) => p.ready), { timeout: 40000 })
    .catch(() => fail(`${who}: no peer became ready`));
}
await alice
  .waitForFunction(
    () => window.__sueta.net.debugState().some((p) => p.conns?.some((a) => a.includes("/webrtc"))),
    { timeout: 30000 },
  )
  .then(() => ok("2-peer connected (webrtc direct)"))
  .catch(() => ok("2-peer connected (circuit only — webrtc upgrade pending)"));

// The relay-outage and relay-restart scenarios below kill and restart the relay
// THIS test spawned. They mean nothing unless the app is actually using it —
// and by default it is not: RELAY-MULTIADDR falls back to
// `goog.DEBUG ? DEV : PROD` (config.cljs) and goog.DEBUG is false in any
// release build, which is exactly what e2e/serve.mjs serves. Run that way, the
// app connects to production, killing the local relay changes nothing, and both
// scenarios pass having proved nothing. Assert the wiring rather than trust it.
{
  const addr = await alice.evaluate(() => window.__sueta?.net?._relayAddr ?? "");
  if (!/127\.0\.0\.1|localhost/.test(addr)) {
    fail(
      `app is using relay ${addr || "<unknown>"}, not the one this test spawned — ` +
        "the outage scenarios would pass vacuously. Serve `npm run dev`, or build dist " +
        "with VITE_RELAY_MULTIADDR pointing at the dev relay.",
    );
  }
  ok(`relay under test is the spawned one (${addr.slice(0, 28)}…)`);
}

// 2. text alice → bob
await alice.fill("#input", "hello bob, e2e over webrtc");
await alice.click("#send-btn");
await bob
  .waitForFunction(() => [...document.querySelectorAll(".msg-bubble")].some((b) => b.textContent.includes("hello bob")), { timeout: 5000 })
  .catch(() => fail("bob never received alice's message"));
ok("text message delivered");

// 3. third peer → 3-way mesh
const carol = await openPeer("carol", roomURL);
await carol
  .waitForFunction(() => window.__sueta?.net?.debugState().filter((p) => p.ready).length === 2, { timeout: 40000 })
  .catch(() => fail("carol didn't reach 2 ready peers"));
await alice.waitForFunction(() => window.__sueta?.net?.debugState().filter((p) => p.ready).length === 2, { timeout: 20000 });
ok("3-way mesh formed");

// 4. carol sends; both alice and bob receive
await carol.fill("#input", "carol joined the mesh");
await carol.click("#send-btn");
for (const [who, page] of [["alice", alice], ["bob", bob]]) {
  await page
    .waitForFunction(() => [...document.querySelectorAll(".msg-bubble")].some((b) => b.textContent.includes("carol joined")), { timeout: 5000 })
    .catch(() => fail(`${who} missed carol's message`));
}
ok("broadcast to full mesh works");

// 4b. identity: bob must render alice's exact fingerprint next to her message
const aliceFp = await alice.evaluate(() => window.__sueta.identity?.fp.emoji);
if (aliceFp) {
  await bob
    .waitForFunction(
      (fp) =>
        [...document.querySelectorAll(".msg")].some(
          (m) => m.textContent.includes("hello bob") && m.querySelector(".msg-id")?.textContent.startsWith(fp),
        ),
      aliceFp,
      { timeout: 8000 },
    )
    .catch(() => fail("bob doesn't show alice's identity fingerprint"));
  ok(`identity fingerprints propagate & verify (alice = ${aliceFp})`);
} else {
  console.log("(identity unsupported in this browser — skipped)");
}

// 5. reaction: bob reacts to alice's message (hover bar is desktop path; use store API-equivalent via chip UI)
const msgId = await bob.evaluate(() => {
  const bubbles = [...document.querySelectorAll(".msg")];
  return bubbles.length; // sanity
});
await bob.hover(".msg:nth-of-type(2)"); // alice's "hello bob" message (after hello ordering may vary)
// react via the first hover button on the message containing the text
await bob.evaluate(() => {
  const msg = [...document.querySelectorAll(".msg")].find((m) => m.textContent.includes("hello bob"));
  msg.querySelector(".hb").click();
});
await alice
  .waitForFunction(() => [...document.querySelectorAll(".chip")].some((c) => c.textContent.includes("👍 1")), { timeout: 5000 })
  .catch(() => fail("alice never saw bob's reaction"));
ok("reactions propagate");

// 6. thread: alice replies in a thread on her own message; bob sees reply count
await alice.evaluate(() => {
  const msg = [...document.querySelectorAll(".msg")].find((m) => m.textContent.includes("hello bob"));
  [...msg.querySelectorAll(".hb")].at(-1).click(); // 💬 open thread
});
await alice.fill("#thread-input", "replying to myself in a thread");
await alice.click("#thread-send-btn");
await bob
  .waitForFunction(() => [...document.querySelectorAll(".thread-btn")].some((b) => b.textContent.includes("1 reply")), { timeout: 5000 })
  .catch(() => fail("bob never saw the thread reply count"));
ok("threads propagate");
await alice.click("#thread-close");

// 7. image share via file input
await bob.setInputFiles("#file-in", { name: "photo.png", mimeType: "image/png", buffer: tinyPNG() });
await alice
  .waitForFunction(() => [...document.querySelectorAll(".img-box img")].length >= 1, { timeout: 15000 })
  .catch(() => fail("alice never received bob's image"));
await carol.waitForFunction(() => [...document.querySelectorAll(".img-box img")].length >= 1, { timeout: 15000 });
ok("image transfer (re-encoded, chunked, encrypted) works");

// 8. blind relay proof: watch a fresh join's WS frames for plaintext
const spy = await browser.newContext();
const spyPage = await spy.newPage();
const frames = [];
spyPage.on("websocket", (ws) => {
  ws.on("framesent", (f) => frames.push(String(f.payload)));
  ws.on("framereceived", (f) => frames.push(String(f.payload)));
});
// no request may leave the app's own infrastructure — Helia's DEFAULT config
// races public IPFS gateways for blocks (CID + IP leak); guard the fix forever
const externalRequests = [];
spyPage.on("request", (r) => {
  const u = new URL(r.url());
  if (!/^https?:|^wss?:/.test(u.protocol)) return; // blob:/data: are local
  // own infra only: the dev app + dev relay, and the prod TURN endpoint.
  // The apex is OURS too — the id bridge is served from https://ardegazu.ro/id/
  // (id-kit's DEFAULT-BRIDGE-URL), so match it the way id-kit's own
  // APP-ORIGIN-RE does: an OPTIONAL subdomain. `.endsWith(".ardegazu.ro")`
  // silently excludes the apex and fails this check on every release build.
  if (!/^(localhost|127\.0\.0\.1)$/.test(u.hostname) && !/(^|\.)ardegazu\.ro$/.test(u.hostname))
    externalRequests.push(r.url());
});
await spyPage.goto(roomURL);
await spyPage.fill("#name-in", "eve-observer");
await spyPage.click("#name-ok");
await spyPage.waitForFunction(() => window.__sueta?.net?.debugState().some((p) => p.ready), { timeout: 40000 });
await spyPage.fill("#input", "SECRET-CANARY-9000");
await spyPage.click("#send-btn");
await spyPage.waitForTimeout(1000);
// v2: every relay-bound frame is noise-encrypted libp2p transport — no JSON
// structure remains, so the sweep has no exemptions at all (stronger than v1)
const anyPlain = frames.some((f) => /v=0|a=ice|SECRET-CANARY|eve-observer/.test(f));
if (anyPlain) fail("PLAINTEXT LEAKED over the relay: " + frames.find((f) => /v=0|a=ice|SECRET-CANARY|eve-observer/.test(f)).slice(0, 200));
if (frames.length === 0) fail("spy captured no relay frames — spy wiring broken?");
if (externalRequests.length > 0) fail("requests escaped to third parties: " + externalRequests.slice(0, 3).join(" "));
ok(`blind relay verified: ${frames.length} WS frames, zero plaintext SDP/names/messages, zero third-party requests`);

// 8a. reaction REMOVAL must survive replication to late joiners (fixed v1 bug:
// one-shot history sync only ever merged adds, so removals resurrected)
await bob.evaluate(() => {
  const msg = [...document.querySelectorAll(".msg")].find((m) => m.textContent.includes("hello bob"));
  msg.querySelector(".hb").click(); // toggle the earlier 👍 off
});
await alice
  .waitForFunction(() => ![...document.querySelectorAll("#msgs .chip")].some((c) => c.textContent.includes("👍 1")), { timeout: 15000 })
  .catch(async () => {
    for (const [who, page] of [["alice", alice], ["bob", bob]]) {
      console.log(
        `${who} state:`,
        JSON.stringify(
          await page.evaluate(() => ({
            me: window.__sueta.log.myAuthorId?.slice(0, 10),
            rx: window.__sueta.store.messages
              .filter((m) => m.reactions.size > 0)
              .map((m) => [m.text?.slice(0, 20), [...m.reactions.entries()].map(([e, r]) => [e, [...r.by].map((x) => x.slice(0, 10))])]),
          })),
        ),
      );
    }
    fail("alice still shows bob's removed reaction");
  });
// re-add a different reaction so 8b's joiner has one to verify (👍 stays off)
await bob.evaluate(() => {
  const msg = [...document.querySelectorAll(".msg")].find((m) => m.textContent.includes("hello bob"));
  msg.querySelectorAll(".hb")[1].click();
});
ok("reaction removal replicates (v1 bug fixed)");

// 8b. history sync: a brand-new peer receives the room's past from members
const dave = await openPeer("dave", roomURL);
await dave.waitForFunction(() => window.__sueta?.net?.debugState().some((p) => p.ready), { timeout: 40000 });
await dave
  .waitForFunction(() => [...document.querySelectorAll(".msg-bubble")].some((b) => b.textContent.includes("hello bob")), { timeout: 15000 })
  .catch(() => fail("dave didn't receive history messages"));
await dave
  .waitForFunction(() => [...document.querySelectorAll(".img-box img")].length >= 1, { timeout: 25000 })
  .catch(() => fail("dave didn't receive the history image"));
const daveExtras = await dave.evaluate(() => ({
  thread: [...document.querySelectorAll(".thread-btn")].some((b) => /1 repl/.test(b.textContent)),
  chip: [...document.querySelectorAll("#msgs .chip")].some((c) => c.textContent.includes("❤️ 1")),
  removedGone: ![...document.querySelectorAll("#msgs .chip")].some((c) => c.textContent.includes("👍")),
}));
if (!daveExtras.thread || !daveExtras.chip) fail("dave missing thread/reaction state from history");
if (!daveExtras.removedGone) fail("dave resurrected bob's REMOVED reaction from history");
ok("history sync: late joiner got text, image, threads & reactions (incl. removal)");

// 9. relay-down resilience: kill the relay; chat between connected members
// must keep flowing (direct webrtc connections don't depend on it), and after
// a restart new members must be able to join again.
if (relayExternal) {
  console.log("(relay outage scenario skipped — relay not owned by this test)");
} else {
  relayProc.kill("SIGKILL");
  await alice.waitForFunction(() => window.__sueta.net.relayUp === false, { timeout: 15000 }).catch(() => {});
  await alice.fill("#input", "sent while the relay is down");
  await alice.click("#send-btn");
  await bob
    .waitForFunction(() => [...document.querySelectorAll(".msg-bubble")].some((b) => b.textContent.includes("while the relay is down")), { timeout: 8000 })
    .catch(() => fail("chat died with the relay down — it must not"));
  ok("chat survives relay outage (pure p2p)");

  if (!(await startRelay())) fail("could not restart the dev relay");
  await alice.waitForFunction(() => window.__sueta.net.relayUp === true, { timeout: 30000 }).catch(() => fail("alice never reconnected to the restarted relay"));
  const frank = await openPeer("frank", roomURL);
  await frank
    .waitForFunction(() => window.__sueta?.net?.debugState().some((p) => p.ready), { timeout: 60000 })
    .catch(() => fail("frank couldn't join after the relay restart"));
  await frank.context().close();
  ok("relay restart: reconnection + fresh joins work");
}

// 9b. device-local persistence: reload alone in a fresh room → history intact
{
  const solo = await browser.newContext().then((c) => c.newPage());
  await solo.goto(BASE);
  await solo.click("#new-room");
  await solo.waitForSelector("#name-in");
  await solo.fill("#name-in", "hermit");
  await solo.click("#name-ok");
  await solo.fill("#input", "note to future self");
  await solo.click("#send-btn");
  await solo.waitForTimeout(2200); // let the debounced IndexedDB save land
  await solo.reload();
  // stored name → no modal on reload (returning users join silently)
  await solo
    .waitForFunction(
      () => [...document.querySelectorAll(".msg-bubble")].some((b) => b.textContent.includes("note to future self")),
      { timeout: 8000 },
    )
    .catch(() => fail("history did not survive a reload (device persistence)"));
  const mineStyled = await solo.evaluate(() => !!document.querySelector(".msg.mine"));
  if (!mineStyled) fail("restored own message lost its 'mine' styling");
  ok("history persists on-device across reloads (incl. authorship)");
}

// 9c. identity sheet: "save in password app" must not crash and must close the sheet
await dave.click("#peers-pill");
await dave.click(".roster-row.clickable");
await dave.waitForSelector("#id-save");
await dave.click("#id-save button[type=submit]");
await dave.waitForFunction(() => !document.querySelector("#id-save"), { timeout: 5000 }).catch(() => fail("identity save did not complete/close"));
const savedToast = await dave.evaluate(() => [...document.querySelectorAll(".toast")].some((t) => t.textContent.includes("🗝️")));
if (!savedToast) fail("identity save gave no feedback");
ok("identity save flow completes (PasswordCredential / AutoFill heuristic)");

// 10. wrong secret lands in an empty room
const outsider = await browser.newContext().then((c) => c.newPage());
await outsider.goto(BASE + "#" + "X".repeat(43));
await outsider.fill("#name-in", "outsider");
await outsider.click("#name-ok");
await outsider.waitForTimeout(2500);
// discovery is relay-wide, so transient unverified records are expected —
// but nothing may ever VERIFY (that needs the room keys), so the roster stays empty
const outsiderPeers = await outsider.evaluate(
  () => window.__sueta.net.debugState().filter((p) => p.verified).length,
);
if (outsiderPeers !== 0) fail("outsider with different secret verified a peer!");
const outsiderRoster = await outsider.evaluate(() => document.querySelectorAll(".roster-row").length);
if (outsiderRoster > 1) fail("outsider's roster shows members of another room!");
ok("different secret ⇒ isolated room");

// 11. same-identity room sync: two devices holding one seed merge room lists;
// members with OTHER identities (alice/bob) must never receive them
{
  const NS = "chat-ardegazu-ro-v2";
  const seed = Buffer.from(Array.from({ length: 32 }, () => Math.floor(Math.random() * 256))).toString("base64url");
  const extraA = "A".repeat(43);
  const extraB = "B".repeat(43);

  const openDevice = async (label, extraSecret) => {
    const page = await browser.newContext().then((c) => c.newPage());
    page.on("pageerror", (e) => console.log(`[${label}] pageerror:`, e.message));
    await page.goto(BASE); // same-origin page so localStorage is reachable pre-boot
    await page.evaluate(
      ([ns, s, name, extra]) => {
        localStorage.setItem(`${ns}:id`, s);
        localStorage.setItem(`${ns}:name`, name);
        localStorage.setItem(`${ns}:rooms`, JSON.stringify([{ s: extra, label: `${name} extra`, ts: Date.now() - 5000 }]));
      },
      [NS, seed, label, extraSecret],
    );
    await page.goto(roomURL);
    await page.reload(); // fragment-only navigation doesn't re-run boot
    // stored name → no modal; the room must boot straight to the composer
    await page.waitForSelector("#input", { timeout: 15000 }).catch(() => fail(`${label}: room did not boot without the name modal`));
    return page;
  };

  const devA = await openDevice("dev-a", extraA);
  const devB = await openDevice("dev-b", extraB);

  const hasRoom = (page, s) =>
    page.waitForFunction(
      ([ns, sec]) => JSON.parse(localStorage.getItem(`${ns}:rooms`) ?? "[]").some((r) => r.s === sec),
      [NS, s],
      { timeout: 40000 },
    );
  await hasRoom(devA, extraB).catch(() => fail("dev-a never received dev-b's room"));
  await hasRoom(devB, extraA).catch(() => fail("dev-b never received dev-a's room"));
  ok("same-identity devices merged room lists (both directions)");

  // negative: alice holds a DIFFERENT identity — the lists must not leak to her
  await alice.waitForTimeout(1500);
  const leaked = await alice.evaluate(
    ([ns, a, b]) => JSON.parse(localStorage.getItem(`${ns}:rooms`) ?? "[]").some((r) => r.s === a || r.s === b),
    [NS, extraA, extraB],
  );
  if (leaked) fail("room list leaked to a room member with a different identity!");
  ok("other members' room lists stay private");

  await devA.context().close();
  await devB.context().close();
}

console.log("\n🎉 all e2e checks passed");
await browser.close();
relayProc?.kill("SIGKILL");
process.exit(0);

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