chuck.js/scripts/webrtc-browser-test.js
Jeena a0481ed867 Route gameCommand traffic through WebRTC unreliable DataChannel
Socket.IO (TCP) holds back later packets while it retransmits a lost
one, which stalls worldUpdate delivery on lossy long-distance links —
exactly the pattern game state suffers worst from. WebRTC DataChannels
in unreliable mode (ordered:false, maxRetransmits:0) drop late packets
instead of queueing them, which is what we want for high-frequency
state sync.

Adds a per-user WebRTCTransport on top of the existing Socket.IO
connection. Socket.IO stays in charge of bootstrap, signaling
(SDP/ICE exchange), and control messages — only gameCommand payloads
get routed onto the unreliable channel once it's open. If WebRTC
fails to negotiate, gameCommand transparently falls back to
Socket.IO, so the game keeps working unchanged.

A new StatsLogger writes per-session JSONL events (session_start,
webrtc_ready with negotiation time, per-second stats with transport,
RTT samples, recv/send rates, seq gaps) so we can compare real-world
runs (e.g. Germany server <-> Korea client) instead of guessing.
URL flag ?webrtc=0 forces fallback for A/B testing.

scripts/webrtc-browser-test.js spins up a headless Chromium against
a freshly-started server and asserts the unreliable channel opens
and gameCommand traffic actually rides it.
2026-05-11 00:38:18 +00:00

185 lines
7.7 KiB
JavaScript

// Headless-Chromium smoke test of the integrated WebRTC transport.
//
// Spawns the chuck.js server, creates a channel via the API, launches Chromium,
// loads the game, watches the DOM "transport" indicator and the runs/*.jsonl
// log to confirm the unreliable DataChannel actually opened end-to-end.
"use strict";
const { chromium } = require("playwright");
const { spawn } = require("child_process");
const http = require("http");
const fs = require("fs");
const path = require("path");
const SERVER_URL = "http://localhost:1234";
const CHANNEL = "browsersmoke";
function apiCall(body) {
return new Promise((resolve, reject) => {
const data = JSON.stringify(body);
const req = http.request({
host: "localhost", port: 1234, path: "/api", method: "POST",
headers: { "Content-Type": "application/json", "Content-Length": data.length }
}, (res) => {
let chunks = "";
res.on("data", (c) => (chunks += c));
res.on("end", () => resolve(JSON.parse(chunks)));
});
req.on("error", reject);
req.write(data); req.end();
});
}
async function waitForServerReady() {
for (let i = 0; i < 30; i++) {
try {
const r = await apiCall({ command: "getMaps" });
if (r.success) return;
} catch (_) {}
await new Promise((r) => setTimeout(r, 200));
}
throw new Error("server not ready");
}
function fileLog(pid, label, line) {
console.log("[" + label + ":" + pid + "] " + line.trim());
}
async function main() {
const srvLogPath = "/tmp/chuck-srv.log";
fs.writeFileSync(srvLogPath, "");
const srv = spawn("node", ["server.js"], {
cwd: process.cwd(),
stdio: ["ignore", fs.openSync(srvLogPath, "a"), fs.openSync(srvLogPath, "a")],
env: process.env
});
console.log("[test] server pid=" + srv.pid);
try {
await waitForServerReady();
console.log("[test] server up");
const r = await apiCall({
command: "createChannel",
options: { channelName: CHANNEL, levelUids: ["debug"], maxUsers: 4, minUsers: 1, scoreLimit: 5 }
});
if (!r.success) throw new Error("createChannel failed: " + JSON.stringify(r));
console.log("[test] channel created");
// Find the run file the server just opened.
await new Promise((r) => setTimeout(r, 500));
const runFiles = fs.readdirSync("runs").filter((f) => f.endsWith(".jsonl"))
.map((f) => ({ f, t: fs.statSync(path.join("runs", f)).mtimeMs }))
.sort((a, b) => b.t - a.t);
const runFile = path.join("runs", runFiles[0].f);
console.log("[test] run file: " + runFile);
const browser = await chromium.launch({
executablePath: "/usr/sbin/chromium",
headless: true,
args: ["--no-sandbox", "--disable-setuid-sandbox", "--use-fake-ui-for-media-stream"]
});
const ctx = await browser.newContext();
const page = await ctx.newPage();
page.on("console", (msg) => console.log("[browser]", msg.type(), msg.text()));
page.on("pageerror", (err) => console.error("[browser ERR]", err.message));
const url = SERVER_URL + "/#" + CHANNEL;
console.log("[test] navigating to " + url);
await page.goto(url, { waitUntil: "load" });
// Diagnostics: dump form visibility states and the latest /api response.
await page.waitForTimeout(2000);
const diag = await page.evaluate(() => ({
joinDisplay: getComputedStyle(document.getElementById("customjoinform")).display,
createDisplay: getComputedStyle(document.getElementById("createform")).display,
listDisplay: getComputedStyle(document.getElementById("listform")).display,
hash: window.location.hash,
customname: document.getElementById("customname").value
}));
console.log("[test] form states:", JSON.stringify(diag));
// If the hashchange didn't auto-show the form, force the showCustomJoinForm path.
if (diag.joinDisplay !== "block") {
console.log("[test] forcing showCustomJoinForm");
await page.evaluate(() => {
document.getElementById("customname").value = location.hash.substr(1);
document.getElementById("customjoinform").style.display = "block";
});
}
await page.waitForSelector("#customjoinform", { state: "visible", timeout: 8000 });
console.log("[test] customjoinform visible");
// Fill in nickname and submit the form.
await page.evaluate(() => {
document.getElementById("nick").value = "browser-bot";
const form = document.getElementById("customjoinform");
// The form's onsubmit returns false to prevent navigation. Calling
// onsubmit() directly invokes the join handler.
form.onsubmit({ preventDefault: () => {} });
});
console.log("[test] join submitted");
// Wait up to 12s for the transport indicator to switch to "webrtc",
// or for the JSONL to show a webrtc_ready event.
const deadline = Date.now() + 12000;
let webrtcReady = false;
let transportText = "";
while (Date.now() < deadline) {
await page.waitForTimeout(500);
transportText = await page.evaluate(() => {
const labels = Array.from(document.querySelectorAll("label"));
const t = labels.find((l) => /^transport:/.test(l.textContent || ""));
return t ? t.textContent : "";
}).catch(() => "");
const log = fs.readFileSync(runFile, "utf8");
if (/webrtc_ready/.test(log) || /transport:webrtc/.test(transportText)) {
webrtcReady = true;
break;
}
}
console.log("[test] transport indicator: '" + transportText + "'");
console.log("[test] webrtc_ready event seen: " + (webrtcReady ? "YES" : "NO"));
// Wait a bit more to collect stats events and verify gameCommand
// traffic is actually flowing.
await page.waitForTimeout(3000);
const events = fs.readFileSync(runFile, "utf8")
.split("\n").filter(Boolean).map((l) => { try { return JSON.parse(l); } catch (_) { return null; } })
.filter(Boolean);
const stats = events.filter((e) => e.type === "stats");
const lastStat = stats[stats.length - 1];
console.log("[test] stats events received: " + stats.length);
if (lastStat) {
console.log("[test] last stats: transport=" + lastStat.transport +
" recvRate=" + lastStat.recvRate +
" sendRate=" + lastStat.sendRate);
}
const webrtcInUse = lastStat && lastStat.transport === "webrtc";
const traffic = lastStat && lastStat.recvRate > 0;
const logTail = fs.readFileSync(runFile, "utf8").split("\n").filter(Boolean).slice(-10);
console.log("[test] last JSONL events:");
for (const l of logTail) console.log(" " + l);
await browser.close();
const pass = webrtcReady && webrtcInUse && traffic;
console.log("\nRESULT: " + (pass ? "PASS" : "FAIL") +
" (webrtc_ready=" + webrtcReady + " webrtcInUse=" + !!webrtcInUse +
" trafficFlowing=" + !!traffic + ")");
process.exitCode = pass ? 0 : 1;
} finally {
srv.kill("SIGINT");
await new Promise((r) => setTimeout(r, 500));
srv.kill("SIGKILL");
const tail = fs.readFileSync(srvLogPath, "utf8").split("\n").slice(-30).join("\n");
console.log("\n--- server log tail ---\n" + tail);
}
}
main().catch((e) => { console.error("[test] fatal:", e); process.exit(2); });