Files
factorio-clusterio/test/host/server.js
T
bbassieandClaude Fable 5.1 4b0238b64b Cover UDP error paths and sendUdp in tests
Codecov flagged the sendUdp methods on Instance and BaseInstancePlugin,
the bind, socket error, malformed packet and send failure paths in
FactorioServer, and the --enable-lua-udp branch of startScenario.  Add
unit tests for each and start the integration scenario test with UDP
enabled on Factorio 2.0.59+.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-15 16:38:34 +00:00

659 lines
23 KiB
JavaScript

import assert from "node:assert/strict";
import dgram from "node:dgram";
import events from "node:events";
import fs from "node:fs/promises";
import path from "node:path";
import * as hostServer from "@clusterio/host/dist/node/src/server.js";
import { Link, wait } from "@clusterio/lib";
import { testLines } from "../lib/factorio/lines.js";
import { slowTest, externalTest } from "../common.js";
import "../setup_logging.js";
describe("host/server", function() {
describe("_getFactorioVersion()", function() {
it("should get the version from a changelog", async function() {
let version = await hostServer._getFactorioVersion(path.join("test", "file", "changelogs", "good"));
assert.equal(version, "0.1.1");
});
it("should return null if unable to find the version", async function() {
let version = await hostServer._getFactorioVersion(path.join("test", "file", "changelogs", "bad"));
assert.equal(version, null);
});
it("should return null if file does not exist", async function() {
let version = await hostServer._getFactorioVersion(path.join("test", "file", "changelogs", "not-exists"));
assert.equal(version, null);
});
});
describe("_versionOrder()", function() {
it("should sort an array of versions", function() {
let versions = ["1.2.3", "0.1.4", "0.1.2", "1.2.3", "0.1.5", "1.10.2"];
versions.sort(hostServer._versionOrder);
assert.deepEqual(
versions,
["1.10.2", "1.2.3", "1.2.3", "0.1.5", "0.1.4", "0.1.2"]
);
});
});
describe("_findVersion()", function() {
describe("direct install", function() {
it("should search given directory for latest Factorio install", async function() {
const installDir = path.join("test", "file", "factorio", "0.1.2");
const [dir, version] = await hostServer.findVersion(installDir, "latest");
assert.equal(dir, path.join(installDir, "data"));
assert.equal(version, "0.1.2");
});
it("should search given directory for given Factorio install", async function() {
const installDir = path.join("test", "file", "factorio", "0.1.1");
const [dir, version] = await hostServer.findVersion(installDir, "0.1.1");
assert.equal(dir, path.join(installDir, "data"));
assert.equal(version, "0.1.1");
});
it("should search given directory for partly given Factorio install", async function() {
const installDir = path.join("test", "file", "factorio", "0.1.2");
const [dir, version] = await hostServer.findVersion(installDir, "0.1");
assert.equal(dir, path.join(installDir, "data"));
assert.equal(version, "0.1.2");
});
it("should reject if the version does not match", async function() {
let installDir = path.join("test", "file", "factorio", "0.1.1");
await assert.rejects(
hostServer.findVersion(installDir, "0.1.2"),
new Error(
`Unable to find Factorio version 0.1.2: ${installDir} is a direct (single-version) ` +
`install of 0.1.1. Use a versioned layout, where ${installDir} contains a subdirectory ` +
"per version, to run other versions or download them automatically."
)
);
});
});
describe("mutli install", function() {
it("should reject if no factorio install with the given version was found", async function() {
let installDir = path.join("test", "file", "factorio");
await assert.rejects(
hostServer.findVersion(installDir, "0.1.3"),
new Error("Unable to find Factorio version 0.1.3")
);
});
it("should search given directory for given Factorio install", async function() {
const installDir = path.join("test", "file", "factorio");
const [dir, version] = await hostServer.findVersion(installDir, "0.1.1");
assert.equal(dir, path.join(installDir, "0.1.1", "data"));
assert.equal(version, "0.1.1");
});
it("should search given directory for partly given Factorio install", async function() {
const installDir = path.join("test", "file", "factorio");
const [dir, version] = await hostServer.findVersion(installDir, "0.1");
assert.equal(dir, path.join(installDir, "0.1.2", "data"));
assert.equal(version, "0.1.2");
});
it("should reject if no factorio install with the given version was found", async function() {
let installDir = path.join("test", "file", "factorio");
await assert.rejects(
hostServer.findVersion(installDir, "0.1.3"),
new Error("Unable to find Factorio version 0.1.3")
);
});
it("should reject if no factorio install was found", async function() {
let installDir = path.join("test", "file");
await assert.rejects(
hostServer.findVersion(installDir, "0.0.0"),
new Error(`Unable to find any Factorio install in ${installDir}`)
);
});
});
});
describe("_listFactorioVersions()", function() {
it("should list the version in a direct install", async function() {
const installDir = path.join("test", "file", "factorio", "0.1.1");
const installedVersions = await hostServer._listFactorioVersions(installDir);
assert.deepEqual(installedVersions, {
direct: true,
versions: new Set(["0.1.1"]),
});
});
it("should list all versions in a directory", async function() {
const installDir = path.join("test", "file", "factorio");
const installedVersions = await hostServer._listFactorioVersions(installDir);
assert.deepEqual(installedVersions, {
direct: false,
versions: new Set(["0.1.1", "0.1.2"]),
});
});
});
describe("downloadAndExtractZip", function() {
let _fetch;
beforeEach(function() {
_fetch = global.fetch;
});
afterEach(function() {
global.fetch = _fetch;
});
it("works", async function() {
slowTest(this);
externalTest(this);
const url = "https://github.com/clusterio/clusterio/archive/refs/tags/v2.0.0-alpha.22.zip";
const downloads = path.join("temp", "test", "downloads");
await fs.rm(downloads, { force: true, recursive: true, maxRetries: 10 });
await fs.mkdir(downloads, { recursive: true });
await hostServer._downloadAndExtractZip(url, path.join(downloads, "zip"));
await fs.access(path.join(downloads, "zip", "packages", "controller", "package.json"));
});
it("errors and bad status", async function() {
global.fetch = () => ({ ok: false, status: -1, statusText: "Fetch called" });
await assert.rejects(hostServer._downloadAndExtractZip("url does not matter"), /-1 Fetch called/);
});
});
describe("downloadAndExtractTar", function() {
let _fetch;
beforeEach(function() {
_fetch = global.fetch;
});
afterEach(function() {
global.fetch = _fetch;
});
it("works", async function() {
slowTest(this);
externalTest(this);
const url = "https://github.com/clusterio/clusterio/archive/refs/tags/v2.0.0-alpha.22.tar.gz";
const downloads = path.join("temp", "test", "downloads");
await fs.rm(downloads, { force: true, recursive: true, maxRetries: 10 });
await fs.mkdir(downloads, { recursive: true });
await hostServer._downloadAndExtractTar(url, path.join(downloads, "tar"));
await fs.access(path.join(downloads, "tar", "packages", "controller", "package.json"));
});
it("errors and bad status", async function() {
global.fetch = () => ({ ok: false, status: -1, statusText: "Fetch called" });
await assert.rejects(hostServer._downloadAndExtractTar("url does not matter"), /-1 Fetch called/);
});
});
describe("randomDynamicPort()", function() {
it("should return a port number", function() {
let port = hostServer._randomDynamicPort();
assert.equal(typeof port, "number");
assert(Number.isInteger(port));
assert(0 <= port && port < 2**16);
});
it("should return a port number in the dynamic range", function() {
function validate(port) {
return (49152 <= port && port <= 65535);
}
for (let i=0; i < 20; i++) {
assert(validate(hostServer._randomDynamicPort()));
}
});
});
describe("generatePassword()", function() {
it("should return a string", async function() {
let password = await hostServer._generatePassword(1);
assert.equal(typeof password, "string");
});
it("should return a string of the given length", async function() {
let password = await hostServer._generatePassword(10);
assert.equal(password.length, 10);
});
it("should contain only a-z, A-Z, 0-9", async function() {
let password = await hostServer._generatePassword(10);
assert(/^[a-zA-Z0-9]+$/.test(password), `${password} failed test`);
});
});
describe("parseOutput()", function() {
it("should parse the test lines", function() {
for (let [line, reference] of testLines) {
reference.source = "test";
let output = hostServer._parseOutput(line, "test");
assert.deepEqual(output, reference);
}
});
});
describe("class FactorioServer", function() {
let writePath = path.join("temp", "test", "server");
let server = new hostServer.FactorioServer(path.join("test", "file", "factorio"), writePath, {});
describe("constructor()", function() {
it("should handle dashes in write path with strapPaths enabled", function() {
// eslint-disable-next-line no-new
new hostServer.FactorioServer(
path.join("test", "file", "factorio"),
path.join("temp", "test", "server-1"),
{ stripPaths: true }
);
});
});
describe(".setTargetVersion()", function() {
it("should override the target version before init", function() {
const fresh = new hostServer.FactorioServer(
path.join("test", "file", "factorio"), writePath, {}
);
fresh.setTargetVersion("0.1.5");
assert.equal(fresh._targetVersion, "0.1.5");
});
});
describe(".init()", function() {
it("should not throw on first call", async function() {
await server.init();
});
it("should throw if called twice", async function() {
await assert.rejects(server.init(), new Error("Expected state new but state is init"));
});
});
describe(".version", function() {
it("should return the version detected", function() {
assert.equal(server.version, "0.1.2");
});
});
describe("._handleIpc()", function() {
it("should emit the correct ipc event", async function() {
let waiter = events.once(server, "ipc-channel");
await server._handleIpc(Buffer.from('\f$ipc:channel?j"value"'));
let result = await waiter;
assert.equal(result[0], "value");
});
it("should handle special characters in channel name", async function() {
let waiter = events.once(server, "ipc-$ ?\x00\x0a:");
await server._handleIpc(Buffer.from('\f$ipc:$ \\x3f\\x00\\x0a:?j"value"'));
let result = await waiter;
assert.equal(result[0], "value");
});
it("should throw on malformed ipc line", async function() {
await assert.rejects(
server._handleIpc(Buffer.from("\f$ipc:blah")),
new Error('Malformed IPC line "\f$ipc:blah"')
);
});
it("should throw on unknown type", async function() {
await assert.rejects(
server._handleIpc(Buffer.from("\f$ipc:channel??")),
new Error("Unknown IPC type '?'")
);
});
it("should throw on unknown file type", async function() {
await assert.rejects(
server._handleIpc(Buffer.from("\f$ipc:channel?ffoo.invalid")),
new Error("Unknown IPC file format 'invalid'")
);
});
it("should throw on file name with slash", async function() {
await assert.rejects(
server._handleIpc(Buffer.from("\f$ipc:channel?fa/b")),
new Error("Invalid IPC file name 'a/b'")
);
});
it("should load and delete json file", async function() {
await fs.mkdir(server.writePath("script-output"), { recursive: true });
let filePath = server.writePath("script-output", "data.json");
await fs.writeFile(filePath, '{"data":"spam"}');
let waiter = events.once(server, "ipc-channel");
await server._handleIpc(Buffer.from("\f$ipc:channel?fdata.json"));
let result = await waiter;
assert.deepEqual(result[0], { "data": "spam" });
await assert.rejects(fs.access(filePath), "File was not deleted");
});
});
describe(".handle()", function() {
it("should log validation errors from ipc handlers", async function() {
let logged = [];
let ipcServer = new hostServer.FactorioServer(
path.join("test", "file", "factorio"), writePath,
{ logger: { error: msg => { logged.push(msg); } } }
);
class NumberEvent {
static type = "event";
static src = "instance";
static dst = "controller";
constructor(value) { this.value = value; }
static jsonSchema = { type: "number" };
static fromJSON(json) { return new this(json); }
}
let eventFromJSON = Link.eventFromJSON(NumberEvent, "NumberEvent");
ipcServer.handle("bad_event", async () => { eventFromJSON("not a number"); });
ipcServer.emit("ipc-bad_event", {});
await new Promise(resolve => setImmediate(resolve));
assert.equal(logged.length, 1);
assert(logged[0].startsWith("Error handling ipc event:\nError: Event NumberEvent failed validation\n"));
assert(logged[0].includes('"message": "must be number"'));
});
});
describe("Lua UDP", function() {
let udpServer;
let received;
before(async function() {
// Stand-in for the Factorio server's --enable-lua-udp socket
udpServer = dgram.createSocket("udp4");
received = [];
udpServer.on("message", (msg, rinfo) => received.push([msg, rinfo]));
await events.once(udpServer.bind(0, "127.0.0.1"), "listening");
});
after(function() {
udpServer.close();
});
it("should not have a host port when not running", function() {
assert.equal(server.hostUdpPort, undefined);
});
it("should reject sendUdp when not enabled", async function() {
server._state = "running";
try {
await assert.rejects(server.sendUdp("data"), new Error("Lua UDP is not enabled"));
} finally {
server._state = "init";
}
});
it("should bind a socket, send and receive ipc over UDP", async function() {
server.enableLuaUdp = true;
server.luaUdpPort = udpServer.address().port;
await server._bindUdpSocket();
server._state = "running";
try {
assert(server.hostUdpPort > 0, "hostUdpPort not set");
await server.sendUdp("hello");
await server.sendUdp(Buffer.from("world"));
while (received.length < 2) {
await wait(1);
}
assert.equal(received[0][0].toString(), "hello");
assert.equal(received[1][0].toString(), "world");
assert.equal(received[0][1].port, server.hostUdpPort);
let ipcReceived = [];
let onIpc = content => ipcReceived.push(content);
server.on("ipc-udp_channel", onIpc);
let waiter = events.once(server, "ipc-udp_channel");
// Packets not from the Factorio server's port are ignored
let other = dgram.createSocket("udp4");
await events.once(other.bind(0, "127.0.0.1"), "listening");
other.send(Buffer.from('\f$ipc:udp_channel?j{"data":"bad"}'), server.hostUdpPort, "127.0.0.1");
udpServer.send(Buffer.from('\f$ipc:udp_channel?j{"data":"spam"}'), server.hostUdpPort, "127.0.0.1");
await waiter;
server.off("ipc-udp_channel", onIpc);
other.close();
assert.deepEqual(ipcReceived, [{ data: "spam" }]);
} finally {
server._resetState();
server.enableLuaUdp = false;
}
assert.equal(server._udpSocket, null);
assert.equal(server.hostUdpPort, undefined);
});
it("should warn on packets not in IPC format", async function() {
server.luaUdpPort = udpServer.address().port;
await server._bindUdpSocket();
const logger = server._logger;
let warnings = [];
server._logger = { warn: msg => warnings.push(msg) };
try {
udpServer.send(Buffer.from("not ipc"), server.hostUdpPort, "127.0.0.1");
while (warnings.length < 1) {
await wait(1);
}
assert.equal(warnings[0], "Ignoring UDP packet not in IPC format: not ipc");
} finally {
server._logger = logger;
server._resetState();
}
});
it("should emit error on malformed IPC packets", async function() {
server.luaUdpPort = udpServer.address().port;
await server._bindUdpSocket();
try {
let waiter = events.once(server, "error");
udpServer.send(Buffer.from("\f$ipc:no_separator"), server.hostUdpPort, "127.0.0.1");
let [err] = await waiter;
assert.equal(err.message, 'Malformed IPC line "\f$ipc:no_separator"');
} finally {
server._resetState();
}
});
it("should forward socket errors", async function() {
await server._bindUdpSocket();
try {
let waiter = events.once(server, "error");
server._udpSocket.emit("error", new Error("socket failed"));
let [err] = await waiter;
assert.equal(err.message, "socket failed");
} finally {
server._resetState();
}
});
it("should reject sendUdp when sending fails", async function() {
server.luaUdpPort = udpServer.address().port;
await server._bindUdpSocket();
server._state = "running";
try {
// Larger than the maximum size of a UDP datagram
await assert.rejects(server.sendUdp(Buffer.alloc(70000)), { code: "EMSGSIZE" });
} finally {
server._resetState();
}
});
it("should close the socket if binding fails", async function() {
const createSocket = dgram.createSocket;
dgram.createSocket = (...args) => {
const socket = createSocket(...args);
socket.bind = () => process.nextTick(() => socket.emit("error", new Error("bind failed")));
return socket;
};
try {
await assert.rejects(server._bindUdpSocket(), new Error("bind failed"));
} finally {
dgram.createSocket = createSocket;
}
assert.equal(server._udpSocket, null);
});
});
describe(".stop()", function() {
it("should handle server quitting on its own during stop", async function() {
server.shutdownTimeoutMs = 20;
server._server = new events.EventEmitter();
server._server.kill = () => true;
server._state = "running";
server._rconReady = false;
server._rconClient = {
async sendRcon() { },
async end() {
server._rconClient = null;
},
};
server._watchExit();
const stop = server.stop();
stop.catch(() => {});
process.nextTick(() => {
server.emit("rcon-ready");
server._server.emit("exit");
});
await stop;
await wait(21); // Wait until after shutdown timeout
});
});
describe(".checkForUpdates()", function() {
let _fetch;
let fetchCalledWith;
let _platform = process.platform;
beforeEach(function() {
_fetch = global.fetch;
fetchCalledWith = null;
global.fetch = async function(url) {
fetchCalledWith = url;
return {
ok: false,
status: -1,
statusText: "Fetch called",
};
};
});
afterEach(function() {
global.fetch = _fetch;
server._factorioDir = path.join("test", "file", "factorio");
Object.defineProperty(process, "platform", {
value: _platform,
});
});
for (const [suite, target] of [
["full version", "0.1.1"],
["partial version", "0.1"],
["latest version", "latest"],
]) {
/* eslint-disable no-loop-func */
describe(suite, function() {
it("should do nothing when there are no versions", async function() {
server._factorioDir = path.join("test", "file", "factorio");
server._targetVersion = target;
await server.checkForUpdates([]);
assert.equal(fetchCalledWith, null);
});
if (target !== "latest") {
it("should do nothing when no version matches", async function() {
server._factorioDir = path.join("test", "file", "factorio");
server._targetVersion = target;
await server.checkForUpdates([{
stable: true,
version: "0.2.1",
headlessUrl: "test1",
}, {
stable: false,
version: "0.2.0",
headlessUrl: "test2",
}]);
assert.equal(fetchCalledWith, null);
});
}
it("should do nothing when there is no newer version", async function() {
server._factorioDir = path.join("test", "file", "factorio");
server._targetVersion = target;
await server.checkForUpdates([{
stable: true,
version: "0.1.1",
headlessUrl: "test1",
}, {
stable: false,
version: "0.1.0",
headlessUrl: "test2",
}]);
assert.equal(fetchCalledWith, null);
});
it("should do nothing for direct installs", async function() {
server._factorioDir = path.join("test", "file", "factorio", "0.1.1");
server._targetVersion = target === "0.1.1" ? "0.1.5" : target;
await server.checkForUpdates([{
stable: true,
version: "0.1.5",
headlessUrl: "test1",
}, {
stable: true,
version: "0.1.1",
headlessUrl: "test1",
}, {
stable: false,
version: "0.1.0",
headlessUrl: "test2",
}]);
assert.equal(fetchCalledWith, null);
});
it("should do nothing when on windows", async function() {
let logLine = null;
server._logger = { info: line => { logLine = line; } };
Object.defineProperty(process, "platform", { value: "win32" });
server._factorioDir = path.join("test", "file", "factorio");
server._targetVersion = target === "0.1.1" ? "0.1.5" : target;
await server.checkForUpdates([{
stable: true,
version: "0.1.5",
headlessUrl: "test1",
}, {
stable: true,
version: "0.1.1",
headlessUrl: "test2",
}, {
stable: false,
version: "0.1.0",
headlessUrl: "test3",
}]);
assert.equal(fetchCalledWith, null);
assert.ok(logLine !== null);
assert.ok(logLine.endsWith("(automatic downloads are only supported on Linux)."));
});
it("should do attempt to download on linux", async function() {
let logLine = null;
server._logger = { info: line => { logLine = line; } };
Object.defineProperty(process, "platform", { value: "linux" });
server._factorioDir = path.join("test", "file", "factorio");
server._targetVersion = target === "0.1.1" ? "0.1.5" : target;
await assert.rejects(server.checkForUpdates([{
stable: true,
version: "0.1.5",
headlessUrl: "test1",
}, {
stable: true,
version: "0.1.1",
headlessUrl: "test2",
}, {
stable: false,
version: "0.1.0",
headlessUrl: "test3",
}]), new Error("Failed to fetch test1: -1 Fetch called"));
assert.equal(fetchCalledWith, "test1");
assert.ok(logLine !== null);
assert.ok(logLine.endsWith("starting download..."));
});
});
/* eslint-enable no-loop-func */
}
it("should download a version correctly (live api)", async function() {
slowTest(this);
externalTest(this);
if (_platform !== "linux") {
this.skip();
}
server._factorioDir = path.join("temp", "test", "factorioDownload");
server._targetVersion = "latest";
global.fetch = _fetch;
await fs.rm(server._factorioDir, { force: true, recursive: true, maxRetries: 10 });
await fs.mkdir(server._factorioDir, { recursive: true });
await server.checkForUpdates([{
stable: true,
version: "2.0.73",
headlessUrl: "https://www.factorio.com/get-download/2.0.73/headless/linux64",
}]);
});
});
});
});