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