cautpartener / client / e2e / calls.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
// P4 calls e2e — two devices, both ONLINE through the local dev relay (the
// live mesh is a hard requirement: calls are synchronous), with Chromium's
// FAKE camera/microphone devices. Covers: call buttons gate on co-presence;
// audio call ring → accept → media pc CONNECTED on both sides → duration
// timer runs → callee hangs up → both closed + caller's {t:"call", ended}
// marker renders in BOTH timelines; decline → caller told; unanswered ring
// (shortened via the test hook) → missed marker renders for the callee; video
// call → both remote video elements actually playing (videoWidth > 0); hangup
// racing a mocked-slow camera flip → the late tracks are stopped (leak guard).
// Spawns its own relay — stop any external one. Requires Node >= 22.
// Run: node e2e/calls.e2e.mjs
// NEVER put a seed (or any part of one) into a fail message — it is logged.
import { spawn, execSync } from "node:child_process";
import { existsSync } from "node:fs";
import { fileURLToPath } from "node:url";
import {
  acceptInvite,
  confirmJoinedInvite,
  createHarness,
  createIdentity as createIdentityFlow,
  createInvite,
  fail,
  ok,
  sleep,
  waitAllRoomsDrained,
  waitChatReady,
  waitContactsReady,
} from "./helpers.mjs";

const PORT = 5189;
const STUB_PORT = 8796;
const RELAY_PORT = 9097;
const RELAY_DIR = fileURLToPath(new URL("../../deploy/relay/", import.meta.url));
const RELAY_PATH = RELAY_DIR + "relay.mjs";

// ---- dev relay lifecycle -----------------------------------------------------

if (!existsSync(RELAY_DIR + "node_modules")) {
  console.log("installing deploy/relay dependencies (first run)…");
  execSync("npm ci --no-audit --no-fund", { cwd: RELAY_DIR, stdio: "inherit" });
}

let relayProc = null;
async function startRelay() {
  const proc = spawn(process.execPath, [RELAY_PATH, "--dev", "--port", String(RELAY_PORT)], {
    stdio: ["ignore", "pipe", "pipe"],
  });
  let buf = "";
  const peerId = await new Promise((resolve) => {
    const t = setTimeout(() => resolve(null), 15000);
    proc.stdout.on("data", (d) => {
      buf += String(d);
      const m = buf.match(/peerId\s+(\S+)/);
      if (m && buf.includes("relay up")) {
        clearTimeout(t);
        resolve(m[1]);
      }
    });
    proc.stderr.on("data", (d) => process.stderr.write(String(d)));
    proc.on("exit", () => {
      clearTimeout(t);
      resolve(null);
    });
  });
  if (!peerId) fail("dev relay did not come up on :" + RELAY_PORT + " (external instance running? Node >= 22?)");
  relayProc = proc;
  return peerId;
}
const relayPeerId = await startRelay();
const RELAY_MA = `/ip4/127.0.0.1/tcp/${RELAY_PORT}/ws/p2p/${relayPeerId}`;
console.log("dev relay:", RELAY_MA);

const harness = createHarness({
  port: PORT,
  stubPort: STUB_PORT,
  env: { VITE_RELAY_MULTIADDR: RELAY_MA },
  // fake capture devices: getUserMedia yields a synthetic camera + tone
  // (--use-fake-ui-for-media-stream, the auto-grant half, is a harness default)
  browserArgs: ["--use-fake-device-for-media-stream"],
});
const BASE = harness.base;
const createIdentity = (page) => createIdentityFlow(page, BASE);

// ---- page helpers ------------------------------------------------------------

const openConversation = async (page, convId) => {
  await page.evaluate((h) => {
    location.hash = h;
  }, `#/c/${convId}`);
  await page.waitForSelector('[data-testid="chat-input"]', { timeout: 60000 });
};

const waitRelayUp = (page, timeout = 60000) =>
  page.waitForFunction(() => window.__cp?.hub?.debug.live.relayUp === true, undefined, { timeout });

/** The call buttons gate on co-presence — enabled ⇔ the peer's call topic has subscribers. */
const waitCallable = (page, timeout = 90000) =>
  page.waitForSelector('[data-testid="call-audio"]:not([disabled])', { timeout });

const waitPhase = (page, phase, timeout = 30000) =>
  page.waitForFunction((p) => window.__cp?.calls?.debug().phase === p, phase, { timeout });

const waitConnected = (page, timeout = 60000) =>
  page.waitForFunction(
    () => {
      const d = window.__cp?.calls?.debug();
      return d?.phase === "active" && d?.conn === "connected";
    },
    undefined,
    { timeout },
  );

/** A {t:"call"} marker of `kind` rendered in the open timeline. */
const waitCallMarker = (page, kind, count, timeout = 60000) =>
  page.waitForFunction(
    ([k, n]) => document.querySelectorAll(`[data-testid="chat-msg"][data-call="${k}"]`).length >= n,
    [kind, count],
    { timeout },
  );

// ---- scenario ----------------------------------------------------------------

async function main() {
  await harness.start();

  const dirA = harness.newProfileDir("cp-calls-a-");
  const dirB = harness.newProfileDir("cp-calls-b-");
  const errors = { A: [], B: [] };

  // --- contacts via the M2 rendezvous flow ------------------------------------
  let A = await harness.openDevice("A", dirA);
  await createIdentity(A.page);
  await waitContactsReady(A.page);
  const pubA = await A.page.evaluate(() => window.__cp.identity.identity.publicKeyB64);
  const { token } = await createInvite(A.page, "Ana");
  await waitAllRoomsDrained(A.page);
  await A.close();

  const B = await harness.openDevice("B", dirB);
  B.page.on("pageerror", (e) => errors.B.push(e.message));
  await createIdentity(B.page);
  await waitContactsReady(B.page);
  await acceptInvite(B.page, BASE, token, "Bogdan");
  const pubB = await B.page.evaluate(() => window.__cp.identity.identity.publicKeyB64);

  A = await harness.openDevice("A2", dirA);
  A.page.on("pageerror", (e) => errors.A.push(e.message));
  await A.page.goto(BASE);
  await confirmJoinedInvite(A.page);
  await A.page.waitForFunction((pub) => window.__cp?.contacts?.state.contacts.get(pub)?.status === "active", pubB, {
    timeout: 60000,
  });
  await waitChatReady(A.page);
  await waitChatReady(B.page);
  ok("contacts established; both devices stay ONLINE");

  // dismiss the possible re-prompt race (mesh suite's known flow)
  for (let i = 0; i < 10; i++) {
    const again = await A.page.$('[data-testid="invite-confirm-no"]');
    if (!again) break;
    await A.page.click('[data-testid="invite-confirm-no"]').catch(() => {});
    await sleep(500);
  }

  // --- both in the conversation; call buttons gate on co-presence -------------
  await B.page.evaluate(() => {
    location.hash = "#/";
  });
  await openConversation(A.page, pubB);
  await openConversation(B.page, pubA);
  await waitRelayUp(A.page).catch(() => fail("A never connected to the dev relay"));
  await waitRelayUp(B.page).catch(() => fail("B never connected to the dev relay"));
  await waitCallable(A.page).catch(() => fail("A's call button never enabled (B not seen on the call topic)"));
  await waitCallable(B.page).catch(() => fail("B's call button never enabled (A not seen on the call topic)"));
  ok("call buttons enabled on both sides (co-presence gate honest)");

  // --- audio call: ring → accept → connected → timer → callee hangs up --------
  await A.page.click('[data-testid="call-audio"]');
  await A.page.waitForSelector('[data-testid="call-overlay"]', { timeout: 15000 });
  await B.page.waitForSelector('[data-testid="call-incoming"]', { timeout: 30000 }).catch(() =>
    fail("B never saw the incoming-call overlay"),
  );
  const inviteTitle = await B.page.textContent('[data-testid="call-incoming-title"]');
  if (!inviteTitle.includes("Ana")) fail("incoming overlay shows a wire name instead of the LOCAL contact name");
  await B.page.click('[data-testid="call-accept-audio"]');
  await waitConnected(A.page).catch(() => fail("A's media pc never reached connected"));
  await waitConnected(B.page).catch(() => fail("B's media pc never reached connected"));
  ok("audio call: both media-only RTCPeerConnections CONNECTED");

  await A.page
    .waitForFunction(
      () => {
        const t = document.querySelector('[data-testid="call-duration"]')?.textContent ?? "";
        return /^(?!0:00$)\d+:\d\d$/.test(t);
      },
      undefined,
      { timeout: 15000 },
    )
    .catch(() => fail("A's duration timer never advanced past 0:00"));
  ok("duration timer runs");

  await B.page.click('[data-testid="call-hangup"]');
  await waitPhase(A.page, "idle").catch(() => fail("A did not close after B hung up"));
  await waitPhase(B.page, "idle").catch(() => fail("B did not close after hanging up"));
  const overlayGoneA = await A.page.$('[data-testid="call-overlay"]');
  const overlayGoneB = await B.page.$('[data-testid="call-overlay"]');
  if (overlayGoneA || overlayGoneB) fail("call overlay still mounted after hangup");
  ok("callee hangup: both sides idle, overlays unmounted");

  // caller appends the durable marker; the callee sees it via live replication
  await waitCallMarker(A.page, "ended", 1).catch(() => fail("A's timeline never rendered the 'ended' marker"));
  await waitCallMarker(B.page, "ended", 1).catch(() => fail("B's timeline never rendered the 'ended' marker (live)"));
  ok("'ended' call marker in BOTH timelines (caller-appended, live-replicated)");

  // --- decline ----------------------------------------------------------------
  await A.page.click('[data-testid="call-audio"]');
  await B.page.waitForSelector('[data-testid="call-incoming"]', { timeout: 30000 });
  await B.page.click('[data-testid="call-decline"]');
  await waitPhase(A.page, "idle", 15000).catch(() => fail("A kept ringing after B declined"));
  const declineToast = await A.page
    .waitForFunction(() => document.querySelector(".toast")?.textContent?.includes("respins") === true, undefined, {
      timeout: 10000,
    })
    .then(() => true)
    .catch(() => false);
  if (!declineToast) fail("caller never saw the declined notice");
  await waitPhase(B.page, "idle", 15000);
  ok("decline: caller told, both idle");

  // --- unanswered ring → missed marker (shortened via the test hook) ----------
  await A.page.evaluate(() => window.__cp.calls.testSetRingMs(4000));
  await B.page.evaluate(() => window.__cp.calls.testSetRingMs(4000));
  await A.page.click('[data-testid="call-audio"]');
  await B.page.waitForSelector('[data-testid="call-incoming"]', { timeout: 30000 });
  await waitPhase(A.page, "idle", 20000).catch(() => fail("A never gave up the unanswered ring"));
  await waitPhase(B.page, "idle", 20000).catch(() => fail("B's ring overlay never dismissed after the caller gave up"));
  await waitCallMarker(A.page, "missed", 1).catch(() => fail("A's timeline never rendered the 'missed' marker"));
  await waitCallMarker(B.page, "missed", 1).catch(() =>
    fail("B never saw the 'missed' marker (the offline-visible mailbox/live path)"),
  );
  ok("unanswered ring: missed marker appended by the caller and rendered for the callee");

  // --- video call --------------------------------------------------------------
  // restore the default ring timeout — the video scenario must not inherit the
  // 4 s ring from the missed-call scenario above
  await A.page.evaluate(() => window.__cp.calls.testSetRingMs(45000));
  await B.page.evaluate(() => window.__cp.calls.testSetRingMs(45000));
  await A.page.click('[data-testid="call-video"]');
  await B.page.waitForSelector('[data-testid="call-incoming"]', { timeout: 30000 });
  await B.page.click('[data-testid="call-accept-video"]');
  await waitConnected(A.page).catch(() => fail("video call: A never connected"));
  await waitConnected(B.page).catch(() => fail("video call: B never connected"));
  const videoPlaying = (page) =>
    page.waitForFunction(
      () => {
        const r = document.querySelector('[data-testid="call-remote-video"]');
        const l = document.querySelector('[data-testid="call-local-video"]');
        return !!r && !!l && r.readyState >= 2 && r.videoWidth > 0 && !l.hidden && l.videoWidth > 0;
      },
      undefined,
      { timeout: 30000 },
    );
  await videoPlaying(A.page).catch(() => fail("A: remote/local video elements never played (fake camera)"));
  await videoPlaying(B.page).catch(() => fail("B: remote/local video elements never played (fake camera)"));
  ok("video call: remote AND local PiP video actually playing on both sides");

  // --- hangup racing an in-flight camera flip must stop the late tracks -------
  // mock a slow getUserMedia so teardown wins the race: every track of the
  // late-resolving stream must end up readyState "ended" — otherwise the
  // camera/mic would stay live on a dead call until reload (the P4 leak fix)
  await A.page.evaluate(() => {
    const md = navigator.mediaDevices;
    const orig = md.getUserMedia.bind(md);
    window.__gumLate = [];
    md.getUserMedia = async (c) => {
      await new Promise((r) => setTimeout(r, 1500));
      const s = await orig(c);
      window.__gumLate.push(s);
      return s;
    };
  });
  await A.page.click('[data-testid="call-flip"]');
  await sleep(300); // the flip is now parked on the mocked slow getUserMedia
  await A.page.click('[data-testid="call-hangup"]');
  await A.page
    .waitForFunction(
      () =>
        window.__gumLate.length >= 1 &&
        window.__gumLate.every((s) => s.getTracks().every((t) => t.readyState === "ended")),
      undefined,
      { timeout: 15000 },
    )
    .catch(() => fail("late getUserMedia tracks were adopted by a dead call (camera/mic leak)"));
  ok("hangup during a slow camera flip: late-resolving tracks all stopped (no leak)");

  await waitPhase(A.page, "idle").catch(() => fail("A did not close after hanging up the video call"));
  await waitPhase(B.page, "idle").catch(() => fail("B did not close after A hung up the video call"));
  await waitCallMarker(A.page, "ended", 2).catch(() => fail("second 'ended' marker missing on A"));
  ok("video call hangup: both idle, second 'ended' marker appended");

  if (errors.A.length > 0 || errors.B.length > 0) fail(`page errors: ${[...errors.A, ...errors.B].join(" | ")}`);

  await A.close();
  await B.close();
}

let exitCode = 0;
try {
  await main();
  console.log("\n🎉 calls (audio + video, decline, missed marker, timers) scenarios passed");
} catch (err) {
  console.error("❌ " + (err?.message ?? err));
  exitCode = 1;
} finally {
  relayProc?.kill("SIGKILL");
  await harness.shutdown();
}
process.exit(exitCode);

static mirror of HEAD · about · clone: git clone https://cod-sursa.cautpartener.ro/cautpartener.git