mirror of
https://github.com/clusterio/clusterio.git
synced 2026-11-12 07:04:52 +09:00
findVersion takes headless as a plain optional argument, the helper is checkInstallType, the warning is checkMissingBuiltinIcons and the full install lookup is inlined into exportData. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
826 lines
30 KiB
JavaScript
826 lines
30 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("headless argument", function() {
|
|
const mixedDir = path.join("test", "file", "factorio-mixed");
|
|
it("should find only full installs when false", async function() {
|
|
const [dir] = await hostServer.findVersion(mixedDir, "0.1.2", false);
|
|
assert.equal(dir, path.join(mixedDir, "full", "data"));
|
|
});
|
|
it("should find only headless installs when true", async function() {
|
|
const [dir] = await hostServer.findVersion(mixedDir, "0.1.2", true);
|
|
assert.equal(dir, path.join(mixedDir, "headless", "data"));
|
|
});
|
|
it("should skip installs of the other build when matching a partial version", async function() {
|
|
const installDir = path.join("test", "file", "factorio");
|
|
const [dir, version] = await hostServer.findVersion(installDir, "0.1", false);
|
|
assert.deepEqual([dir, version], [path.join(installDir, "0.1.2", "data"), "0.1.2"]);
|
|
});
|
|
it("should reject a direct install of the other build", async function() {
|
|
const installDir = path.join(mixedDir, "headless");
|
|
await assert.rejects(
|
|
hostServer.findVersion(installDir, "0.1.2", false),
|
|
new Error(
|
|
`Unable to find a full install of Factorio 0.1.2: ${installDir} is a direct ` +
|
|
"(single-version) install of the headless build."
|
|
)
|
|
);
|
|
});
|
|
it("should reject if no install of that build has the version", async function() {
|
|
await assert.rejects(
|
|
hostServer.findVersion(path.join("test", "file", "factorio"), "0.1.2", true),
|
|
new Error("Unable to find headless Factorio version 0.1.2")
|
|
);
|
|
});
|
|
});
|
|
});
|
|
|
|
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(port >= 0 && 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;
|
|
before(async function() {
|
|
server = await hostServer.FactorioServer.fromOptions(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"),
|
|
path.join("test", "file", "factorio", "0.1.2", "data"),
|
|
"0.1.2",
|
|
{ stripPaths: true, rconPassword: "" }
|
|
);
|
|
});
|
|
});
|
|
|
|
describe(".fromOptions()", function() {
|
|
it("should resolve the requested version", async function() {
|
|
const found = await hostServer.FactorioServer.fromOptions(
|
|
path.join("test", "file", "factorio"), writePath, { version: "0.1.1" }
|
|
);
|
|
assert.equal(found.version, "0.1.1");
|
|
assert.equal(found.dataPath(), path.join("test", "file", "factorio", "0.1.1", "data"));
|
|
assert.equal(found._state, "init");
|
|
});
|
|
it("should generate an RCON password if none is given", async function() {
|
|
assert.equal(typeof server.rconPassword, "string");
|
|
assert.equal(server.rconPassword.length, 10);
|
|
const given = await hostServer.FactorioServer.fromOptions(
|
|
path.join("test", "file", "factorio"), writePath, { rconPassword: "secret" }
|
|
);
|
|
assert.equal(given.rconPassword, "secret");
|
|
});
|
|
it("should reject if the version is not installed", async function() {
|
|
await assert.rejects(
|
|
hostServer.FactorioServer.fromOptions(
|
|
path.join("test", "file", "factorio"), writePath, { version: "0.1.5" }
|
|
),
|
|
/Unable to find Factorio version 0.1.5/
|
|
);
|
|
});
|
|
});
|
|
|
|
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 = await hostServer.FactorioServer.fromOptions(
|
|
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");
|
|
server.luaUdpPort = udpServer.address().port;
|
|
});
|
|
after(function() {
|
|
udpServer.close();
|
|
});
|
|
afterEach(function() {
|
|
server._stopUdp();
|
|
server._state = "init";
|
|
});
|
|
function sendFromGame(message) {
|
|
udpServer.send(Buffer.from(message), server.hostUdpPort, "127.0.0.1");
|
|
}
|
|
function captureLogger(level) {
|
|
const logger = server._logger;
|
|
const lines = [];
|
|
server._logger = { [level]: msg => lines.push(msg) };
|
|
return { lines, restore: () => { server._logger = logger; } };
|
|
}
|
|
|
|
it("should refuse to start on Factorio before 2.1.10", async function() {
|
|
const version = server._version;
|
|
server._version = "2.1.9";
|
|
server.enableLuaUdp = true;
|
|
try {
|
|
const error = new Error("Lua UDP requires Factorio 2.1.10 or later");
|
|
await assert.rejects(server.start("save.zip"), error);
|
|
await assert.rejects(server.startScenario("test"), error);
|
|
assert.equal(server._state, "init");
|
|
} finally {
|
|
server._version = version;
|
|
server.enableLuaUdp = false;
|
|
}
|
|
});
|
|
it("should open the socket when RCON starts", async function() {
|
|
const startRcon = server._startRcon;
|
|
server._startRcon = async () => {};
|
|
server.enableLuaUdp = true;
|
|
try {
|
|
server._handleOutput(Buffer.from(
|
|
" 0.500 Info RemoteCommandProcessor.cpp:133: Starting RCON interface"
|
|
), "stdout");
|
|
while (!server._udpSocket) {
|
|
await wait(1);
|
|
}
|
|
} finally {
|
|
server._startRcon = startRcon;
|
|
server.enableLuaUdp = false;
|
|
}
|
|
assert(server.hostUdpPort > 0, "socket was not opened");
|
|
});
|
|
it("should not have a host port when not running", function() {
|
|
assert.equal(server.hostUdpPort, undefined);
|
|
});
|
|
it("should reject sendUdp when the socket is not open", async function() {
|
|
server._state = "running";
|
|
await assert.rejects(server.sendUdp("data"), new Error("UDP socket is not open"));
|
|
});
|
|
it("should send packets to the Lua UDP port", async function() {
|
|
await server._startUdp();
|
|
server._state = "running";
|
|
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);
|
|
});
|
|
it("should emit udp events for packets from the game", async function() {
|
|
await server._startUdp();
|
|
let udpReceived = [];
|
|
let onUdp = data => udpReceived.push(data);
|
|
// Escaped characters in the channel name are unescaped
|
|
server.on("udp-udp?channel", onUdp);
|
|
let waiter = events.once(server, "udp-udp?channel");
|
|
// Packets not from the Factorio server's port are ignored
|
|
let other = dgram.createSocket("udp4");
|
|
try {
|
|
await events.once(other.bind(0, "127.0.0.1"), "listening");
|
|
other.send(Buffer.from("udp\\x3fchannel?bad"), server.hostUdpPort, "127.0.0.1");
|
|
sendFromGame("udp\\x3fchannel?spam?eggs");
|
|
await waiter;
|
|
} finally {
|
|
other.close();
|
|
server.off("udp-udp?channel", onUdp);
|
|
}
|
|
assert(Buffer.isBuffer(udpReceived[0]), "data is not a Buffer");
|
|
assert.deepEqual(udpReceived.map(String), ["spam?eggs"]);
|
|
});
|
|
it("should warn on malformed packets and unhandled channels", async function() {
|
|
await server._startUdp();
|
|
const { lines, restore } = captureLogger("warn");
|
|
try {
|
|
sendFromGame("no separator");
|
|
sendFromGame("unhandled?data");
|
|
while (lines.length < 2) {
|
|
await wait(1);
|
|
}
|
|
} finally {
|
|
restore();
|
|
}
|
|
assert.deepEqual(lines, [
|
|
'Ignoring malformed UDP packet "no separator"',
|
|
"Warning: Unhandled udp-unhandled",
|
|
]);
|
|
});
|
|
it("should invoke handlers registered with handleUdp", async function() {
|
|
let handled = [];
|
|
server.handleUdp("handled", async data => { handled.push(data.toString()); });
|
|
server.handleUdp("failing", async () => { throw new Error("boom"); });
|
|
const { lines, restore } = captureLogger("error");
|
|
try {
|
|
server.emit("udp-handled", Buffer.from("data"));
|
|
server.emit("udp-failing", Buffer.from("data"));
|
|
await new Promise(resolve => setImmediate(resolve));
|
|
} finally {
|
|
restore();
|
|
}
|
|
assert.deepEqual(handled, ["data"]);
|
|
assert.equal(lines.length, 1);
|
|
assert(lines[0].startsWith("Error handling udp event:\nError: boom"), lines[0]);
|
|
});
|
|
it("should log errors thrown synchronously by handleUdp handlers", async function() {
|
|
await server._startUdp();
|
|
server.handleUdp("sync", () => { throw new Error("sync boom"); });
|
|
const { lines, restore } = captureLogger("error");
|
|
try {
|
|
sendFromGame("sync?data");
|
|
while (lines.length < 1) {
|
|
await wait(1);
|
|
}
|
|
} finally {
|
|
restore();
|
|
}
|
|
assert(lines[0].startsWith("Error handling udp event:\nError: sync boom"), lines[0]);
|
|
});
|
|
it("should forward socket errors", async function() {
|
|
await server._startUdp();
|
|
let waiter = events.once(server, "error");
|
|
server._udpSocket.emit("error", new Error("socket failed"));
|
|
let [err] = await waiter;
|
|
assert.equal(err.message, "socket failed");
|
|
});
|
|
it("should reject sendUdp when sending fails", async function() {
|
|
await server._startUdp();
|
|
server._state = "running";
|
|
// Larger than the maximum size of a UDP datagram
|
|
await assert.rejects(server.sendUdp(Buffer.alloc(70000)), { code: "EMSGSIZE" });
|
|
});
|
|
it("should throw if the socket is already open", async function() {
|
|
await server._startUdp();
|
|
await assert.rejects(server._startUdp(), new Error("UDP socket is already open"));
|
|
});
|
|
it("should log an error if opening the socket 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;
|
|
};
|
|
const { lines, restore } = captureLogger("error");
|
|
try {
|
|
await server._startUdp();
|
|
} finally {
|
|
dgram.createSocket = createSocket;
|
|
restore();
|
|
}
|
|
assert.equal(server._udpSocket, null);
|
|
assert(lines[0].startsWith("Failed to open UDP socket:\nError: bind failed"), lines[0]);
|
|
});
|
|
it("should close the socket when the server exits", async function() {
|
|
await server._startUdp();
|
|
server._server = new events.EventEmitter();
|
|
server._state = "stopping";
|
|
server._watchExit();
|
|
server._server.emit("exit", 0, null);
|
|
assert.equal(server._udpSocket, null);
|
|
assert.equal(server._state, "init");
|
|
});
|
|
it("should close the socket when the server is killed for hanging", async function() {
|
|
await server._startUdp();
|
|
let killed = false;
|
|
server._server = { kill: () => { killed = true; } };
|
|
const { restore } = captureLogger("error");
|
|
try {
|
|
server.onStopTimeout();
|
|
} finally {
|
|
restore();
|
|
server._server = null;
|
|
}
|
|
assert(killed, "server was not killed");
|
|
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;
|
|
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() {
|
|
const factorioDir = path.join("test", "file", "factorio");
|
|
// A target that matches the 0.1.5 download but not the installed 0.1.1
|
|
const newerTarget = target === "0.1.1" ? "0.1.5" : target;
|
|
it("should do nothing when there are no versions", async function() {
|
|
await hostServer.checkForUpdates(factorioDir, target, []);
|
|
|
|
assert.equal(fetchCalledWith, null);
|
|
});
|
|
if (target !== "latest") {
|
|
it("should do nothing when no version matches", async function() {
|
|
await hostServer.checkForUpdates(factorioDir, target, [{
|
|
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() {
|
|
await hostServer.checkForUpdates(factorioDir, target, [{
|
|
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() {
|
|
await hostServer.checkForUpdates(path.join(factorioDir, "0.1.1"), newerTarget, [{
|
|
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;
|
|
const logger = { info: line => { logLine = line; } };
|
|
Object.defineProperty(process, "platform", { value: "win32" });
|
|
|
|
await hostServer.checkForUpdates(factorioDir, newerTarget, [{
|
|
stable: true,
|
|
version: "0.1.5",
|
|
headlessUrl: "test1",
|
|
}, {
|
|
stable: true,
|
|
version: "0.1.1",
|
|
headlessUrl: "test2",
|
|
}, {
|
|
stable: false,
|
|
version: "0.1.0",
|
|
headlessUrl: "test3",
|
|
}], logger);
|
|
|
|
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;
|
|
const logger = { info: line => { logLine = line; } };
|
|
Object.defineProperty(process, "platform", { value: "linux" });
|
|
|
|
await assert.rejects(hostServer.checkForUpdates(factorioDir, newerTarget, [{
|
|
stable: true,
|
|
version: "0.1.5",
|
|
headlessUrl: "test1",
|
|
}, {
|
|
stable: true,
|
|
version: "0.1.1",
|
|
headlessUrl: "test2",
|
|
}, {
|
|
stable: false,
|
|
version: "0.1.0",
|
|
headlessUrl: "test3",
|
|
}], logger), 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 */
|
|
}
|
|
describe("concurrent downloads", function() {
|
|
const versions = [{ stable: true, version: "0.1.5", headlessUrl: "test1" }];
|
|
|
|
// Holds the mocked download open until release() and resolves waiting once a check waits for it
|
|
function holdDownload(factorioDir) {
|
|
const held = { fetches: 0 };
|
|
const release = new Promise(resolve => { held.release = resolve; });
|
|
held.fetching = new Promise(resolve => {
|
|
global.fetch = async function() {
|
|
held.fetches += 1;
|
|
resolve();
|
|
await release;
|
|
return { ok: false, status: -1, statusText: "Fetch called" };
|
|
};
|
|
});
|
|
let waited;
|
|
held.waiting = new Promise(resolve => { waited = resolve; });
|
|
const logger = { info: line => line.endsWith("waiting for download...") && waited() };
|
|
held.check = () => hostServer.checkForUpdates(factorioDir, "latest", versions, logger);
|
|
Object.defineProperty(process, "platform", { value: "linux" });
|
|
return held;
|
|
}
|
|
|
|
it("should download once when servers check at the same time", async function() {
|
|
const held = holdDownload(path.join("test", "file", "factorio"));
|
|
let settled = 0;
|
|
const checks = [0, 1].map(() => held.check().finally(() => { settled += 1; }));
|
|
|
|
await held.waiting;
|
|
assert.equal(held.fetches, 1);
|
|
assert.equal(settled, 0);
|
|
|
|
held.release();
|
|
await Promise.all(checks.map(check => assert.rejects(check, /Fetch called/)));
|
|
assert.equal(held.fetches, 1);
|
|
});
|
|
it("should wait for a download in progress whose version already looks installed", async function() {
|
|
const factorioDir = path.join("temp", "test", "download-lock");
|
|
await fs.rm(factorioDir, { force: true, recursive: true });
|
|
await fs.mkdir(factorioDir, { recursive: true });
|
|
const held = holdDownload(factorioDir);
|
|
const first = held.check();
|
|
await held.fetching;
|
|
|
|
// The extraction has written the changelog but has not finished
|
|
const dataDir = path.join(factorioDir, "0.1.5", "data");
|
|
await fs.mkdir(dataDir, { recursive: true });
|
|
await fs.writeFile(path.join(dataDir, "changelog.txt"), "Version: 0.1.5\n");
|
|
const second = held.check();
|
|
|
|
await held.waiting;
|
|
held.release();
|
|
await Promise.all([first, second].map(check => assert.rejects(check, /Fetch called/)));
|
|
assert.equal(held.fetches, 1);
|
|
});
|
|
});
|
|
it("should download a version correctly (live api)", async function() {
|
|
slowTest(this);
|
|
externalTest(this);
|
|
if (_platform !== "linux") {
|
|
this.skip();
|
|
}
|
|
|
|
const factorioDir = path.join("temp", "test", "factorioDownload");
|
|
global.fetch = _fetch;
|
|
await fs.rm(factorioDir, { force: true, recursive: true, maxRetries: 10 });
|
|
await fs.mkdir(factorioDir, { recursive: true });
|
|
await hostServer.checkForUpdates(factorioDir, "latest", [{
|
|
stable: true,
|
|
version: "2.0.73",
|
|
headlessUrl: "https://www.factorio.com/get-download/2.0.73/headless/linux64",
|
|
}]);
|
|
});
|
|
});
|
|
});
|
|
});
|