cbot/scripts/e2e-cbot.mjs

389 lines
13 KiB
JavaScript
Raw Permalink Normal View History

2026-06-01 11:07:34 +02:00
import { createWriteStream, existsSync } from "node:fs";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import https from "node:https";
import net from "node:net";
import path from "node:path";
import { spawn } from "node:child_process";
import mineflayer from "mineflayer";
const ROOT = path.resolve(new URL("..", import.meta.url).pathname);
const RUN_DIR = path.join(ROOT, "run");
const MODS_DIR = path.join(RUN_DIR, "mods");
const CARPET_JAR = "fabric-carpet-1.21.11-1.4.194+v251223.jar";
const CARPET_URL = "https://github.com/gnembon/fabric-carpet/releases/download/1.4.194/fabric-carpet-1.21.11-1.4.194%2Bv251223.jar";
const MC_VERSION = "1.21.11";
const TEST_TIMEOUT_MS = 180_000;
const CHAT_TIMEOUT_MS = 15_000;
const startedAt = Date.now();
let serverProcess;
const clients = [];
async function main() {
const port = Number(process.env.CBOT_E2E_PORT || await getFreePort());
const suffix = Date.now().toString(36).slice(-5);
const playerA = `A${suffix}`;
const playerB = `B${suffix}`;
await prepareRunDirectory(port);
serverProcess = startServer();
await waitForServerReady(serverProcess);
assertIncludes(serverProcess.output, "- carpet ", "server loaded Fabric Carpet");
assertIncludes(serverProcess.output, "- cbot ", "server loaded cbot");
serverCommand("cbot config reset");
await delay(750);
const a = await connectPlayer(playerA, port);
await runAndExpect(a, "cbot list", [`#1 cbot_${playerA}_1 [offline]`]);
await runAndExpect(a, "cbot gui", ["Install cbot on your client to use the GUI"]);
await runAndExpect(a, "cbot buy", ["You need 1 minecraft:netherite_block"]);
serverCommand(`give ${playerA} netherite_block 1`);
await delay(750);
await runAndExpect(a, "cbot buy", [`Bought bot #2 (cbot_${playerA}_2)`]);
await runAndExpect(a, "cbot list", [`#2 cbot_${playerA}_2 [offline]`]);
const b = await connectPlayer(playerB, port);
await runAndExpectEither(b, "cbot config show", ["Unknown or incomplete command", "You do not have permission", "Incorrect argument for command"]);
serverCommand(`op ${playerA}`);
await delay(750);
await runAndExpect(a, "cbot config show", ["cbot config:", "starting-free: 1", "price: minecraft:netherite_block x1", "cap: none"]);
await runAndExpect(a, "cbot config starting-free 3", ["Set starting free bots to 3"]);
await runAndExpect(a, "cbot list", [`#3 cbot_${playerA}_3 [offline]`]);
await runAndExpect(a, "cbot config price minecraft:diamond 2", ["Set bot price to minecraft:diamond x2"]);
await runAndExpect(a, "cbot buy", ["You need 2 minecraft:diamond"]);
serverCommand(`give ${playerA} diamond 2`);
await delay(750);
await runAndExpect(a, "cbot buy", [`Bought bot #4 (cbot_${playerA}_4)`]);
await runAndExpect(a, "cbot config price minecraft:diamond 0", ["Set bot price to minecraft:diamond x0"]);
await runAndExpect(a, "cbot buy", [`Bought bot #5 (cbot_${playerA}_5)`]);
await runAndExpect(a, "cbot config cap 5", ["Set bot cap to 5"]);
await runAndExpect(a, "cbot buy", ["You have reached the bot cap"]);
await runAndExpect(a, "cbot config cap 2", ["Set bot cap to 2"]);
await runAndExpect(a, "cbot list", [`#5 cbot_${playerA}_5 [offline]`]);
const completions = await a.tabComplete("/cbot 1 u", true, false, 5000);
assertIncludes(normalizeCompletions(completions).join("\n"), "use", "action autocomplete includes use");
await runAndExpect(a, "cbot config cap none", ["Set bot cap to none"]);
await runAndExpect(a, "cbot config botname \"cbot{id}_of_{player}\"", ["Set botname pattern to: cbot{id}_of_{player}", "Already-spawned old-name bots may need"]);
const renamedA = await runAndCollect(a, "cbot list", ["Your bots:"]);
assertIncludes(renamedA.join("\n"), `#1 cbot1_of_${playerA} [offline]`, "renamed bot list reflects pattern");
assertNotIncludes(renamedA.join("\n"), `cbot_${playerA}_1`, "old bot name no longer listed after rename");
const renamedANames = parseListedBotNames(renamedA);
const longPlayer = `LongName${suffix}ZZZ`.slice(0, 16);
const c = await connectPlayer(longPlayer, port);
const longList = await runAndCollect(c, "cbot list", ["Your bots:", "#3 "]);
const longNames = parseListedBotNames(longList);
if (longNames.length < 3) {
throw new Error(`expected at least 3 long-player bots; got ${longNames.join(", ")}`);
}
for (const name of longNames) {
if (name.length > 16) {
throw new Error(`expected truncated bot name <= 16 chars; got ${name}`);
}
}
await runAndExpect(c, "cbot 1 spawn", [`Spawned ${longNames[0]}`]);
await waitForChatIncludes(c, `${longNames[0]} joined the game`, 30_000);
await runAndExpect(a, "cbot 1 spawn", [`Spawned ${renamedANames[0]}`]);
await waitForChatIncludes(a, `${renamedANames[0]} joined the game`, 30_000);
await runAndExpect(a, "cbot list", [`#1 ${renamedANames[0]} [online]`]);
await runAndExpect(a, "cbot 1 use once", [`Sent action to ${renamedANames[0]}`]);
await runAndExpect(a, "cbot 1 tp", [`Teleported ${renamedANames[0]} to you`]);
await runAndExpect(a, "cbot 1 tp 10 80 10", [`Teleported ${renamedANames[0]}`]);
await runAndExpect(a, "cbot config reset", ["Reset cbot config to defaults"]);
await runAndExpect(b, "cbot list", [`#1 cbot_${playerB}_1 [offline]`]);
await runAndExpect(b, "cbot 2 spawn", ["Invalid bot id"]);
await stopServer();
console.log(`[e2e] passed in ${Math.round((Date.now() - startedAt) / 1000)}s`);
}
async function prepareRunDirectory(port) {
await mkdir(MODS_DIR, { recursive: true });
await ensureCarpetJar();
await writeFile(path.join(RUN_DIR, "eula.txt"), "eula=true\n", "utf8");
await patchServerProperties(port);
}
async function ensureCarpetJar() {
const jarPath = path.join(MODS_DIR, CARPET_JAR);
if (existsSync(jarPath)) {
return;
}
console.log(`[e2e] downloading ${CARPET_JAR}`);
await download(CARPET_URL, jarPath);
}
async function patchServerProperties(port) {
const file = path.join(RUN_DIR, "server.properties");
const existing = existsSync(file) ? await readFile(file, "utf8") : "";
const values = new Map();
for (const line of existing.split(/\r?\n/)) {
if (!line || line.startsWith("#") || !line.includes("=")) {
continue;
}
const [key, ...rest] = line.split("=");
values.set(key, rest.join("="));
}
values.set("server-port", String(port));
values.set("online-mode", "false");
values.set("enforce-secure-profile", "false");
values.set("spawn-protection", "0");
values.set("white-list", "false");
values.set("enable-command-block", "true");
const body = [
"#Minecraft server properties for cbot e2e tests",
...[...values.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([key, value]) => `${key}=${value}`),
"",
].join("\n");
await writeFile(file, body, "utf8");
}
function startServer() {
console.log("[e2e] starting Fabric server");
const child = spawn("./gradlew", ["runServer"], {
cwd: ROOT,
stdio: ["pipe", "pipe", "pipe"],
env: { ...process.env, TERM: "dumb" },
});
child.output = "";
child.stdout.on("data", (chunk) => {
const text = chunk.toString();
child.output += text;
process.stdout.write(prefixLines(text, "[server] "));
});
child.stderr.on("data", (chunk) => {
const text = chunk.toString();
child.output += text;
process.stderr.write(prefixLines(text, "[server] "));
});
return child;
}
async function waitForServerReady(child) {
await withTimeout(new Promise((resolve, reject) => {
const onExit = (code) => reject(new Error(`server exited before ready with code ${code}`));
const onData = () => {
if (child.output.includes("Done (") && child.output.includes("For help, type \"help\"")) {
child.off("exit", onExit);
child.stdout.off("data", onData);
child.stderr.off("data", onData);
resolve();
}
};
child.on("exit", onExit);
child.stdout.on("data", onData);
child.stderr.on("data", onData);
}), TEST_TIMEOUT_MS, "server startup");
}
async function connectPlayer(username, port) {
console.log(`[e2e] connecting ${username}`);
const bot = mineflayer.createBot({
host: "127.0.0.1",
port,
username,
auth: "offline",
version: MC_VERSION,
});
clients.push(bot);
bot.seenMessages = [];
bot.on("message", (message) => {
const text = message.toString();
bot.seenMessages.push(text);
console.log(`[${username}] ${text}`);
});
await withTimeout(new Promise((resolve, reject) => {
bot.once("spawn", resolve);
bot.once("error", reject);
bot.once("kicked", (reason) => reject(new Error(`${username} kicked: ${reason}`)));
}), CHAT_TIMEOUT_MS, `${username} login`);
await delay(1000);
return bot;
}
async function runAndExpect(bot, command, expectedMessages) {
const start = bot.seenMessages.length;
console.log(`[e2e] /${command}`);
bot.chat(`/${command}`);
await withTimeout(waitUntil(() => {
const received = bot.seenMessages.slice(start);
return expectedMessages.every((expected) => received.some((message) => message.includes(expected)));
}), CHAT_TIMEOUT_MS, `/${command} -> ${expectedMessages.join(", ")}`);
}
async function runAndExpectEither(bot, command, expectedMessages) {
const start = bot.seenMessages.length;
console.log(`[e2e] /${command}`);
bot.chat(`/${command}`);
await withTimeout(waitUntil(() => {
const received = bot.seenMessages.slice(start);
return expectedMessages.some((expected) => received.some((message) => message.includes(expected)));
}), CHAT_TIMEOUT_MS, `/${command} -> one of ${expectedMessages.join(", ")}`);
}
async function runAndCollect(bot, command, expectedMessages) {
const start = bot.seenMessages.length;
console.log(`[e2e] /${command}`);
bot.chat(`/${command}`);
await withTimeout(waitUntil(() => {
const received = bot.seenMessages.slice(start);
return expectedMessages.every((expected) => received.some((message) => message.includes(expected)));
}), CHAT_TIMEOUT_MS, `/${command} -> ${expectedMessages.join(", ")}`);
return bot.seenMessages.slice(start);
}
async function waitForChatIncludes(bot, expected, timeoutMs = CHAT_TIMEOUT_MS) {
await withTimeout(waitUntil(() => {
return bot.seenMessages.some((message) => message.includes(expected));
}), timeoutMs, `chat message containing "${expected}"`);
}
function serverCommand(command) {
console.log(`[console] ${command}`);
serverProcess.stdin.write(`${command}\n`);
}
async function stopServer() {
for (const bot of clients) {
bot.quit();
}
if (!serverProcess || serverProcess.exitCode !== null) {
return;
}
console.log("[e2e] stopping server");
serverProcess.stdin.write("stop\n");
await withTimeout(new Promise((resolve) => serverProcess.once("exit", resolve)), 30_000, "server stop");
}
async function waitUntil(predicate) {
while (!predicate()) {
await delay(100);
}
}
async function getFreePort() {
return new Promise((resolve, reject) => {
const server = net.createServer();
server.listen(0, "127.0.0.1", () => {
const { port } = server.address();
server.close(() => resolve(port));
});
server.on("error", reject);
});
}
async function download(url, destination) {
await new Promise((resolve, reject) => {
const request = https.get(url, (response) => {
if ([301, 302, 303, 307, 308].includes(response.statusCode)) {
response.resume();
download(response.headers.location, destination).then(resolve, reject);
return;
}
if (response.statusCode !== 200) {
response.resume();
reject(new Error(`download failed with HTTP ${response.statusCode}`));
return;
}
const file = createWriteStream(destination);
response.pipe(file);
file.on("finish", () => file.close(resolve));
file.on("error", reject);
});
request.on("error", reject);
});
}
async function withTimeout(promise, timeoutMs, label) {
let timeout;
try {
return await Promise.race([
promise,
new Promise((_, reject) => {
timeout = setTimeout(() => reject(new Error(`timed out waiting for ${label}`)), timeoutMs);
}),
]);
} finally {
clearTimeout(timeout);
}
}
function assertIncludes(haystack, needle, label) {
if (!haystack.includes(needle)) {
throw new Error(`expected ${label}; missing "${needle}"`);
}
}
function assertNotIncludes(haystack, needle, label) {
if (haystack.includes(needle)) {
throw new Error(`expected ${label}; unexpectedly found "${needle}"`);
}
}
function parseListedBotNames(messages) {
const names = [];
for (const message of messages) {
const match = message.match(/^#\d+ (\S+) \[(?:online|offline)\]$/);
if (match) {
names.push(match[1]);
}
}
return names;
}
function normalizeCompletions(completions) {
return completions.map((completion) => {
if (typeof completion === "string") {
return completion;
}
return completion.match ?? completion.text ?? String(completion);
});
}
function prefixLines(text, prefix) {
return text.split(/(?<=\n)/).map((line) => line ? `${prefix}${line}` : line).join("");
}
function delay(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
process.on("SIGINT", async () => {
await stopServer().catch(() => {});
process.exit(130);
});
main().catch(async (error) => {
console.error(`[e2e] failed: ${error.stack || error.message}`);
await stopServer().catch(() => {});
process.exit(1);
});