389 lines
13 KiB
JavaScript
389 lines
13 KiB
JavaScript
|
|
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);
|
||
|
|
});
|