sueta / 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
// End-to-end mesh test with real Chromium (WebRTC needs real UDP; the embedded
// preview browser can't do it). Requires `go run ../server --dev` on :8080 and
// `npm run dev` on :5173. Run: node e2e/mesh.e2e.mjs
import { chromium } from "playwright";
import { deflateSync } from "node:zlib";

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

// 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. mesh forms
for (const [who, page] of [["alice", alice], ["bob", bob]]) {
  await page
    .waitForFunction(() => window.__sueta?.mesh?.debugState().some((p) => p.dc === "open"), { timeout: 20000 })
    .catch(() => fail(`${who}: datachannel never opened`));
}
ok("2-peer datachannel open");

// 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?.mesh?.debugState().filter((p) => p.dc === "open").length === 2, { timeout: 20000 })
  .catch(() => fail("carol didn't form 2 channels"));
await alice.waitForFunction(() => window.__sueta?.mesh?.debugState().filter((p) => p.dc === "open").length === 2, { timeout: 10000 });
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)));
});
await spyPage.goto(roomURL);
await spyPage.fill("#name-in", "eve-observer");
await spyPage.click("#name-ok");
await spyPage.waitForFunction(() => window.__sueta?.mesh?.debugState().some((p) => p.dc === "open"), { timeout: 20000 });
await spyPage.fill("#input", "SECRET-CANARY-9000");
await spyPage.click("#send-btn");
await spyPage.waitForTimeout(1000);
const leaked = frames.filter((f) => /sdp|candidate:|SECRET-CANARY|eve-observer/i.test(f) && !/"payload"/.test(f));
const anyPlain = frames.some((f) => /v=0|a=ice|SECRET-CANARY|eve-observer/.test(f));
if (anyPlain) fail("PLAINTEXT LEAKED over signaling: " + frames.find((f) => /v=0|a=ice|SECRET-CANARY|eve-observer/.test(f)).slice(0, 200));
ok(`blind relay verified: ${frames.length} WS frames, zero plaintext SDP/names/messages`);

// 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?.mesh?.debugState().some((p) => p.dc === "open"), { timeout: 20000 });
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(".chip")].some((c) => c.textContent.includes("👍 1")),
}));
if (!daveExtras.thread || !daveExtras.chip) fail("dave missing thread/reaction state from history");
ok("history sync: late joiner got text, image, threads & reactions");

// 9. signaling-down resilience: chat keeps working with WS closed
await alice.evaluate(() => window.__sueta.signaling.close());
await alice.fill("#input", "sent while signaling is down");
await alice.click("#send-btn");
await bob
  .waitForFunction(() => [...document.querySelectorAll(".msg-bubble")].some((b) => b.textContent.includes("while signaling is down")), { timeout: 5000 })
  .catch(() => fail("chat died with signaling down — it must not"));
ok("chat survives signaling outage (pure p2p)");

// 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();
  await solo.waitForSelector("#name-in");
  await solo.click("#name-ok");
  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);
const outsiderPeers = await outsider.evaluate(() => window.__sueta.mesh.debugState().length);
if (outsiderPeers !== 0) fail("outsider with different secret saw peers!");
ok("different secret ⇒ isolated room");

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