161 lines
7.4 KiB
JavaScript
161 lines
7.4 KiB
JavaScript
// What the end-to-end tests share: a static server for the pages, the relay on loopback
|
|
// from a test configuration, the host's composition (wai_live_web), and the browser.
|
|
//
|
|
// Every address is loopback, and every wait has a hard bound.
|
|
|
|
import http from "node:http";
|
|
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import os from "node:os";
|
|
import { spawn, execFileSync } from "node:child_process";
|
|
import crypto from "node:crypto";
|
|
|
|
export const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
|
|
export async function until(what, ms, f, every = 250) {
|
|
const end = Date.now() + ms;
|
|
let last;
|
|
for (;;) {
|
|
try {
|
|
last = await f();
|
|
if (last) return last;
|
|
} catch (e) {
|
|
last = e;
|
|
}
|
|
if (Date.now() > end) throw new Error(`${what}: not within ${ms} ms (last: ${last instanceof Error ? last.message : JSON.stringify(last)?.slice(0, 600)})`);
|
|
await sleep(every);
|
|
}
|
|
}
|
|
|
|
const MIME = { ".html": "text/html; charset=utf-8", ".js": "text/javascript; charset=utf-8", ".mjs": "text/javascript; charset=utf-8", ".wasm": "application/wasm", ".json": "application/json", ".css": "text/css" };
|
|
|
|
// A static server on 127.0.0.1: `root` at /, and each of `mounts` (a URL path to a file).
|
|
export function serve(root, mounts = {}) {
|
|
const server = http.createServer((req, res) => {
|
|
const u = new URL(req.url, "http://127.0.0.1");
|
|
let file = mounts[u.pathname];
|
|
if (!file) {
|
|
const p = path.normalize(path.join(root, decodeURIComponent(u.pathname)));
|
|
if (!p.startsWith(root)) { res.writeHead(403); return res.end(); }
|
|
file = fs.existsSync(p) && fs.statSync(p).isDirectory() ? path.join(p, "index.html") : p;
|
|
}
|
|
fs.readFile(file, (err, b) => {
|
|
if (err) { res.writeHead(404); return res.end("not found"); }
|
|
res.writeHead(200, { "content-type": MIME[path.extname(file)] || "application/octet-stream", "cache-control": "no-store" });
|
|
res.end(b);
|
|
});
|
|
});
|
|
return new Promise((ok) => server.listen(0, "127.0.0.1", () => ok({ server, url: `http://127.0.0.1:${server.address().port}`, close: () => new Promise((r) => server.close(r)) })));
|
|
}
|
|
|
|
const strip = (s) => s.replace(/\x1b\[[0-9;]*m/g, "");
|
|
|
|
// The relay, from a configuration this writes, on loopback ports it reads back from its
|
|
// log: the UDP port of QUIC and WebTransport, the TCP port of the WebSocket binding, and
|
|
// its development certificate's SHA-256.
|
|
export async function startRelay(bin, cfg, dir, name) {
|
|
const toml = path.join(dir, `${name}.toml`);
|
|
fs.writeFileSync(toml, relayToml(cfg));
|
|
const log = path.join(dir, `${name}.log`);
|
|
const out = fs.openSync(log, "w");
|
|
const proc = spawn(bin, ["--config", toml], { stdio: ["ignore", "pipe", "pipe"], env: { ...process.env, NO_COLOR: "1", JOULE_MOQ_RELAY_LOG: process.env.JOULE_MOQ_RELAY_LOG || "info" } });
|
|
const lines = [];
|
|
const take = (b) => {
|
|
fs.writeSync(out, b);
|
|
for (const l of strip(b.toString()).split("\n")) if (l) lines.push(l);
|
|
};
|
|
proc.stdout.on("data", take);
|
|
proc.stderr.on("data", take);
|
|
const exited = new Promise((r) => proc.on("exit", (code, signal) => r({ code, signal })));
|
|
const r = await until(`relay ${name} listening`, 30000, () => {
|
|
if (proc.exitCode != null) throw new Error(`relay ${name} exited: ${lines.slice(-5).join(" | ")}`);
|
|
const q = lines.map((l) => l.match(/on udp 127\.0\.0\.1:(\d+)/)).find(Boolean);
|
|
const w = lines.map((l) => l.match(/on tcp 127\.0\.0\.1:(\d+)/)).find(Boolean);
|
|
const h = lines.map((l) => l.match(/certificate sha-256 \(hex\):\s+([0-9a-f]{64})/)).find(Boolean);
|
|
return q && w && h && { quic: Number(q[1]), ws: Number(w[1]), hash: h[1] };
|
|
}, 100);
|
|
const path_ = cfg.path || "/moq";
|
|
return {
|
|
...r,
|
|
name,
|
|
proc,
|
|
lines,
|
|
log,
|
|
exited,
|
|
wt: `https://127.0.0.1:${r.quic}${path_}`,
|
|
wsUrl: `ws://127.0.0.1:${r.ws}${path_}`,
|
|
// `signal` null: wait for an exit already asked for.
|
|
async stop(signal = "SIGINT", ms = 20000) {
|
|
if (signal && proc.exitCode == null && proc.signalCode == null) proc.kill(signal);
|
|
const e = await Promise.race([exited, sleep(ms).then(() => null)]);
|
|
if (!e) { proc.kill("SIGKILL"); await exited; }
|
|
if (!this.closedLog) { this.closedLog = true; fs.closeSync(out); }
|
|
return e;
|
|
},
|
|
};
|
|
}
|
|
|
|
function relayToml(c) {
|
|
const q = (s) => JSON.stringify(s);
|
|
const arr = (a) => `[${a.map(q).join(", ")}]`;
|
|
let t = `quic_listen = "127.0.0.1:0"\nwebsocket_listen = "127.0.0.1:0"\npath = ${q(c.path || "/moq")}\ngoaway_uri = ${q(c.goaway_uri || "")}\n\n`;
|
|
t += `[tls]\ndev_names = ["localhost", "127.0.0.1"]\n\n[limits]\ngoaway_timeout_ms = ${c.goaway_timeout_ms || 8000}\n\n`;
|
|
t += `[receipts]\nreceipts_track_suffix = "_receipts"\n\n`;
|
|
for (const d of c.door || []) t += `[[receipts.door]]\nprefix = ${arr(d.prefix)}\ntrust = ${arr(d.trust)}\nhold_ms = ${d.hold_ms}\n\n`;
|
|
t += `[summary]\nout_path = ${q(c.summary_out)}\n\n[auth]\nrequired = ${c.auth ? "true" : "false"}\n\n`;
|
|
for (const g of c.grants || []) t += `[[auth.grants]]\nprefix = ${arr(g.prefix)}\ntoken_sha256 = ${q(g.token_sha256)}\npublish = ${g.publish}\nsubscribe = ${g.subscribe}\nproof = ${g.proof === true}\n\n`;
|
|
return t;
|
|
}
|
|
|
|
// The test room, from wai_live_web on the host.
|
|
export function makeFixture(waiBin, names, dir) {
|
|
const seed = crypto.randomBytes(32).toString("hex");
|
|
const t0 = BigInt(Date.now()) * 1000n;
|
|
const out = execFileSync(waiBin, ["fixture", seed, t0.toString(), names.join(",")], { maxBuffer: 16 << 20 }).toString();
|
|
const f = JSON.parse(out);
|
|
const file = path.join(dir, "fixture.json");
|
|
fs.writeFileSync(file, JSON.stringify(f, null, 1));
|
|
return { fixture: f, file };
|
|
}
|
|
|
|
// The composition the host computes at `t` for the room as a page holds it.
|
|
export function hostFrame(waiBin, roomJson, compose, t, dir) {
|
|
const file = path.join(dir, `room-${t}.json`);
|
|
fs.writeFileSync(file, roomJson);
|
|
return JSON.parse(execFileSync(waiBin, ["frame", file, compose, String(t)], { maxBuffer: 16 << 20 }).toString());
|
|
}
|
|
|
|
// The browser, headless, with a fake camera and microphone, through memguard where the host
|
|
// has it. Every process this run starts carries `--moq-web-run=<id>` so that strays can be
|
|
// found and ended afterwards.
|
|
export const RUN_ID = crypto.randomBytes(6).toString("hex");
|
|
|
|
export function browserArgs() {
|
|
return [
|
|
"--use-fake-device-for-media-stream",
|
|
"--use-fake-ui-for-media-stream",
|
|
"--autoplay-policy=no-user-gesture-required",
|
|
"--disable-background-timer-throttling",
|
|
"--disable-renderer-backgrounding",
|
|
"--disable-backgrounding-occluded-windows",
|
|
`--moq-web-run=${RUN_ID}`,
|
|
];
|
|
}
|
|
|
|
// A wrapper that runs the browser under memguard with its own bound (MOQ_WEB_BROWSER_GB,
|
|
// 8 by default), so that one runaway browser is killed, not the host.
|
|
export function memguardedBrowser(bin, dir) {
|
|
const mg = process.env.RB_ROOT ? path.join(process.env.RB_ROOT, "bin", "memguard") : process.env.MEMGUARD || "";
|
|
if (!mg || !fs.existsSync(mg)) return { executable: bin, guarded: false };
|
|
const w = path.join(dir, "browser-memguard.sh");
|
|
fs.writeFileSync(w, `#!/bin/bash\nexec ${JSON.stringify(mg)} --max-gb ${process.env.MOQ_WEB_BROWSER_GB || 8} -- ${JSON.stringify(bin)} "$@"\n`, { mode: 0o755 });
|
|
return { executable: w, guarded: true };
|
|
}
|
|
|
|
export function killStrays() {
|
|
try { execFileSync("pkill", ["-f", `--moq-web-run=${RUN_ID}`]); } catch { /* none left */ }
|
|
}
|
|
|
|
export function tmpdir(label) {
|
|
return fs.mkdtempSync(path.join(process.env.TMPDIR || os.tmpdir(), `moq-web-${label}-`));
|
|
}
|