mirror of
https://github.com/logsol/chuck.js.git
synced 2026-05-11 10:37:34 +00:00
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.
185 lines
7.7 KiB
JavaScript
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); });
|