import assert from "node:assert/strict"; import events from "node:events"; import fs from "node:fs/promises"; import path from "node:path"; import * as lib from "@clusterio/lib"; import { InstanceManager } from "@clusterio/controller"; import { FactorioServer, _getFactorioVersion } from "@clusterio/host/dist/node/src/server.js"; import { logger } from "@clusterio/lib"; import { factorioDir, requiresFactorio } from "./index.js"; import { slowTest } from "../common.js"; import { getTestRconPort } from "./rcon_port.js"; describe("Integration of host/src/server", function() { requiresFactorio(this); describe("getFactorioVersion()", function() { it("should get a version from factorio's changelog.txt", async function() { function checkVersion(version) { if (!/^\d+\.\d+\.\d+$/.test(version)) { assert.fail(`Detected version '${version}' does not followed the format x.y.z`); } } let version = await _getFactorioVersion(factorioDir); if (version !== null) { checkVersion(version); } else { for (let name of await fs.readdir(factorioDir)) { // Avoid any fancy directory detection in order to follow symlinks version = await _getFactorioVersion(path.join(factorioDir, name)); if (version !== null) { checkVersion(version); } } } }); }); describe("class FactorioServer", function() { let writePath = path.join("temp", "test", "integration"); let server; let logFile; before(async function() { server = await FactorioServer.fromOptions(factorioDir, writePath, {}); // Let Factorio bind an OS-assigned UDP port instead of guessing a free one. server.gamePort = 0; // Delete result from previous run of these tests await fs.rm(writePath, { force: true, recursive: true, maxRetries: 10 }); await fs.mkdir(writePath, { recursive: true }); logFile = (await fs.open(path.join(writePath, "log.txt"), "w")).createWriteStream({ encoding: "utf8" }); server.on("output", function(output) { if (output.source === "stderr") { // Factorio doesn't print on stderr but sometimes on // Travis a BrokenPipeError is printed on stderr after // the server has closed for mysterious reasons. // I have no idea what causes it, but it causes a write // after end error if not handled. logger.error(`Factorio stderr: ${JSON.stringify(output)}`); return; } logFile.write(`${JSON.stringify(output)}\n`); }); }); after(function() { logFile.end(); }); beforeEach(async function() { if (server.pid === null) { server.rconPort = await getTestRconPort(); } }); describe(".exampleSettings()", function() { let settings; it("returns an object", async function() { settings = await server.exampleSettings(); assert(typeof settings === "object"); }); it("aligns with the default settings in Clusterio", async function() { const unusedDefaultKeys = new Set(Object.keys(InstanceManager.DefaultFactorioSettings)); for (const key of Object.keys(settings)) { unusedDefaultKeys.delete(key); } assert( unusedDefaultKeys.size === 0, `Factorio's server-settings.example.json does not contain the key(s) ${[...unusedDefaultKeys]}` ); }); }); function log(message) { logFile.write(`=================== ${message}\n`); } describe(".create()", function() { it("creates a map file at writeDir/saves/name", async function() { slowTest(this); log(".create() with new save"); // Make sure the test is not fooled by previous data let mapPath = server.writePath("saves", "test.zip"); await assert.rejects(fs.access(mapPath), "save exist before test"); await server.create("test.zip", undefined, { width: 50, height: 50 }); assert.doesNotReject(await fs.access(mapPath), "test did not create save"); }); }); describe(".start()", function() { it("starts the server", async function() { slowTest(this); log(".start()"); // Make sure the test does not fail due to not having internet let settings = await server.exampleSettings(); settings.visibility = { public: false, lan: true }; settings.require_user_verification = false; await fs.writeFile(server.writePath("server-settings.json"), JSON.stringify(settings, null, "\t")); // Make sure the test does not fail due to create() failing. let mapPath = server.writePath("saves", "test.zip"); await assert.doesNotReject(fs.access(mapPath), "save is missing"); // Lua UDP needs Factorio 2.1.10, see docs/configuration.md server.enableLuaUdp = lib.integerFullVersion(server.version) >= lib.integerFullVersion("2.1.10"); await server.start("test.zip"); }); }); describe(".disableAchievments()", function() { it("disables acheivements", async function() { slowTest(this); log(".disableAchievements()"); assert.equal(await server.disableAchievements(), true); }); it("can tell when acheivements were disabled", async function() { slowTest(this); assert.equal(await server.disableAchievements(), false); }); }); describe(".sendRcon()", function() { it("returns the result of a command", async function() { slowTest(this); log(".sendRcon()"); let result = await server.sendRcon("/sc rcon.print('success')"); assert.equal(result, "success\n"); }); it("throws on non-empty response when enabled", async function() { slowTest(this); await assert.rejects( server.sendRcon("/sc rcon.print('fail')", true), new Error('Expected empty response but got "fail\n"') ); }); }); describe("Lua UDP", function() { it("emits udp events for packets sent from the game", async function() { slowTest(this); if (!server.enableLuaUdp) { this.skip(); } log("Lua UDP from game"); assert(server.hostUdpPort > 0, "host UDP socket is not open"); let waiter = events.once(server, "udp-test_channel"); await server.sendRcon(`/sc helpers.send_udp(${server.hostUdpPort}, "test_channel?hello", 0)`); let [data] = await waiter; assert.equal(data.toString(), "hello"); }); it("delivers packets sent with sendUdp to the game", async function() { slowTest(this); if (!server.enableLuaUdp) { this.skip(); } log("Lua UDP to game"); // Poll from on_tick like the clusterio module does await server.sendRcon( "/sc script.on_event(defines.events.on_udp_packet_received, " + "function(event) print('udp:' .. event.payload) end) " + "script.on_nth_tick(1, function() helpers.recv_udp(0) end)" ); let pass = false; function filter(output) { if (output.message === "udp:hello") { pass = true; } } server.on("output", filter); await server.sendUdp("hello"); // The server does not tick without players, commands make it tick for (let i = 0; i < 10; i++) { await server.sendRcon("/sc rcon.print('tick')"); await lib.wait(10); if (pass) { break; } } server.off("output", filter); assert(pass, "server did not output the packet payload"); }); }); describe(".stop()", function() { it("stops the server", async function() { slowTest(this); log(".stop()"); await server.stop(); }); }); describe(".startScenario()", function() { before("Write test_scenario", async function() { let content = "script.on_init(function() print('test_scenario init') end)\n"; await fs.mkdir(server.writePath("scenarios", "test_scenario"), { recursive: true }); await fs.writeFile(server.writePath("scenarios", "test_scenario", "control.lua"), content); }); it("runs the given scenario", async function() { slowTest(this); log(".startScenario()"); let pass = false; function filter(output) { if (output.message === "test_scenario init") { pass = true; } } server.on("output", filter); await server.startScenario("test_scenario"); log(".stop()"); await server.stop(); server.off("output", filter); assert(pass, "server did not output line from test scenario"); }); }); describe(".start() error handling", function() { it("should handle factorio erroring out", async function() { slowTest(this); log(".start() for error handling"); await server.start("test.zip"); if (!server._rconReady) { await events.once(server, "rcon-ready"); } server.sendRcon("/c script.on_nth_tick(1, function() o.o = 1 end)").catch(() => {}); function discard() { } server.on("error", discard); await new Promise(resolve => server.once("exit", resolve)); server.off("error", discard); }); }); describe(".stop() hang detection", function() { it("should detect factorio hanging on shutdown", async function() { slowTest(this); log(".start() for hang detection"); await server.start("test.zip"); if (!server._rconReady) { await events.once(server, "rcon-ready"); } server.sendRcon("/c while true do end").catch(() => {}); await new Promise((resolve) => setTimeout(resolve, 300)); log(".stop() for hang detection"); server.shutdownTimeoutMs = 2000; await server.stop(); server.shutdownTimeoutMs = 0; }); it("should not hang if RCON fails to connect while stopping", async function() { slowTest(this); const origWaitForReady = server._waitForReady; server._waitForReady = () => { server._waitForReady = origWaitForReady; server.rconPort -= 1; // Mangle port so it fails to connect. return server._waitForReady(); }; try { await server.start("test.zip"); if (server._rconReady) { assert.fail("Test failed, RCON managed to connect"); } await server.stop(); } finally { if (server._state !== "init") { await server.kill(); assert.fail("Server did not stop"); } } }); it("should not hang if RCON fails to connect before stopping", async function() { slowTest(this); const origWaitForReady = server._waitForReady; server._waitForReady = () => { server._waitForReady = origWaitForReady; server.rconPassword = "wrong"; // Mangle password so it fails to authenticate. return server._waitForReady(); }; try { await server.start("test.zip"); if (server._rconReady) { assert.fail("Test failed, RCON managed to connect"); } await events.once(server, "rcon-ready"); await server.stop(); } finally { if (server._state !== "init") { await server.kill(); assert.fail("Server did not stop"); } } }); it("should work if RCON has dropped out", async function() { slowTest(this); try { await server.start("test.zip"); if (!server._rconReady) { await events.once(server, "rcon-ready"); } await server._rconClient.end(); await server.stop(); } finally { if (server._state !== "init") { await server.kill(); assert.fail("Server did not stop"); } } }); }); describe(".start() termination detection", function() { it("should tell if Factorio got killed", async function() { slowTest(this); log(".start() for kill detection"); let startPromise = server.start("test.zip"); server.once("output", () => server._server.kill("SIGKILL")); await assert.rejects( startPromise, new lib.EnvironmentError( "Factorio server was unexpectedly killed, is the system low on memory?" ) ); }); it("should tell if Factorio unexpectedly closed with a code", async function() { slowTest(this); log(".start() for unexpected close detection"); await assert.rejects( server.start("does-not-exist.zip"), new lib.EnvironmentError("Factorio server unexpectedly shut down with code 1") ); }); }); }); });