mirror of
https://github.com/clusterio/clusterio.git
synced 2026-11-11 15:04:52 +09:00
FactorioServer.fromOptions finds the Factorio install and generates the RCON password before constructing, so _version and _dataDir are never null and setTargetVersion and init are gone. checkForUpdates is now a module function called before fromOptions. Instance.fromConfig resolves the version, downloads Factorio if needed and builds the server, then constructs the instance with it. It runs before the instance is connected, so the starting and stopped statuses and the version requests go through the host link. FactorioVersionsRequest and LatestReleasesRequest now accept host as a source, and the host tracks starting instances so a second start during a download is rejected. Plugin loading stays a separate loadPlugins step after the instance is connected. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
124 lines
4.5 KiB
JavaScript
124 lines
4.5 KiB
JavaScript
import assert from "node:assert/strict";
|
|
import events from "node:events";
|
|
import path from "node:path";
|
|
|
|
import * as lib from "@clusterio/lib";
|
|
import { FactorioServer } from "@clusterio/host/dist/node/src/server.js";
|
|
|
|
|
|
// Stand-in for the Factorio child process, only exit and kill are used here.
|
|
class FakeProcess extends events.EventEmitter {
|
|
killed = false;
|
|
kill() {
|
|
this.killed = true;
|
|
}
|
|
}
|
|
|
|
function createStartedServer() {
|
|
const server = new FactorioServer(
|
|
path.join("test", "file", "factorio"), path.join("temp", "test", "server"),
|
|
path.join("test", "file", "factorio", "0.1.2", "data"), "0.1.2", { rconPassword: "" },
|
|
);
|
|
server._state = "running";
|
|
server._rconReady = false;
|
|
server._server = new FakeProcess();
|
|
server._watchExit();
|
|
// Instance keeps an error listener on the server for as long as it runs.
|
|
server.on("error", () => {});
|
|
return server;
|
|
}
|
|
|
|
// Fail instead of hanging if the call never settles.
|
|
async function withTimeout(promise, message) {
|
|
let timeoutId;
|
|
const timeout = new Promise((resolve, reject) => {
|
|
timeoutId = setTimeout(() => reject(new Error(message)), 500);
|
|
});
|
|
try {
|
|
return await Promise.race([promise, timeout]);
|
|
} finally {
|
|
clearTimeout(timeoutId);
|
|
}
|
|
}
|
|
|
|
describe("host/src/server", function() {
|
|
describe("FactorioServer.stop()", function() {
|
|
it("returns if the process exits before RCON is ready", async function() {
|
|
const server = createStartedServer();
|
|
const stopped = server.stop();
|
|
server._server.emit("exit", 1, null);
|
|
await withTimeout(stopped, "stop() did not resolve");
|
|
assert.equal(server._state, "init");
|
|
assert.equal(server.listenerCount("rcon-ready"), 0);
|
|
assert.equal(server.listenerCount("error"), 1);
|
|
});
|
|
|
|
it("returns if the process fails before RCON is ready", async function() {
|
|
const server = createStartedServer();
|
|
const stopped = server.stop();
|
|
// Failing to spawn emits an error event followed by exit.
|
|
server._server.emit("error", Object.assign(new Error("spawn failed"), { code: "EACCES" }));
|
|
await withTimeout(stopped, "stop() did not resolve");
|
|
assert.equal(server._state, "init");
|
|
assert.equal(server.listenerCount("rcon-ready"), 0);
|
|
assert.equal(server.listenerCount("error"), 1);
|
|
});
|
|
|
|
it("does not reject if the process exits with an error before RCON is ready", async function() {
|
|
const server = createStartedServer();
|
|
const stopped = server.stop();
|
|
// The wait rejects with this error, stopping succeeds regardless.
|
|
server.emit("error", new lib.EnvironmentError("Factorio server was killed"));
|
|
server._server.emit("exit", null, "SIGKILL");
|
|
await assert.doesNotReject(withTimeout(stopped, "stop() did not resolve"));
|
|
assert.equal(server._state, "init");
|
|
assert.equal(server.listenerCount("rcon-ready"), 0);
|
|
assert.equal(server.listenerCount("exit"), 0);
|
|
assert.equal(server.listenerCount("error"), 1);
|
|
});
|
|
|
|
it("stops the server if RCON becomes ready", async function() {
|
|
const server = createStartedServer();
|
|
const stopped = server.stop();
|
|
server._rconReady = true;
|
|
server.emit("rcon-ready");
|
|
await new Promise(resolve => setImmediate(resolve));
|
|
assert.equal(server.listenerCount("exit"), 0);
|
|
const fakeProcess = server._server;
|
|
assert.equal(fakeProcess.killed, true);
|
|
fakeProcess.emit("exit", 0, null);
|
|
await withTimeout(stopped, "stop() did not resolve");
|
|
});
|
|
});
|
|
|
|
describe("FactorioServer.sendRcon()", function() {
|
|
it("throws if the process is killed before RCON is ready", async function() {
|
|
const server = createStartedServer();
|
|
const sent = server.sendRcon("/version");
|
|
// kill() sets the state before killing, which suppresses the error event.
|
|
server._state = "stopping";
|
|
server._server.emit("exit", null, "SIGKILL");
|
|
await assert.rejects(
|
|
withTimeout(sent, "sendRcon() did not settle"),
|
|
new Error("RCON connection lost"),
|
|
);
|
|
assert.equal(server.listenerCount("rcon-ready"), 0);
|
|
assert.equal(server.listenerCount("error"), 1);
|
|
});
|
|
|
|
it("throws the exit error if the process exits before RCON is ready", async function() {
|
|
const server = createStartedServer();
|
|
const sent = server.sendRcon("/version");
|
|
// An unexpected exit emits why the server went away before the exit event.
|
|
server._server.emit("exit", 1, null);
|
|
await assert.rejects(
|
|
withTimeout(sent, "sendRcon() did not settle"),
|
|
new lib.EnvironmentError("Factorio server unexpectedly shut down with code 1"),
|
|
);
|
|
assert.equal(server.listenerCount("rcon-ready"), 0);
|
|
assert.equal(server.listenerCount("exit"), 0);
|
|
assert.equal(server.listenerCount("error"), 1);
|
|
});
|
|
});
|
|
});
|