262 lines
7.1 KiB
JavaScript
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)]);
|
|
}
|
|
}
|