Files

262 lines
7.1 KiB
JavaScript

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)]);
}
}