sueta / 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
// P2P call e2e: fake media devices, real Chromium, real SRTP.
// Requires `go run ../server --dev` (:8080) and `npm run dev` (:5173).
import { chromium } from "playwright";

const BASE = process.env.BASE_URL ?? "http://localhost:5173";
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?.mesh?.debugState().some((x) => x.dc === "open"), { timeout: 20000 });

// 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.mesh.debugState()[0].sending);
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.mesh.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?.mesh?.debugState().filter((x) => x.dc === "open").length === 2, { timeout: 20000 });
await carol.waitForTimeout(2000);
const carolMedia = await carol.evaluate(() => window.__sueta.mesh.debugState().map((x) => x.sending));
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"));
const afterLeave = await alice.evaluate(() => window.__sueta.mesh.debugState().map((x) => x.sending));
if (afterLeave.some((n) => n !== 0)) fail(`alice still sending after leave: ${afterLeave}`);
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
const bobCtx = bob.context();
await bobCtx.close();
await alice.waitForTimeout(500);
// alice is out of the call; check via carol? carol never joined. Rejoin alice to observe roster:
await alice.click("#call-join");
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?.mesh?.debugState().some((x) => x.dc === "open"), { timeout: 20000 });
ok("lobby paste-to-join lands in the room and meshes");

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