import assert from "node:assert/strict"; import { spawn } from "node:child_process"; import { mkdtemp, mkdir, readFile, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; export const root = path.resolve( path.dirname(fileURLToPath(import.meta.url)), "..", ); export const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); export function options() { const opts = {}; for (let i = 2; i < process.argv.length; i += 2) opts[process.argv[i].replace(/^--/, "")] = process.argv[i + 1]; return opts; } export async function harness(opts = {}) { const port = Number(opts.port || 4001), url = `http://127.0.0.1:${port}`; try { await fetch(`${url}/api/health`, { signal: AbortSignal.timeout(500) }); throw Error( `Port ${port} is already serving HTTP; choose an unused --port`, ); } catch (error) { if (error.message?.includes("already serving")) throw error; } const data = opts.data ? path.resolve(opts.data) : await mkdtemp(path.join(os.tmpdir(), "shacraft-server-test-")); await mkdir(data, { recursive: true }); let stderr = ""; const binary = path.resolve( opts.binary || path.join(root, "target/debug/shacraft-server"), ); const child = spawn( binary, [ "--listen", `127.0.0.1:${port}`, "--data", data, "--client", path.join(root, "client"), "--packages", path.join(root, "packages"), ], { cwd: root, stdio: ["ignore", "pipe", "pipe"] }, ); child.stdout.on("data", (chunk) => (stderr += chunk)); child.stderr.on("data", (chunk) => (stderr += chunk)); let failure; child.on("error", (e) => (failure = e)); const running = () => child.exitCode === null && child.signalCode === null && !failure; async function kill(signal = "SIGKILL") { if (!running()) return; const exited = new Promise((resolve) => child.once("exit", resolve)); async function waitForExit() { let timer; await Promise.race([ exited, new Promise((resolve) => { timer = setTimeout(resolve, 5000); }), ]); clearTimeout(timer); } child.kill(signal); await waitForExit(); if (running()) { child.kill("SIGKILL"); await waitForExit(); } if (running()) throw Error("Test server did not exit after SIGKILL"); } async function stop() { await kill("SIGINT"); } try { const deadline = Date.now() + 45000; while (true) { if (failure) throw failure; if (!running()) throw Error(`Server exited: ${stderr}`); try { if ((await fetch(`${url}/api/health`)).ok) break; } catch {} if (Date.now() > deadline) throw Error(`Server startup timeout: ${stderr}`); await delay(100); } } catch (e) { await stop(); throw e; } const token = ( await readFile(path.join(data, "control.token"), "utf8") ).trim(); async function get(route) { const r = await fetch(url + route); assert.equal(r.status, 200, `${route}: HTTP ${r.status}`); return r.json(); } async function control(method, params = {}) { const response = await fetch(url + "/api/control", { method: "POST", headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", }, body: JSON.stringify({ method, params }), }); const body = await response.json(); if (!response.ok || body.error) throw Error(`${method}: ${body.error || response.status}`); return body.result; } return { url, data, pid: child.pid, binary, get, control, stop, kill, logs: () => stderr, async report(value) { const output = opts.output ? path.resolve(opts.output) : path.join(data, "report.json"); await mkdir(path.dirname(output), { recursive: true }); await writeFile(output, JSON.stringify(value, null, 2) + "\n"); console.log( JSON.stringify( { ...value, samples: undefined, sample_count: value.samples?.length, report_file: output, data, }, null, 2, ), ); }, }; } export class Client { constructor(url) { this.ws = new WebSocket(url.replace(/^http/, "ws") + "/ws"); this.queue = []; this.waiters = []; this.id = null; this.seq = 0; this.latest = null; this.revision = null; this.error = null; this.open = new Promise((resolve, reject) => { this.ws.addEventListener("open", resolve, { once: true }); this.ws.addEventListener( "error", () => reject(Error("WebSocket connection failed")), { once: true }, ); }); this.ws.addEventListener("message", (event) => { let m; try { m = JSON.parse(event.data); } catch { return; } if (m.type === "state") this.latest = m; if (m.type === "welcome") { this.id = m.id; this.welcome = m; } if (["welcome", "snapshot", "blocks"].includes(m.type)) this.revision = m.revision; for (let i = 0; i < this.waiters.length; i++) { const w = this.waiters[i]; if (w.predicate(m)) { this.waiters.splice(i, 1); clearTimeout(w.timer); w.resolve(m); return; } } this.queue.push(m); if (this.queue.length > 500) this.queue.shift(); }); } send(message) { this.ws.send(JSON.stringify(message)); } async join(manifest, world, name = "TestPlayer") { await this.open; this.send({ type: "join", protocol: 1, manifest_hash: manifest.hash, world, name, }); return this.next("welcome"); } next(type, predicate = () => true, timeout = 10000) { return this.wait((m) => m.type === type && predicate(m), timeout); } wait(predicate, timeout = 10000) { const i = this.queue.findIndex(predicate); if (i >= 0) return Promise.resolve(this.queue.splice(i, 1)[0]); return new Promise((resolve, reject) => { const w = { predicate, resolve, timer: setTimeout(() => { this.waiters = this.waiters.filter((x) => x !== w); reject(Error(`WebSocket message timeout (${timeout} ms)`)); }, timeout), }; this.waiters.push(w); }); } input(fields = {}) { const input = { type: "input", seq: ++this.seq, yaw: 0, pitch: 0, forward: 0, strafe: 0, jump: false, ...fields, }; this.send(input); return input.seq; } async state(predicate = () => true, timeout = 10000) { return this.next( "state", (m) => predicate( m.players.find((p) => p.id === this.id), m, ), timeout, ); } async close() { if (this.ws.readyState === WebSocket.CLOSED) return; const closed = new Promise((resolve) => this.ws.addEventListener("close", resolve, { once: true }), ); this.ws.close(); await Promise.race([closed, delay(2000)]); } }