chat / client / e2e / call.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
// P2P call e2e: fake media devices, real Chromium, real SRTP. Media rides
// media-only RTCPeerConnections signaled over libp2p streams (lib/media.ts).
// Requires `npm run dev` (:5173); spawns the dev relay itself.
import { chromium } from "playwright";
import { fileURLToPath } from "node:url";
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 RELAY_PATH = fileURLToPath(new URL("../../deploy/relay/relay.mjs", import.meta.url));
const { proc: relayProc } = await startDevRelay(RELAY_PATH, { banner: "sueta relay up" });
// Retire the relay on EVERY exit path, not just the happy one. `fail()` and any
// uncaught error call process.exit, which used to leave the relay listening on
// :9090 — and the NEXT run then spawned its own, watched it die on the busy
// port, and read that death as "an external relay is already running". Two runs
// back to back therefore tested whatever the orphan was, and a run after a
// failed one could test with no relay at all.
process.on("exit", () => relayProc?.kill("SIGKILL"));
const fail = (m) => {
  console.error("❌ " + m);
  process.exit(1);
};
const ok = (m) => console.log("✅ " + m);

const browser = await chromium.launch({
  args: [
    "--disable-features=WebRtcHideLocalIpsWithMdns",
    "--use-fake-device-for-media-stream",
    "--use-fake-ui-for-media-stream",
    "--autoplay-policy=no-user-gesture-required",
  ],
});

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

const alice = await openPeer("alice", BASE);
await alice.waitForFunction(() => location.hash.length > 40);
const roomURL = await alice.evaluate(() => location.href);
const bob = await openPeer("bob", roomURL);

for (const p of [alice, bob])
  await p.waitForFunction(() => window.__sueta?.net?.debugState().some((x) => x.ready), { timeout: 40000 });

// 1. alice starts an audio call — no media flows while she's alone
await alice.click("#call-btn");
await alice.waitForSelector("#callbar:not(.hidden)");
const aloneTx = await alice.evaluate(() =>
  window.__sueta.media.debugState().reduce((n, x) => n + (x.sending?.length ?? 0), 0),
);
if (aloneTx !== 0) fail(`alice sends media with nobody in call (sending=${aloneTx})`);
ok("call started; zero media while alone");

// 2. bob sees the callbar and joins with video → frames both ways
await bob.waitForSelector("#call-join-video:not(.hidden)", { timeout: 10000 });
await bob.click("#call-join-video");
// alice (audio-only) must receive bob's VIDEO frames; bob must receive alice's live AUDIO
await alice
  .waitForFunction(
    () => [...document.querySelectorAll(".tile video")].some((v) => !v.muted && v.videoWidth > 0 && v.readyState >= 2),
    { timeout: 25000 },
  )
  .catch(async () => fail(`alice: no remote video frames — ${JSON.stringify(await alice.evaluate(() => window.__sueta.media.debugState()))}`));
await bob
  .waitForFunction(
    () => [...window.__sueta.call.members.values()].some((m) => m.stream?.getAudioTracks().some((t) => t.readyState === "live")),
    { timeout: 25000 },
  )
  .catch(() => fail("bob: no live remote audio track from alice"));
ok("media flows: bob's video → alice, alice's audio → bob (renegotiation + DTLS-SRTP)");

// 3. mute propagates
await alice.click("#call-mic");
await bob
  .waitForFunction(() => [...document.querySelectorAll(".tile-label")].some((l) => l.textContent.includes("🔇") && l.textContent.includes("alice")), { timeout: 8000 })
  .catch(() => fail("bob never saw alice's mute badge"));
ok("mute state propagates");

// 4. cam-off propagates (bob turns his camera off)
await bob.click("#call-cam");
await alice
  .waitForFunction(() => [...document.querySelectorAll(".tile.novideo .tile-label")].some((l) => l.textContent.includes("bob")), { timeout: 8000 })
  .catch(() => fail("alice never saw bob's cam-off tile"));
ok("cam-off propagates");

// 5. carol is in the ROOM but not the call → zero media on all her pairs
const carol = await openPeer("carol", roomURL);
await carol.waitForFunction(() => window.__sueta?.net?.debugState().filter((x) => x.ready).length === 2, { timeout: 40000 });
await carol.waitForTimeout(2000);
const carolMedia = await carol.evaluate(() => window.__sueta.media.debugState().map((x) => x.sending?.length ?? 0));
if (carolMedia.some((n) => n !== 0)) fail(`room member outside call has media senders: ${carolMedia}`);
ok("non-call room member exchanges zero media");

// 6. chat still works during the call
await carol.fill("#input", "text during the call");
await carol.click("#send-btn");
await alice
  .waitForFunction(() => [...document.querySelectorAll(".msg-bubble")].some((b) => b.textContent.includes("during the call")), { timeout: 8000 })
  .catch(() => fail("chat broken during call"));
ok("chat flows during the call");

// 7. leave: alice's senders drop, bob loses her tile, chat still works after
await alice.click("#call-leave");
await bob
  .waitForFunction(() => ![...document.querySelectorAll(".tile-label")].some((l) => l.textContent.includes("alice")), { timeout: 8000 })
  .catch(() => fail("alice's tile survived her leaving"));
await alice
  .waitForFunction(() => window.__sueta.media.debugState().every((x) => (x.sending?.length ?? 0) === 0), { timeout: 8000 })
  .catch(async () =>
    fail(`alice still sending after leave: ${JSON.stringify(await alice.evaluate(() => window.__sueta.media.debugState()))}`),
  );
await alice.fill("#input", "still chatting after the call");
await alice.click("#send-btn");
await bob.waitForFunction(() => [...document.querySelectorAll(".msg-bubble")].some((b) => b.textContent.includes("after the call")), { timeout: 8000 });
ok("leave detaches media, dc survives the renegotiation");

// 8. crash-leave: bob's page dies without op:leave → his tile disappears.
// Alice has to be IN the call to watch that happen, and the order matters:
// #call-join lives inside #callbar, which update-call hides the moment nobody
// is in a call at all (any-call = in-call || members.size). Alice left in 7, so
// killing bob FIRST and rejoining after races his removal for the very button
// the rejoin needs — it lost that race two runs in three. Rejoin while he is
// still alive, prove his tile is really there, then kill him: same assertion,
// no race, and it can no longer pass vacuously against a tile that never came.
await alice.click("#call-join");
await alice
  .waitForFunction(() => [...document.querySelectorAll(".tile-label")].some((l) => l.textContent.includes("bob")), { timeout: 25000 })
  .catch(() => fail("bob's tile never appeared after alice rejoined"));
const bobCtx = bob.context();
await bobCtx.close();
await alice
  .waitForFunction(() => ![...document.querySelectorAll(".tile-label")].some((l) => l.textContent.includes("bob")), { timeout: 25000 })
  .catch(() => fail("bob's ghost tile survived his crash"));
ok("crashed member is removed from the call roster");

// 9. lobby paste-to-join (PWA fix): fresh context pastes the full invite URL
const dave = await browser.newContext().then((c) => c.newPage());
await dave.goto(BASE);
await dave.fill("#join-link", roomURL);
await dave.click("#join-room");
await dave.waitForSelector("#name-in");
await dave.fill("#name-in", "dave");
await dave.click("#name-ok");
await dave.waitForFunction(() => window.__sueta?.net?.debugState().some((x) => x.ready), { timeout: 40000 });
ok("lobby paste-to-join lands in the room and meshes");

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

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