From 24b6eae1bcee3a5a1c13b8927a417a4444e931fe Mon Sep 17 00:00:00 2001 From: Harshit Date: Thu, 27 Aug 2026 12:01:30 +0530 Subject: [PATCH] test(sfu): add SFU capacity load test and client instrumentation - add load-test script and results CSV for SFU capacity testing - expose window globals for test timing (__csSocket, __csRoomId, __csLiveAt, __csJoinedAt, __csFirstFrameAt) - wire socket connect/disconnect lifecycle in Broadcaster; use connectSocket in ViewerPage - unprotect broadcaster/viewer/dashboard routes for load testing - log socket disconnect reason and details at error level Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/src/utils/socket.util.ts | 4 +- frontend/src/pages/Broadcaster.tsx | 15 +- frontend/src/pages/ViewerPage.tsx | 12 +- frontend/src/router/index.tsx | 6 +- frontend/src/socket.ts | 2 + frontend/src/viewer.ts | 3 + load-test/sfu-capacity-results.csv | 101 ++++ load-test/sfu-capacity.js | 854 +++++++++++++++++++++++++++++ 8 files changed, 990 insertions(+), 7 deletions(-) create mode 100644 load-test/sfu-capacity-results.csv create mode 100644 load-test/sfu-capacity.js diff --git a/backend/src/utils/socket.util.ts b/backend/src/utils/socket.util.ts index 6277d07..5cf68a6 100644 --- a/backend/src/utils/socket.util.ts +++ b/backend/src/utils/socket.util.ts @@ -140,8 +140,8 @@ io.on("connection", (socket) => { } }) - socket.on("disconnect", async (reason) => { - logger.info(`User disconnected ${socket.id} beacuse of ${reason}`) + socket.on("disconnect", async (reason, details) => { + logger.error(`User disconnected, ${socket.id} reason: ${reason} details: ${details}`) handleDisconnect(socket) await stopFfmpegRecording(socket.id) }); diff --git a/frontend/src/pages/Broadcaster.tsx b/frontend/src/pages/Broadcaster.tsx index 2558300..234d9dd 100644 --- a/frontend/src/pages/Broadcaster.tsx +++ b/frontend/src/pages/Broadcaster.tsx @@ -1,4 +1,5 @@ -import { useRef, useState } from "react"; +import { useRef, useState, useEffect } from "react"; +import { connectSocket , disconnectSocket } from "../socket"; import { CameraOff, Camera, @@ -51,6 +52,16 @@ export default function BroadcasterPage() { setLogs((prev) => [...prev, { message, timestamp: new Date() }]); }; + + + useEffect(() => { + const socket = connectSocket(); + + return () => { + disconnectSocket(); + }; + }, []); + async function startBroadcast() { try { log("Creating room..."); @@ -58,6 +69,7 @@ export default function BroadcasterPage() { const room = await broadcaster.createRoom(); setRoomId(room.id); + (window as any).__csRoomId = room.id; log("Fetching RTP capabilities..."); @@ -84,6 +96,7 @@ export default function BroadcasterPage() { await broadcaster.startProducing(stream); setIsLive(true); + (window as any).__csLiveAt = Date.now(); log("Broadcast started successfully."); } catch (err: any) { diff --git a/frontend/src/pages/ViewerPage.tsx b/frontend/src/pages/ViewerPage.tsx index 71a06bd..dd87551 100644 --- a/frontend/src/pages/ViewerPage.tsx +++ b/frontend/src/pages/ViewerPage.tsx @@ -3,6 +3,7 @@ import { useSearchParams } from "react-router-dom"; import { X, Circle } from "lucide-react"; import api from "../api/axios"; +import { connectSocket , disconnectSocket } from "../socket"; import Viewer from "../viewer"; import SystemLogs from "../components/broadcaster/SystemLogs"; @@ -20,7 +21,7 @@ interface Log { const viewer = new Viewer(); export default function ViewerPage() { - const socket = getSocket() + const socket = connectSocket() const [searchParams] = useSearchParams(); const videoRef = useRef(null); @@ -52,6 +53,14 @@ export default function ViewerPage() { ]); }; + // useEffect(() => { + // const socket = connectSocket(); + + // return () => { + // disconnectSocket(); + // }; + // }, []); + async function joinRoom(e: React.FormEvent) { e.preventDefault(); @@ -86,6 +95,7 @@ export default function ViewerPage() { await viewer.connectionState(roomId); + (window as any).__csJoinedAt = Date.now(); setConnected(true); startHeartBeat(); diff --git a/frontend/src/router/index.tsx b/frontend/src/router/index.tsx index 72bcdee..adc6128 100644 --- a/frontend/src/router/index.tsx +++ b/frontend/src/router/index.tsx @@ -16,10 +16,10 @@ export default function Router() { } /> } /> + } /> + } /> + } /> }> - } /> - } /> - } /> diff --git a/frontend/src/socket.ts b/frontend/src/socket.ts index 694eedc..003b4d3 100644 --- a/frontend/src/socket.ts +++ b/frontend/src/socket.ts @@ -11,6 +11,7 @@ export function connectSocket() { } socket = io(window.location.origin); + (window as any).__csSocket = socket; socket.on("connect", () => { console.log("Client connected", socket?.id); @@ -22,6 +23,7 @@ export function connectSocket() { socket.on("connect_error", (error) => { console.log("Error", error.message); + console.log("SOCKET CONNECT ERROR DETAILS:", error); }); socket.on("debug:instance", (data) => { diff --git a/frontend/src/viewer.ts b/frontend/src/viewer.ts index 7e9b0c6..223c6f5 100644 --- a/frontend/src/viewer.ts +++ b/frontend/src/viewer.ts @@ -283,6 +283,9 @@ class Viewer{ mediaStream.addTrack(track) }) viewerVideo.current.srcObject = mediaStream + viewerVideo.current.addEventListener('loadeddata', () => { + (window as any).__csFirstFrameAt = Date.now(); + }, { once: true }); if(viewerVideo.current){ console.log('Viewer started playing') }else{ diff --git a/load-test/sfu-capacity-results.csv b/load-test/sfu-capacity-results.csv new file mode 100644 index 0000000..3301022 --- /dev/null +++ b/load-test/sfu-capacity-results.csv @@ -0,0 +1,101 @@ +timestamp,viewers,joinP50,joinP99,firstFrameP50,firstFrameP99,failures +2026-08-19T03:33:16.173Z,1,341,341,362,362,0 +2026-08-19T03:33:20.123Z,2,346,346,388,388,0 +2026-08-19T03:33:23.971Z,3,336,336,381,381,0 +2026-08-19T03:33:28.038Z,4,373,373,397,397,0 +2026-08-19T03:33:31.922Z,5,353,353,385,385,0 +2026-08-19T03:33:36.021Z,6,366,366,410,410,0 +2026-08-19T03:33:40.121Z,7,349,349,394,394,0 +2026-08-19T03:33:44.121Z,8,387,387,434,434,0 +2026-08-19T03:33:48.489Z,9,378,378,395,395,0 +2026-08-19T03:33:52.587Z,10,395,395,419,419,0 +2026-08-19T03:33:56.771Z,11,396,396,436,436,0 +2026-08-19T03:34:01.380Z,12,389,389,408,408,0 +2026-08-19T03:34:05.571Z,13,407,407,435,435,0 +2026-08-19T03:34:09.634Z,14,428,428,461,461,0 +2026-08-19T03:34:13.986Z,15,423,423,457,457,0 +2026-08-19T03:34:18.386Z,16,410,410,442,442,0 +2026-08-19T03:34:22.536Z,17,417,417,468,468,0 +2026-08-19T03:34:26.983Z,18,445,445,465,465,0 +2026-08-19T03:34:31.244Z,19,461,461,487,487,0 +2026-08-19T03:34:35.383Z,20,475,475,494,494,0 +2026-08-19T03:34:39.585Z,21,486,486,516,516,0 +2026-08-19T03:34:43.967Z,22,457,457,489,489,0 +2026-08-19T03:34:48.652Z,23,468,468,533,533,0 +2026-08-19T03:34:53.038Z,24,507,507,563,563,0 +2026-08-19T03:34:57.740Z,25,500,500,554,554,0 +2026-08-19T03:35:02.559Z,26,486,486,516,516,0 +2026-08-19T03:35:07.261Z,27,541,541,574,574,0 +2026-08-19T03:35:12.083Z,28,559,559,580,580,0 +2026-08-19T03:35:16.852Z,29,500,500,549,549,0 +2026-08-19T03:35:21.758Z,30,552,552,597,597,0 +2026-08-19T03:35:26.751Z,31,534,534,599,599,0 +2026-08-19T03:35:31.664Z,32,535,535,577,577,0 +2026-08-19T03:35:36.757Z,33,597,597,652,652,0 +2026-08-19T03:35:41.663Z,34,556,556,610,610,0 +2026-08-19T03:35:46.585Z,35,581,581,607,607,0 +2026-08-19T03:35:51.574Z,36,630,630,675,675,0 +2026-08-19T03:35:56.683Z,37,666,666,688,688,0 +2026-08-19T03:36:01.866Z,38,690,690,760,760,0 +2026-08-19T03:36:06.964Z,39,679,679,739,739,0 +2026-08-19T03:36:12.085Z,40,673,673,699,699,0 +2026-08-19T03:36:17.367Z,41,695,695,771,771,0 +2026-08-19T03:36:22.760Z,42,695,695,750,750,0 +2026-08-19T03:36:28.098Z,43,727,727,749,749,0 +2026-08-19T03:36:33.672Z,44,737,737,821,821,0 +2026-08-19T03:36:39.166Z,45,745,745,822,822,0 +2026-08-19T03:36:44.684Z,46,813,813,894,894,0 +2026-08-19T03:36:50.184Z,47,818,818,882,882,0 +2026-08-19T03:36:55.881Z,48,850,850,926,926,0 +2026-08-19T03:37:01.900Z,49,911,911,952,952,0 +2026-08-19T03:37:07.882Z,50,800,800,876,876,0 +2026-08-19T03:37:13.888Z,51,951,951,1029,1029,0 +2026-08-19T03:37:20.212Z,52,978,978,1023,1023,0 +2026-08-19T03:37:26.388Z,53,987,987,1084,1084,0 +2026-08-19T03:37:32.855Z,54,1262,1262,1301,1301,0 +2026-08-19T03:37:39.202Z,55,1037,1037,1097,1097,0 +2026-08-19T03:37:46.003Z,56,1077,1077,1109,1109,0 +2026-08-19T03:37:52.557Z,57,1062,1062,1107,1107,0 +2026-08-19T03:37:59.297Z,58,1209,1209,1301,1301,0 +2026-08-19T03:38:06.254Z,59,1210,1210,1308,1308,0 +2026-08-19T03:38:13.195Z,60,1386,1386,1489,1489,0 +2026-08-19T03:38:20.308Z,61,1333,1333,1422,1422,0 +2026-08-19T03:38:28.064Z,62,1319,1319,1412,1412,0 +2026-08-19T03:38:35.652Z,63,1412,1412,1505,1505,0 +2026-08-19T03:38:44.576Z,64,2868,2868,2958,2958,0 +2026-08-19T03:38:52.598Z,65,1935,1935,2032,2032,0 +2026-08-19T03:39:41.698Z,66,n/a,n/a,n/a,n/a,1 +2026-08-19T03:39:50.785Z,67,1782,1782,1860,1860,1 +2026-08-19T03:40:00.474Z,68,1721,1721,1872,1872,1 +2026-08-19T03:40:10.696Z,69,1821,1821,1968,1968,1 +2026-08-19T03:40:20.764Z,70,2004,2004,2103,2103,1 +2026-08-19T03:40:31.929Z,71,2290,2290,2400,2400,1 +2026-08-19T03:41:22.868Z,72,n/a,n/a,n/a,n/a,2 +2026-08-19T03:41:34.524Z,73,2372,2372,2581,2581,2 +2026-08-19T03:41:47.701Z,74,2935,2935,3021,3021,2 +2026-08-19T03:42:39.292Z,75,n/a,n/a,n/a,n/a,3 +2026-08-19T03:42:51.845Z,76,2236,2236,2347,2347,3 +2026-08-19T03:43:04.971Z,77,2510,2510,2545,2545,3 +2026-08-19T03:43:21.097Z,78,3961,3961,4067,4067,3 +2026-08-19T03:44:15.879Z,79,n/a,n/a,n/a,n/a,4 +2026-08-19T03:44:32.753Z,80,4622,4622,4902,4902,4 +2026-08-19T03:44:43.493Z,81,1798,1798,1902,1902,4 +2026-08-19T03:44:51.573Z,82,1697,1697,1756,1756,4 +2026-08-19T03:45:07.981Z,83,3197,3197,3389,3389,4 +2026-08-19T03:45:23.895Z,84,3248,3248,3566,3566,4 +2026-08-19T03:45:30.251Z,85,1241,1241,1373,1373,4 +2026-08-19T03:45:40.065Z,86,1708,1708,1813,1813,4 +2026-08-19T03:45:52.749Z,87,4098,4098,4200,4200,4 +2026-08-19T03:46:04.922Z,88,2382,2382,2444,2444,4 +2026-08-19T03:46:56.247Z,89,n/a,n/a,n/a,n/a,5 +2026-08-19T03:47:08.676Z,90,748,748,874,874,5 +2026-08-19T03:47:16.974Z,91,2736,2736,2854,2854,5 +2026-08-19T03:47:28.818Z,92,2608,2608,2792,2792,5 +2026-08-19T03:48:22.194Z,93,n/a,n/a,n/a,n/a,6 +2026-08-19T03:48:28.777Z,94,1179,1179,1258,1258,6 +2026-08-19T03:49:15.344Z,95,n/a,n/a,n/a,n/a,7 +2026-08-19T03:49:26.690Z,96,1321,1321,1393,1393,7 +2026-08-19T03:49:37.293Z,97,2924,2924,3184,3184,7 +2026-08-19T03:50:28.698Z,98,n/a,n/a,n/a,n/a,8 +2026-08-19T03:50:42.666Z,99,1211,1211,1274,1274,8 +2026-08-19T03:50:56.765Z,100,3810,3810,3888,3888,8 \ No newline at end of file diff --git a/load-test/sfu-capacity.js b/load-test/sfu-capacity.js new file mode 100644 index 0000000..6048293 --- /dev/null +++ b/load-test/sfu-capacity.js @@ -0,0 +1,854 @@ +const puppeteer = require("puppeteer"); +const fs = require("fs"); + +function arg(name, def) { + const i = process.argv.indexOf(`--${name}`); + if (i === -1) return def; + return process.argv[i + 1]; +} + +const BASE_URL = arg("baseUrl", "http://localhost"); +// const TOKEN = arg("token", null); + +const TEST_EMAIL = "harsxit04@gmail.com" +const TEST_PASSWORD = "@Harshit1308" + +if (!TEST_EMAIL || !TEST_PASSWORD) { + console.error( + "Set CROWDSTREAM_TEST_EMAIL and CROWDSTREAM_TEST_PASSWORD" + ); + process.exit(1); +} + + +const BATCH_SIZE = parseInt(arg("batchSize", "5"), 10); +const BATCH_INTERVAL_MS = parseInt( + arg("batchIntervalMs", "10000"), + 10 +); +const MAX_VIEWERS = parseInt( + arg("maxViewers", "100"), + 10 +); + +const OUT_CSV = arg( + "out", + "sfu-capacity-results.csv" +); + +const WAIT_TIMEOUT_MS = 20000; +let failures = 0; + +// if (!TOKEN) { +// console.error("Missing --token "); +// process.exit(1); +// } + +const CHROME_FLAGS = [ + "--use-fake-device-for-media-stream", + "--use-fake-ui-for-media-stream", + "--disable-gpu", + "--no-sandbox", + "--mute-audio", +]; + +async function login(page) { + console.log("Opening signin page..."); + + await page.goto(`${BASE_URL}/signin`, { + waitUntil: "networkidle2", + timeout: 30000, + }); + + await page.waitForSelector("#email", { + timeout: WAIT_TIMEOUT_MS, + }); + + await page.type("#email", TEST_EMAIL); + await page.type("#password", TEST_PASSWORD); + + await page.click('button[type="submit"]'); + + // SignInPage navigates to /dashboard after successful sign-in. + await page.waitForFunction( + () => window.location.pathname === "/dashboard", + { timeout: WAIT_TIMEOUT_MS } + ); + + console.log("Login successful:", await page.url()); + await page.waitForFunction( + () => + window.__csSocket && + window.__csSocket.connected === true, + { + timeout: WAIT_TIMEOUT_MS, + } + ); + + console.log( + "Socket connected:", + await page.evaluate(() => ({ + connected: window.__csSocket.connected, + id: window.__csSocket.id, + })) + ); +} + +async function withAuthCookie(page) { + const hostname = new URL(BASE_URL).hostname; + + await page.setCookie({ + name: "accessToken", + value: TOKEN, + domain: hostname, + path: "/", + }); +} + +function percentile(values, p) { + if (!values.length) return null; + + const index = + Math.ceil((p / 100) * values.length) - 1; + + return values[ + Math.min( + Math.max(index, 0), + values.length - 1 + ) + ]; +} + +/* + * --------------------------------------------------------- + * BROADCASTER + * --------------------------------------------------------- + */ + +async function launchBroadcaster(browser) { + const context = + browser.defaultBrowserContext(); + + await context.overridePermissions( + BASE_URL, + ["camera", "microphone"] + ); + + const page = await browser.newPage(); + await login(page); + + page.on("response", async (response) => { + const request = response.request(); + + await page.waitForFunction( + () => window.__csSocket, + { timeout: 10000 } + ); + + const socketState = await page.evaluate(() => ({ + connected: window.__csSocket.connected, + id: window.__csSocket.id, + })); + + console.log("SOCKET STATE:", socketState); + + if ( + request.method() !== "GET" || + response.status() >= 400 + ) { + console.log( + `[HTTP ${response.status()}] ${request.method()} ${response.url()}` + ); + + try { + const body = await response.text(); + console.log( + `[HTTP BODY] ${body.slice(0, 1000)}` + ); + } catch {} + } + }); + + /* + * IMPORTANT: + * Show us what the React application is actually doing. + */ + + page.on("console", (msg) => { + console.log( + `[BROADCASTER ${msg.type()}] ${msg.text()}` + ); + }); + + page.on("pageerror", (error) => { + console.error( + `[BROADCASTER PAGE ERROR] ${error.message}` + ); + }); + + page.on("requestfailed", (request) => { + console.error( + `[REQUEST FAILED] ${request.method()} ${request.url()}` + ); + + console.error( + `Reason: ${request.failure()?.errorText}` + ); + }); + + // await withAuthCookie(page); + + console.log( + `Opening broadcaster: ${BASE_URL}/broadcaster` + ); + + await page.goto( + `${BASE_URL}/broadcaster`, + { + waitUntil: "networkidle2", + timeout: 30000, + } + ); + + console.log( + "Broadcaster page loaded." + ); + + console.log("\n========== PAGE DEBUG =========="); + + const debug = await page.evaluate(() => ({ + url: window.location.href, + title: document.title, + body: document.body.innerText, + buttons: Array.from(document.querySelectorAll("button")).map( + (b) => ({ + text: b.innerText, + disabled: b.disabled, + }) + ), + })); + + console.log("URL:", debug.url); + console.log("TITLE:", debug.title); + + console.log("\nBUTTONS:"); + console.log(JSON.stringify(debug.buttons, null, 2)); + + console.log("\nPAGE TEXT:"); + console.log(debug.body); + + console.log("========== END DEBUG ==========\n"); + + /* + * Make sure the Go Live button actually exists. + */ + + await page.waitForFunction( + () => + Array.from( + document.querySelectorAll("button") + ).some((button) => + button.textContent?.includes("Go live") + ), + { + timeout: WAIT_TIMEOUT_MS, + } + ); + + console.log( + '"Go live" button found.' + ); + + /* + * Click Go Live. + */ + + await page.evaluate(() => { + const button = Array.from( + document.querySelectorAll("button") + ).find((button) => + button.textContent?.includes("Go live") + ); + + if (!button) { + throw new Error( + "Go live button disappeared" + ); + } + + button.click(); + }); + + console.log( + 'Clicked "Go live".' + ); + + /* + * Now wait for the room marker. + * + * Your BroadcasterPage does: + * + * setRoomId(room.id) + * window.__csRoomId = room.id + * + * AFTER createRoom() succeeds. + */ + + try { + await page.waitForFunction( + () => Boolean(window.__csRoomId), + { + timeout: WAIT_TIMEOUT_MS, + } + ); + } catch (error) { + console.error( + "\n========================================" + ); + + console.error( + "BROADCASTER DID NOT CREATE A ROOM" + ); + + console.error( + "========================================" + ); + + console.error( + "Current URL:", + page.url() + ); + + /* + * Print the visible logs from your broadcaster UI. + */ + + const logs = await page.evaluate(() => { + const text = document.body.innerText; + + return text.slice(-5000); + }); + + console.error( + "\nBrowser page text:\n" + ); + + console.error(logs); + + /* + * Save a screenshot so we can see exactly + * what Puppeteer saw. + */ + + await page.screenshot({ + path: "broadcaster-failure.png", + fullPage: true, + }); + + console.error( + "\nScreenshot saved as:" + ); + + console.error( + "broadcaster-failure.png" + ); + + throw new Error( + "Broadcaster did not expose window.__csRoomId" + ); + } + + const roomId = await page.evaluate( + () => window.__csRoomId + ); + + console.log( + `\nROOM CREATED SUCCESSFULLY` + ); + + console.log( + `Room ID: ${roomId}` + ); + + /* + * Wait for your __csLiveAt marker. + */ + + try { + await page.waitForFunction( + () => Boolean(window.__csLiveAt), + { + timeout: WAIT_TIMEOUT_MS, + } + ); + + console.log( + "Broadcaster is LIVE." + ); + } catch { + console.warn( + "Room exists, but __csLiveAt was not detected." + ); + } + + return { + page, + roomId, + }; +} + +/* + * --------------------------------------------------------- + * VIEWER + * --------------------------------------------------------- + */ + +async function launchViewer( + browser, + roomId, + index +) { + const page = await browser.newPage(); + + page.on("console", (msg) => { + console.log( + `[VIEWER ${index} ${msg.type()}] ${msg.text()}` + ); + }); + + page.on("pageerror", (error) => { + console.error( + `[VIEWER ${index} PAGE ERROR] ${error.message}` + ); + }); + + page.on("requestfailed", (request) => { + console.error( + `[VIEWER ${index} REQUEST FAILED] ${request.url()}` + ); + }); + + page.on("websocketcreated", (ws) => { + console.error("[WS CREATED]", ws.url()); + }); + + page.on("websocketclosed", (ws) => { + console.error("[WS CLOSED]", ws.url()); + }); + + page.on("websocketframereceived", (ws, frame) => { + console.error("[WS RX]", frame.slice(0, 300)); + }); + + page.on("websocketframesent", (ws, frame) => { + console.error("[WS TX]", frame.slice(0, 300)); + }); + + // await withAuthCookie(page); + await login(page) + + const startTime = Date.now(); + + try { + await page.goto( + `${BASE_URL}/viewer?roomId=${roomId}`, + { + waitUntil: "domcontentloaded", + timeout: 30000, + } + ); + + await page.waitForSelector( + "form", + { + timeout: WAIT_TIMEOUT_MS, + } + ); + + /* + * Submit the viewer form. + */ + + await page.$eval( + "form", + (form) => { + form.requestSubmit(); + } + ); + + /* + * Wait for your ViewerPage marker. + */ + + let joinedAt = null; + + try { + await page.waitForFunction( + () => Boolean(window.__csJoinedAt), + { + timeout: WAIT_TIMEOUT_MS, + } + ); + + joinedAt = await page.evaluate( + () => window.__csJoinedAt + ); + } catch {} + + /* + * Wait for actual first video frame. + */ + + let firstFrameAt = null; + + try { + await page.waitForFunction( + () => + Boolean( + window.__csFirstFrameAt + ), + { + timeout: WAIT_TIMEOUT_MS, + } + ); + + firstFrameAt = await page.evaluate( + () => window.__csFirstFrameAt + ); + } catch {} + + return { + index, + page, + + joined: Boolean(joinedAt), + + joinLatencyMs: joinedAt + ? joinedAt - startTime + : null, + + firstFrameLatencyMs: + firstFrameAt + ? firstFrameAt - startTime + : null, + + error: null, + }; + } catch (error) { + return { + index, + page, + + joined: false, + + joinLatencyMs: null, + + firstFrameLatencyMs: null, + + error: error.message, + }; + } +} + +/* + * --------------------------------------------------------- + * MAIN + * --------------------------------------------------------- + */ + +async function main() { + console.log( + "========================================" + ); + + console.log( + "CrowdStream REAL SFU MEDIA TEST" + ); + + console.log( + "========================================" + ); + + console.log( + `Frontend: ${BASE_URL}` + ); + + console.log( + `Batch size: ${BATCH_SIZE}` + ); + + console.log( + `Batch interval: ${BATCH_INTERVAL_MS}ms` + ); + + console.log( + `Max viewers: ${MAX_VIEWERS}` + ); + + console.log( + "Backend CPU: not measured yet" + ); + + console.log(""); + + /* + * Launch Chromium. + */ + + const browser = + await puppeteer.launch({ + headless: true, + protocolTimeout: 120000, + args: CHROME_FLAGS, + }); + + try { + /* + * STEP 1 + * + * Create a REAL broadcaster. + */ + + const broadcaster = + await launchBroadcaster( + browser + ); + + const roomId = + broadcaster.roomId; + + /* + * Give mediasoup a moment to stabilize. + */ + + console.log( + "\nWaiting 5 seconds for media..." + ); + + await new Promise( + (resolve) => + setTimeout(resolve, 5000) + ); + + /* + * CSV. + */ + + const csvRows = [ + [ + "timestamp", + "viewers", + "joinP50", + "joinP99", + "firstFrameP50", + "firstFrameP99", + "failures", + ].join(","), + ]; + + const viewers = []; + + /* + * STEP 2 + * + * Add viewers in batches. + */ + + for ( + let target = BATCH_SIZE; + target <= MAX_VIEWERS; + target += BATCH_SIZE + ) { + console.log( + `\n========================================` + ); + + console.log( + `ADDING VIEWERS: ${target}` + ); + + console.log( + `========================================` + ); + + const batch = + await Promise.all( + Array.from( + { + length: BATCH_SIZE, + }, + (_, index) => + launchViewer( + browser, + roomId, + viewers.length + + index + ) + ) + ); + + viewers.push(...batch); + + /* + * Join latency. + */ + + const joinLatencies = + batch + .map( + (viewer) => + viewer.joinLatencyMs + ) + .filter(Number.isFinite) + .sort( + (a, b) => a - b + ); + + /* + * First frame latency. + */ + + const firstFrameLatencies = + batch + .map( + (viewer) => + viewer.firstFrameLatencyMs + ) + .filter(Number.isFinite) + .sort( + (a, b) => a - b + ); + + const failed = + batch.filter( + (viewer) => + !viewer.joined + ).length; + + failures += failed + + const joinP50 = + percentile( + joinLatencies, + 50 + ); + + const joinP99 = + percentile( + joinLatencies, + 99 + ); + + const frameP50 = + percentile( + firstFrameLatencies, + 50 + ); + + const frameP99 = + percentile( + firstFrameLatencies, + 99 + ); + + console.log( + `\nViewers: ${viewers.length}` + ); + + console.log( + `Join P50: ${joinP50 ?? "n/a"} ms` + ); + + console.log( + `Join P99: ${joinP99 ?? "n/a"} ms` + ); + + console.log( + `First frame P50: ${ + frameP50 ?? "n/a" + } ms` + ); + + console.log( + `First frame P99: ${ + frameP99 ?? "n/a" + } ms` + ); + + console.log( + `Failures: ${failures}` + ); + + /* + * Save results. + */ + + csvRows.push( + [ + new Date().toISOString(), + viewers.length, + joinP50 ?? "n/a", + joinP99 ?? "n/a", + frameP50 ?? "n/a", + frameP99 ?? "n/a", + failures, + ].join(",") + ); + + fs.writeFileSync( + OUT_CSV, + csvRows.join("\n") + ); + + /* + * Wait before next batch. + */ + + if ( + target < MAX_VIEWERS + ) { + console.log( + `\nWaiting ${ + BATCH_INTERVAL_MS / 1000 + } seconds...` + ); + + await new Promise( + (resolve) => + setTimeout( + resolve, + BATCH_INTERVAL_MS + ) + ); + } + } + + console.log( + `\n========================================` + ); + + console.log( + "SFU TEST COMPLETE" + ); + + console.log( + `Results: ${OUT_CSV}` + ); + + console.log( + `========================================` + ); + } finally { + await browser.close(); + } +} + +main().catch((error) => { + console.error( + "\nTEST FAILED:" + ); + + console.error(error); + + process.exit(1); +}); \ No newline at end of file