mirror of
https://github.com/clusterio/clusterio.git
synced 2026-11-12 07:04:52 +09:00
Danielv123 pointed out the recv_udp crash was fixed in 2.1.10, not 2.1.12: recv_udp used an input action for synchronisation which the headless server could not send without a player index. Verified on downloaded headless builds, 2.1.9 crashes and 2.1.10 works. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
380 lines
11 KiB
JavaScript
380 lines
11 KiB
JavaScript
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";
|
|
|
|
|
|
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 = new FactorioServer(factorioDir, writePath, {});
|
|
// Let Factorio bind an OS-assigned UDP port instead of guessing a free one.
|
|
server.gamePort = 0;
|
|
let logFile;
|
|
|
|
before(async function() {
|
|
// 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`);
|
|
});
|
|
|
|
// Initialize sever.
|
|
await server.init();
|
|
});
|
|
|
|
after(function() {
|
|
logFile.end();
|
|
});
|
|
|
|
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")
|
|
);
|
|
});
|
|
});
|
|
});
|
|
});
|