Files
Piik/scripts/client-browser-fanout-gate.ts
T

298 lines
17 KiB
TypeScript

import { spawn, spawnSync, type ChildProcessWithoutNullStreams } from "node:child_process";
import { mkdtemp, mkdir, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import {
CdpConnection, cleanupRun, createPage, evaluate, fetchJsonBefore, launchChrome, reservePort,
waitForSample, waitForVersion, type PageHandle,
} from "./browser-gate-harness";
import { decodeClientEndpoint } from "./client-gate-endpoint";
const ROOT = resolve(import.meta.dirname, "..");
const BUILD_ROOT = join(ROOT, "build/client-check");
// A documentation-only LAN address: it makes the room server publish a LAN
// invitation the gate rewrites to its loopback origins, exactly as the packaged
// Client does on a real network.
const LAN_ADDRESS = "192.0.2.1";
const initialAudio = process.env.PIIK_CLIENT_GATE_AUDIO !== "false";
const probe = String.raw`(() => {
localStorage.setItem('piik:ui-lang', 'en');
localStorage.setItem('piik:ui-mode', 'text');
const pcs = [], requests = [], sockets = [], errors = [];
window.addEventListener('unhandledrejection', (e) => errors.push(String(e.reason)));
const codecPreferences = RTCRtpTransceiver.prototype.setCodecPreferences;
RTCRtpTransceiver.prototype.setCodecPreferences = function(value) {
try { return codecPreferences.call(this,value); }
catch (error) { errors.push(String(error)); throw error; }
};
const PC = RTCPeerConnection, WS = WebSocket;
window.RTCPeerConnection = class extends PC { constructor(...args) { super(...args); pcs.push(this); } };
window.WebSocket = class extends WS {
constructor(...args) { super(...args); if (String(args[0]).includes('/control')) {
sockets.push(this);
} }
send(raw) { if (this.url.includes('/control')) { const value = JSON.parse(raw); requests.push(value.type); } super.send(raw); }
};
let audio = ${initialAudio}, captures = 0;
navigator.mediaDevices.getDisplayMedia = async () => {
captures++;
const canvas = document.createElement('canvas'); canvas.width = 1920; canvas.height = 1080;
const context = canvas.getContext('2d'); let frame = 0;
const draw = () => { context.fillStyle = '#14242a'; context.fillRect(0,0,1920,1080);
context.fillStyle = '#68dab3'; context.fillRect((frame++ * 17)%1800,100,120,400);
context.fillStyle = '#ffffff'; context.font = '80px sans-serif'; context.fillText(String(frame),100,700); };
draw(); const timer = setInterval(draw, 1000/30);
const stream = canvas.captureStream(30);
const track = stream.getVideoTracks()[0]; const stop = track.stop.bind(track);
track.stop = () => { clearInterval(timer); stop(); };
if (audio) {
const ac = new AudioContext(), osc = ac.createOscillator(), dest = ac.createMediaStreamDestination();
osc.frequency.value = 440; osc.connect(dest); osc.start(); await ac.resume();
const sound = dest.stream.getAudioTracks()[0], stopSound = sound.stop.bind(sound);
let soundStopped=false;
sound.stop = () => { if (soundStopped) return; soundStopped=true; osc.stop(); void ac.close(); stopSound(); };
stream.addTrack(sound);
}
return stream;
};
window.fanoutGate = {
audio(value) { audio = value; },
breakIngress() { const pc = pcs.find((p) => p.connectionState === 'connected' && p.getSenders().some((s) => s.track?.kind === 'video'));
pc?.close(); },
async sample() {
let outbound = 0, encoded = 0, received = 0;
const videoStats=[], feedback=[];
for (const pc of pcs) { if (pc.connectionState === 'closed') continue;
const stats = await pc.getStats(); stats.forEach((s) => {
if (s.type==='remote-inbound-rtp') feedback.push({kind:s.kind,lost:s.packetsLost,fraction:s.fractionLost,rtt:s.roundTripTime,reports:s.reportsReceived});
if (s.type==='candidate-pair' && s.nominated) feedback.push({rtt:s.currentRoundTripTime,bwe:s.availableOutgoingBitrate,sent:s.bytesSent,received:s.bytesReceived});
if (s.type === 'outbound-rtp' && s.kind === 'video' && s.framesEncoded > 0) { outbound++; encoded += s.framesEncoded;
const source=stats.get(s.mediaSourceId);
videoStats.push({width:s.frameWidth,height:s.frameHeight,fps:s.framesPerSecond,reason:s.qualityLimitationReason,
sourceFps:source?.framesPerSecond,target:s.targetBitrate,encoder:s.encoderImplementation}); }
if (s.type === 'inbound-rtp' && s.kind === 'video') {
received += s.framesDecoded || 0;
feedback.push({inbound:true,fps:s.framesPerSecond,receivedFrames:s.framesReceived,decodedFrames:s.framesDecoded,
dropped:s.framesDropped,lost:s.packetsLost,received:s.packetsReceived,nack:s.nackCount,pli:s.pliCount,
decodeTime:s.totalDecodeTime,buffer:s.jitterBufferDelay,bufferEmitted:s.jitterBufferEmittedCount});
}
}); }
const video = document.querySelector('video');
return { outbound, encoded, received, captures, videoStats, feedback, width: video?.videoWidth || 0,
requests, errors, peers: pcs.map((p) => ({state:p.connectionState, tracks:p.getSenders().map((s)=>s.track?.kind)})),
frames: video?.getVideoPlaybackQuality().totalVideoFrames || 0,
sharing: Boolean(document.querySelector('button[aria-label="Stop sharing"]')),
captureLive: Boolean(video?.srcObject?.getVideoTracks()[0]?.readyState === 'live'),
capturesEnabled: video?.srcObject?.getVideoTracks()[0]?.enabled,
receiveRequests: requests.filter((r) => r === 'receive-offer').length,
edgeRequests: requests.filter((r) => r === 'prepare-edge').length,
controlOpen: sockets.some((s) => s.readyState === WS.OPEN),
codecSelected: [...document.querySelectorAll('button.lr-chip.is-selected')].map((b) => b.textContent),
audio: video?.srcObject?.getAudioTracks().length || 0,
buttons: [...document.querySelectorAll('.lr-host-share-slot button')].map((b) => b.getAttribute('aria-label')) };
}
};
})()`;
function build(command: string, args: string[]): void {
const result = spawnSync(command, args, { cwd: ROOT, encoding: "utf8", windowsHide: true });
if (result.error) throw result.error;
if (result.status !== 0) {
throw new Error((result.stderr || result.stdout).trim() || command + " failed");
}
}
// startRoomServer builds and starts the Piik server the Browser pages and
// both Clients share. PIIK_ENV stays development because production
// requires an https public origin, a site password and STUN; the Web assets are
// embedded either way.
async function startRoomServer(
port: number,
workingDirectory: string,
): Promise<ChildProcessWithoutNullStreams> {
const go = process.env.PIIK_GO?.trim() || "go";
await mkdir(BUILD_ROOT, { recursive: true });
// The server embeds the Vite output, so the Web build precedes the Go build.
build(process.env.ComSpec || "cmd.exe", ["/d", "/s", "/c", "npm run build:client"]);
const binary = join(BUILD_ROOT, "piik-server.exe");
build(go, ["build", "-trimpath", "-o", binary, "./cmd/piik-server"]);
const origins = [`http://localhost:${port}`, `http://127.0.0.1:${port}`, `http://${LAN_ADDRESS}:${port}`];
const child = spawn(binary, [], {
// The gate profile has no .env, and the environment is explicit: the
// configuration this measurement runs against is these five values, never
// the repository's development file or the operator's shell.
cwd: workingDirectory, stdio: "pipe", windowsHide: true,
env: {
PATH: process.env.PATH,
SystemRoot: process.env.SystemRoot,
PIIK_ENV: "development",
ROOM_DATABASE_PATH: ":memory:",
PORT: String(port),
LISTEN_HOST: "127.0.0.1",
PUBLIC_BASE_URL: `http://${LAN_ADDRESS}:${port}`,
ALLOWED_ORIGINS: origins.join(","),
},
});
child.stdout.resume();
child.stderr.resume();
await waitForSample(
(deadline) => fetchJsonBefore<{ status: string }>(`http://127.0.0.1:${port}/healthz`, deadline),
(value) => value.status === "ok",
20000,
);
return child;
}
async function main(): Promise<void> {
if (process.env.PIIK_CLIENT_BROWSER_FANOUT_GATE !== "true") throw new Error("Browser fanout gate was not enabled");
const chromePath = process.env.CHROME_PATH;
if (!chromePath) throw new Error("CHROME_PATH is required");
const profile = await mkdtemp(join(tmpdir(), "piik-client-media-"));
const appPort = await reservePort(), debugPort = await reservePort();
const origin = `http://localhost:${appPort}`;
let client: ChildProcessWithoutNullStreams | null = null;
let relayClient: ChildProcessWithoutNullStreams | null = null;
let chrome: ChildProcessWithoutNullStreams | null = null;
let cdp: CdpConnection | null = null;
let server: ChildProcessWithoutNullStreams | null = null;
let clientPort = 0;
let relayPort = 0;
const pages: PageHandle[] = [];
const checks: Record<string, unknown> = {};
let stage = "startup", error: string | null = null;
try {
server = await startRoomServer(appPort, profile);
client = spawn(process.env.PIIK_CLIENT_EXE || join(BUILD_ROOT, "piik-app.exe"), [
"--site", origin, "--config", join(profile, "client.json"),
"--capture-process", join(BUILD_ROOT, "piik-capture.exe"),
], { windowsHide: true, stdio: "pipe", env: { ...process.env, PIIK_CLIENT_GATE_NO_BROWSER: "true" } });
client.stderr.resume();
let output = "";
client.stdout.on("data", (chunk: Buffer) => {
output += chunk.toString();
if (!clientPort && output.includes("\n")) clientPort = decodeClientEndpoint(output.split("\n")[0]!).port;
});
await waitForSample(async () => clientPort, (value) => value > 0, 15000);
chrome = launchChrome(chromePath, debugPort, profile, [
"--headless=new",
"--no-first-run", "--no-default-browser-check", "--no-proxy-server",
"--autoplay-policy=no-user-gesture-required", "--disable-background-timer-throttling",
"--disable-renderer-backgrounding", "--disable-backgrounding-occluded-windows",
]);
chrome.stdout.resume(); chrome.stderr.resume();
const version = await waitForVersion(debugPort, chrome);
checks.browser = version.Browser;
cdp = await CdpConnection.connect(version.webSocketDebuggerUrl, Date.now() + 10000);
await cdp.call("Browser.setPermission", { origin, permission: {name:"loopback-network"}, setting:"granted" }, undefined, Date.now()+5000);
const host = await createPage(cdp, `${origin}/#piik-client=1`, probe);
pages.push(host);
const read = async (page: PageHandle) => {
const sample = await evaluate<any>(cdp!, page, "fanoutGate.sample()", Date.now()+5000);
if (page === host) checks.lastHost = sample;
return sample;
};
const click = async (page: PageHandle, selector: string) => {
await waitForSample((deadline) => evaluate<boolean>(cdp!, page,
`Boolean(document.querySelector(${JSON.stringify(selector)}))`, deadline), Boolean, 10000);
await evaluate(cdp!, page, `document.querySelector(${JSON.stringify(selector)}).click()`, Date.now()+5000);
};
stage = "share";
await click(host, 'button[aria-controls="host-advanced-door"]');
stage = "share-codec";
await click(host, 'button[aria-label^="H264"]');
stage = "share-picker";
await click(host, 'button.lr-tv-big.is-action');
stage = "share-browser-capture";
await click(host, 'button[data-source-tab="browser"]');
await click(host, '.lr-source-option.is-browser');
stage = "share-ingress";
await waitForSample(() => read(host), (s) => s.sharing && s.receiveRequests === 1 && s.outbound === 1, 30000);
let invite = await evaluate<string>(cdp, host, "document.querySelector('.lr-invite-url').value", Date.now()+5000);
const url = new URL(invite); url.host = `localhost:${appPort}`; invite = url.toString();
const relayOrigin = `http://127.0.0.1:${appPort}`;
relayClient = spawn(process.env.PIIK_CLIENT_EXE || join(BUILD_ROOT,"piik-app.exe"), [
"--site", relayOrigin, "--config", join(profile,"relay-client.json"),
"--capture-process", join(BUILD_ROOT,"piik-capture.exe"),
], { windowsHide:true, stdio:"pipe", env:{...process.env,PIIK_CLIENT_GATE_NO_BROWSER:"true"} });
relayClient.stderr.resume(); let relayOutput="";
relayClient.stdout.on("data", (chunk:Buffer) => {
relayOutput+=chunk.toString();
if (!relayPort && relayOutput.includes("\n")) relayPort=decodeClientEndpoint(relayOutput.split("\n")[0]!).port;
});
await waitForSample(async()=>relayPort,(value)=>value>0,15000);
const viewers: PageHandle[] = [];
for (let index=0; index<2; index++) {
stage = `viewer-${index+1}`;
const context = await cdp.call<{browserContextId: string}>("Target.createBrowserContext", {}, undefined, Date.now()+5000);
await cdp.call("Browser.setPermission", { origin:index===0?relayOrigin:origin, permission: {name:"loopback-network"}, setting:"granted", browserContextId: context.browserContextId }, undefined, Date.now()+5000);
const viewerURL = index===0 ? invite.replace(origin,relayOrigin)+"&piik-client=1" : invite;
const viewer = await createPage(cdp, viewerURL, probe, true, context.browserContextId);
viewers.push(viewer);
pages.push(viewer);
await waitForSample(() => read(viewer), (s) => s.width > 0 && s.frames >= 30, 30000);
}
stage = "shared-encode";
await waitForSample(() => read(viewers[0]!), (s) => s.width===1920 && s.outbound===0, 30000);
await waitForSample(() => read(viewers[1]!), (s) => s.width===1920 && s.outbound===0, 30000);
checks.host = await read(host);
checks.viewers = await Promise.all(viewers.map(read));
if ((checks.host as any).outbound !== 1 || !(checks.viewers as any[]).every((v) => v.outbound === 0)) {
throw new Error("Shared encode count did not match the Native fanout");
}
const beforeSteady = await Promise.all(viewers.map(read));
const steadyStart = Date.now();
await waitForSample(async () => Date.now()-steadyStart, (elapsed) => elapsed>=5000, 6000);
const afterSteady = await Promise.all(viewers.map(read));
checks.viewerFps = afterSteady.map((sample,index) => (sample.received-beforeSteady[index].received)*1000/(Date.now()-steadyStart));
if (!(checks.viewerFps as number[]).every((fps) => fps>=27)) throw new Error("Viewer delivery did not sustain the 30 fps source");
stage = "quality";
await click(host, 'button[aria-label="720p"]');
await waitForSample(() => read(viewers[0]!), (s) => s.width === 1280, 20000);
checks.liveQuality = true;
stage = "pause-resume";
await click(host, 'button[aria-label="Pause sharing"]');
await waitForSample(() => read(host), (s) => s.capturesEnabled === false, 5000);
await click(host, 'button[aria-label="Resume sharing"]');
const before = await read(viewers[0]!);
await waitForSample(() => read(viewers[0]!), (s) => s.frames >= before.frames+20, 10000);
checks.pauseResume = true;
stage = "source-audio-change";
await evaluate(cdp, host, `fanoutGate.audio(${!initialAudio})`, Date.now()+5000);
await click(host, 'button[aria-label="Switch source"]');
await waitForSample(() => read(host), (s) => s.receiveRequests === 2 && s.captures === 2 && s.sharing, 15000);
const beforeSource = await read(viewers[1]!);
await waitForSample(() => read(viewers[1]!), (s) => s.width > 0 && s.frames >= beforeSource.frames+20 && s.audio===Number(!initialAudio), 25000);
checks.sourceAudioChange = true;
stage = "client-exit";
client.stdin.write("\n");
await waitForSample(() => read(host), (s) => !s.controlOpen && s.sharing && s.captureLive && s.outbound === 2, 25000);
for (const viewer of viewers) {
const prior = await read(viewer);
await waitForSample(() => read(viewer), (s) => s.width > 0 && s.frames >= prior.frames+15, 30000);
}
checks.clientExitRecovery = true;
checks.finalHost = await read(host);
if ((checks.finalHost as any).errors.length > 0) throw new Error("Browser reported an unhandled media error");
await mkdir(BUILD_ROOT, {recursive:true});
const screenshot = await cdp.call<{data:string}>("Page.captureScreenshot", {format:"png"}, host.sessionId, Date.now()+5000);
await writeFile(join(BUILD_ROOT,"browser-fanout.png"), Buffer.from(screenshot.data,"base64"));
await click(host, 'button[aria-label="Stop sharing"]');
stage = "done";
} catch (caught) {
error = caught instanceof Error ? caught.message : String(caught);
if (cdp) checks.atFailure = await Promise.all(pages.map((page) =>
evaluate(cdp!,page,"fanoutGate.sample()",Date.now()+5000).catch(()=>null)));
} finally {
const relayCleanup = await cleanupRun({cdp:null,native:relayClient,chrome:null,server:null,profile:null,ports:relayPort?[relayPort]:[]});
const cleanup = await cleanupRun({cdp, native:client, chrome, server, profile, ports:[appPort,debugPort,...(clientPort?[clientPort]:[])]});
const result = {passed: error===null && Object.values(cleanup).every(Boolean) && relayCleanup.nativeExited && relayCleanup.portsClosed, stage, checks, error, cleanup};
await mkdir(BUILD_ROOT,{recursive:true});
await writeFile(join(BUILD_ROOT,"browser-fanout.json"), JSON.stringify(result,null,2));
process.stdout.write(JSON.stringify(result)+"\n");
if (!result.passed) process.exitCode=1;
}
}
await main();