Files
factorio-clusterio/test/integration/instance.js
T
bbassieandClaude Opus 5.5 ca3dbab49d Unpatch saves started with save patching disabled
Turning factorio.enable_save_patching off did nothing to a save that
was patched before. Its control.lua still loaded every module, so the
clusterio_api interface, player_sync's join handling and the other
module event handlers kept running with no plugin on the host side.

When patching is disabled the instance now restores the original
control.lua that patching kept as scenario.lua and removes the module
files, locale files included, as listed in clusterio.json. The patch
info stays with an empty module list so the patch number keeps
counting. Otherwise patching the save again would reuse the number
stored in the game and on_server_startup would not be raised. Data
the modules put in storage is left alone.

PatchInfo.fromSave dropped the version read from the save, so the
check against newer patch versions never fired. It now keeps it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 00:16:52 +00:00

519 lines
21 KiB
JavaScript

import path from "node:path";
import dgram from "node:dgram";
import events from "node:events";
import assert from "node:assert/strict";
import * as lib from "@clusterio/lib";
import fs from "node:fs/promises";
import { slowTest, testMatrix } from "../common.js";
import {
execCtl, execCtlProcess, sendRcon, getControl,
requiresFactorio, hasFactorio, getFactorioVersion, instancesDir, hostId,
} from "./index.js";
import { getTestRconPort } from "./rcon_port.js";
const instId = 48;
const instName = "Integration";
const instSetConfig = `instance config set ${instName}`;
const instAltId = 49;
const instAltName = "IntegrationAlt";
const requireApi = [
"local api =",
"package.loaded['modules/clusterio/api']", // 1.1.110
"or package.loaded['__level__/modules/clusterio/api.lua']", // 2.0.0
].join(" ");
// Lua UDP needs Factorio 2.1.10, see docs/configuration.md
function hasLuaUdp() {
return lib.integerFullVersion(getFactorioVersion()) >= lib.integerFullVersion("2.1.10");
}
async function freeUdpPort() {
const socket = dgram.createSocket("udp4");
await events.once(socket.bind(0, "127.0.0.1"), "listening");
const port = socket.address().port;
socket.close();
return port;
}
function getUser(name) {
return getControl().send(new lib.UserGetRequest(name));
}
// RCON completion does not acknowledge list replication. In particular, whitelist
// updates pass through an asynchronous file watcher before being broadcast.
async function waitForAssertion(check, timeoutMs = 5000) {
const deadline = performance.now() + timeoutMs;
for (;;) {
try {
await check();
return;
} catch (err) {
if (!(err instanceof assert.AssertionError) || performance.now() >= deadline) {
throw err;
}
}
await lib.wait(25);
}
}
async function waitForUserFlag(name, flag, expected, message) {
await waitForAssertion(async () => {
// The initial event also creates the user, so absence is a pending state.
const users = await getControl().send(new lib.UserListRequest());
const user = users.find(entry => entry.name.toLowerCase() === name.toLowerCase());
assert(user, `User ${name} has not been created`);
assert.equal(user[flag], expected, message);
});
}
async function waitForAltAdmin(expected, message) {
await waitForAssertion(async () => {
// Read Factorio's persisted list before checking /promote or /demote
// responses, so the checks cannot apply a change that has not replicated yet.
const json = await fs.readFile(path.join(instancesDir, instAltName, "server-adminlist.json"), "utf8");
let admins;
// A concurrent write can leave a partial document; retry that observation.
assert.doesNotThrow(() => { admins = JSON.parse(json); }, "Invalid admin list JSON");
assert(Array.isArray(admins), "Admin list is not an array");
assert.equal(admins.some(name => name.toLowerCase() === "adminplayer"), expected, message);
});
}
describe("Clusterio Instance", function() {
requiresFactorio(this);
before(async function() {
this.timeout(20000);
const visibility = JSON.stringify({ lan: true, public: false }).replace(
/"/g, process.platform === "win32" ? '""' : '\\"'
);
// Create a new instance
await execCtl(`instance create ${instName} --id ${instId}`);
await execCtl(`instance config set ${instName} factorio.rcon_port ${await getTestRconPort()}`);
await execCtl(`instance config set ${instName} factorio.enable_whitelist true`);
await execCtlProcess(`instance config set-prop ${instName} factorio.settings visibility "${visibility}"`);
await execCtl(`instance config set-prop ${instName} factorio.settings require_user_verification false`);
await execCtl(`instance assign ${instName} ${hostId}`);
// Exclude main test instance from start-all to prevent interference
await execCtl(`instance config set ${instName} instance.exclude_from_start_all true`);
for (const plugin of [
"global_chat", "research_sync", "statistics_exporter", "subspace_storage", "player_auth", "player_sync",
]) {
await execCtl(`instance config set ${instName} ${plugin}.load_plugin false`);
}
// Create a new alt instance
await execCtl(`instance create ${instAltName} --id ${instAltId}`);
await execCtl(`instance config set ${instAltName} factorio.rcon_port ${await getTestRconPort()}`);
await execCtl(`instance config set ${instAltName} factorio.enable_whitelist true`);
await execCtlProcess(`instance config set-prop ${instAltName} factorio.settings visibility "${visibility}"`);
await execCtl(`instance config set-prop ${instAltName} factorio.settings require_user_verification false`);
await execCtl(`instance assign ${instAltName} ${hostId}`);
// Exclude alt test instance from start-all to prevent interference
await execCtl(`instance config set ${instAltName} instance.exclude_from_start_all true`);
await execCtl(`instance start ${instAltName}`);
});
after(async function() {
if (!hasFactorio()) {
return;
}
this.timeout(20000);
// Delete the instances
await execCtl(`instance delete ${instName}`);
await execCtl(`instance stop ${instAltName}`);
await execCtl(`instance delete ${instAltName}`);
});
describe("whitelist creation", function() {
const whitelistPath = path.join(instancesDir, instName, "server-whitelist.json");
before(async function() {
this.timeout(20000);
await execCtl(`${instSetConfig} factorio.sync_whitelist disabled`);
await execCtl(`${instSetConfig} factorio.enable_whitelist false`);
});
afterEach(async function() {
this.timeout(20000);
await execCtl(`instance stop ${instName}`);
});
it("should always create a whitelist json even if unused", async function() {
slowTest(this);
await fs.rm(whitelistPath, { force: true });
await execCtl(`instance start ${instName}`);
const whitelist = JSON.parse(await fs.readFile(whitelistPath, "utf8"));
assert.deepEqual(whitelist, []);
});
it("should not overwrite the existing whitelist if sync is disabled", async function() {
slowTest(this);
await fs.writeFile(whitelistPath, JSON.stringify(["test_user"]));
await execCtl(`instance start ${instName}`);
const whitelist = JSON.parse(await fs.readFile(whitelistPath, "utf8"));
assert.deepEqual(whitelist, ["test_user"]);
});
});
describe("save patching disabled", function() {
it("should remove the modules from a save patched before", async function() {
this.timeout(40000);
const hasApi = "/sc rcon.print(tostring(remote.interfaces.clusterio_api ~= nil))";
await execCtl(`${instSetConfig} factorio.enable_save_patching true`);
await execCtl(`instance save create ${instName}`);
await execCtl(`instance start ${instName}`);
assert.equal(await sendRcon(instId, hasApi), "true\n");
await execCtl(`instance stop ${instName}`);
await execCtl(`${instSetConfig} factorio.enable_save_patching false`);
await execCtl(`instance start ${instName}`);
try {
assert.equal(await sendRcon(instId, hasApi), "false\n");
} finally {
await execCtl(`instance stop ${instName}`);
}
});
});
for (const [savePatchingEnabled, scriptCommandsEnabled] of testMatrix([true, false], [true, false])) {
const strSavePatching = `save patching: ${savePatchingEnabled ? "Enabled" : "Disabled"}`;
const strScriptCommands = `script commands: ${scriptCommandsEnabled ? "Enabled" : "Disabled"}`;
async function allowSendingScriptCommands() {
if (!scriptCommandsEnabled) {
// Enable command to prevent "Attempted to use script command when disabled"
// This works because we only disable achievements on startup, this will break if we make it live update
await execCtl(`${instSetConfig} factorio.enable_script_commands true`);
}
}
describe(`Integration - ${strSavePatching}, ${strScriptCommands}`, function() {
let luaUdpPort;
before(async function() {
this.timeout(20000);
// Create and start a patched save
await execCtl(`${instSetConfig} factorio.enable_whitelist true`);
await execCtl(`${instSetConfig} factorio.enable_save_patching ${savePatchingEnabled}`);
await execCtl(`${instSetConfig} factorio.enable_script_commands ${scriptCommandsEnabled}`);
await execCtl(`${instSetConfig} factorio.enable_lua_udp ${hasLuaUdp()}`);
luaUdpPort = await freeUdpPort();
await execCtl(`${instSetConfig} factorio.lua_udp_port ${luaUdpPort}`);
await execCtl(`instance save create ${instName}`);
await execCtl(`instance start ${instName}`);
});
after(async function() {
// Stop the instance
await execCtl(`instance stop ${instName}`);
});
describe("Disable achievements", function() {
it("should only disable achievements when script commands are enabled", async function() {
await allowSendingScriptCommands();
const res = await sendRcon(instId, "/sc rcon.print('Disabled')");
assert.equal(res === "Disabled\n", scriptCommandsEnabled);
});
});
if (savePatchingEnabled) {
describe("Lua UDP", function() {
it("should drop send_udp before the module is initialised", async function() {
// Mods can call send_udp from on_init, before the first tick sets up script data
await allowSendingScriptCommands();
const res = await sendRcon(instId,
`/sc ${requireApi} local data = storage or global ` +
"local saved = data.clusterio data.clusterio = nil " +
"local module_ok = pcall(api.send_udp, 'early', 'data') " +
"local mod_ok, port = pcall(remote.call, 'clusterio_api', 'get_host_udp_port') " +
"data.clusterio = saved " +
"rcon.print(tostring(module_ok) .. ' ' .. tostring(mod_ok) .. ' ' .. tostring(port))"
);
assert.equal(res, "true true nil\n");
});
it("should dispatch packets sent to the game", async function() {
if (!hasLuaUdp()) {
this.skip();
}
await allowSendingScriptCommands();
await sendRcon(instId,
"/sc script.on_event(defines.events.on_udp_packet_received, " +
"function(event) storage.udp_test = event.payload end)"
);
const socket = dgram.createSocket("udp4");
let payload;
try {
for (let i = 0; i < 10 && payload !== "hello\n"; i++) {
socket.send("hello", luaUdpPort, "127.0.0.1");
await lib.wait(10);
// The server does not tick without players, commands make it tick
payload = await sendRcon(instId, "/sc rcon.print(tostring(storage.udp_test))");
}
} finally {
socket.close();
}
assert.equal(payload, "hello\n");
});
});
}
describe("Player events", function() {
if (savePatchingEnabled) {
// IPC expects save patching to be enabled
it("should do nothing for an unknown type", async function() {
await assert.rejects(getUser("DoesNotExist"), "User should not exist");
await allowSendingScriptCommands();
await sendRcon(instId,
`/sc ${requireApi} api.send_json("player_event",` +
"{ type='invalid type', name='DoesNotExist' })"
);
await assert.rejects(getUser("DoesNotExist"), "User was created");
});
it("should respond to a player join and leave event", async function() {
await allowSendingScriptCommands();
await sendRcon(instId,
`/sc ${requireApi} api.send_json("player_event",` +
"{ type='join', name='JoiningPlayer' })"
);
const userJoin = await getUser("JoiningPlayer");
assert(userJoin.instances.has(instId), "Player is not shown as online");
await sendRcon(instId,
`/sc ${requireApi} api.send_json("player_event",` +
"{ type='leave', name='JoiningPlayer', reason='quit' })"
);
const userLeave = await getUser("JoiningPlayer");
assert(!userLeave.instances.has(instId), "Player is not shown as offline");
});
}
it("should respond to a player ban and unban event", async function() {
this.timeout(25000);
await execCtl(`${instSetConfig} factorio.sync_banlist bidirectional`);
await sendRcon(instId, "/ban BannedPlayer");
await waitForUserFlag("BannedPlayer", "isBanned", true, "Player is not banned");
await waitForAssertion(async () => {
const isAltBanned = await sendRcon(instAltId, "/banlist get BannedPlayer");
assert.equal(isAltBanned, "BannedPlayer is banned.\n", "User is not banned on alt");
});
await sendRcon(instId, "/unban BannedPlayer");
await waitForUserFlag("BannedPlayer", "isBanned", false, "Player is not unbanned");
await waitForAssertion(async () => {
const isAltUnbanned = await sendRcon(instAltId, "/banlist get BannedPlayer");
assert.equal(isAltUnbanned, "BannedPlayer is not banned.\n", "User is not unbanned on alt");
});
});
it("should respond to a player promote and demote event", async function() {
this.timeout(25000);
await execCtl(`${instSetConfig} factorio.sync_adminlist bidirectional`);
await sendRcon(instId, "/promote AdminPlayer");
await waitForUserFlag("AdminPlayer", "isAdmin", true, "Player is not promoted");
await waitForAltAdmin(true, "User is not promoted on alt");
const isAltPromoted = await sendRcon(instAltId, "/promote AdminPlayer");
assert.equal(isAltPromoted,
"AdminPlayer is already in the admin list and will be promoted upon joining the game.\n",
"User is not promoted on alt"
);
await sendRcon(instId, "/demote AdminPlayer");
await waitForUserFlag("AdminPlayer", "isAdmin", false, "Player is not demoted");
await waitForAltAdmin(false, "User is not demoted on alt");
const isAltDemoted = await sendRcon(instAltId, "/demote AdminPlayer");
assert.equal(isAltDemoted,
"AdminPlayer is not in the admin list.\n",
"User is not demoted on alt"
);
});
it("should respond to a player whitelist add and remove event", async function() {
this.timeout(25000);
await execCtl(`${instSetConfig} factorio.sync_whitelist bidirectional`);
await sendRcon(instId, "/whitelist add WhitelistPlayer");
await waitForUserFlag("WhitelistPlayer", "isWhitelisted", true, "Player is not whitelisted");
await waitForAssertion(async () => {
const isAltAdded = await sendRcon(instAltId, "/whitelist get WhitelistPlayer");
assert.equal(isAltAdded,
"WhitelistPlayer is whitelisted.\n",
"User is not whitelisted on alt"
);
});
await sendRcon(instId, "/whitelist remove WhitelistPlayer");
await waitForUserFlag("WhitelistPlayer", "isWhitelisted", false, "Player is not unwhitelisted");
await waitForAssertion(async () => {
const isAltRemoved = await sendRcon(instAltId, "/whitelist get WhitelistPlayer");
assert.equal(isAltRemoved,
"WhitelistPlayer is not whitelisted.\n",
"User is not unwhitelisted on alt"
);
});
});
});
});
}
});
describe("start-all and stop-all commands", function() {
requiresFactorio(this);
// Test instance IDs for start-all/stop-all tests
const testInstId1 = 50;
const testInstId2 = 51;
const testInstId3 = 52;
const testInstName1 = "StartAllTest1";
const testInstName2 = "StartAllTest2";
const testInstName3 = "StartAllTest3";
before(async function() {
this.timeout(30000);
// Create test instances for start-all/stop-all testing
await execCtl(`instance create ${testInstName1} --id ${testInstId1}`);
await execCtl(`instance create ${testInstName2} --id ${testInstId2}`);
await execCtl(`instance create ${testInstName3} --id ${testInstId3}`);
// Configure instances
await execCtl(`instance assign ${testInstName1} ${hostId}`);
await execCtl(`instance assign ${testInstName2} ${hostId}`);
await execCtl(`instance assign ${testInstName3} ${hostId}`);
const visibility = JSON.stringify({ lan: false, public: false }).replace(
/"/g, process.platform === "win32" ? '""' : '\\"'
);
for (const name of [testInstName1, testInstName2, testInstName3]) {
await execCtl(`instance config set ${name} factorio.rcon_port ${await getTestRconPort()}`);
await execCtlProcess(`instance config set-prop ${name} factorio.settings visibility "${visibility}"`);
await execCtl(`instance config set-prop ${name} factorio.settings require_user_verification false`);
}
// Exclude testInstName3 from start-all by default
await execCtl(`instance config set ${testInstName3} instance.exclude_from_start_all true`);
// Disable plugins to speed up testing
for (const instanceName of [testInstName1, testInstName2, testInstName3]) {
for (const plugin of [
"global_chat", "research_sync", "statistics_exporter",
"subspace_storage", "player_auth", "player_sync",
]) {
await execCtl(`instance config set ${instanceName} ${plugin}.load_plugin false`);
}
}
});
after(async function() {
this.timeout(30000);
// Clean up test instances - ensure they are stopped first
await execCtl(`instance stop ${testInstName1}`).catch(() => {});
await execCtl(`instance stop ${testInstName2}`).catch(() => {});
await execCtl(`instance stop ${testInstName3}`).catch(() => {});
await execCtl(`instance delete ${testInstName1}`).catch(() => {});
await execCtl(`instance delete ${testInstName2}`).catch(() => {});
await execCtl(`instance delete ${testInstName3}`).catch(() => {});
});
async function getInstanceStatus(id) {
const instances = await getControl().send(new lib.InstanceDetailsListRequest());
const instanceMap = new Map(instances.map(instance => [instance.id, instance]));
return instanceMap.get(id)?.status;
}
describe("start-all command", function() {
beforeEach(async function() {
this.timeout(15000);
// Ensure all test instances are stopped before each test
await execCtl(`instance stop ${testInstName1}`).catch(() => {});
await execCtl(`instance stop ${testInstName2}`).catch(() => {});
await execCtl(`instance stop ${testInstName3}`).catch(() => {});
});
afterEach(async function() {
this.timeout(15000);
// Clean up: stop any instances that were started during tests
await execCtl(`instance stop ${testInstName1}`).catch(() => {});
await execCtl(`instance stop ${testInstName2}`).catch(() => {});
await execCtl(`instance stop ${testInstName3}`).catch(() => {});
});
it("starts only stopped instances that are not excluded by default", async function() {
this.timeout(20000);
// Start one instance to verify it's not started again
await execCtl(`instance start ${testInstName2}`);
// Verify initial state
assert.equal(await getInstanceStatus(testInstId1), "stopped");
assert.equal(await getInstanceStatus(testInstId2), "running");
assert.equal(await getInstanceStatus(testInstId3), "stopped");
// Run start-all without force
await execCtl("instance start-all");
// Verify results - only testInstName1 should be started
// testInstName2 was already running, testInstName3 is excluded
assert.equal(await getInstanceStatus(testInstId1), "running");
assert.equal(await getInstanceStatus(testInstId2), "running");
assert.equal(await getInstanceStatus(testInstId3), "stopped");
});
it("starts excluded instances when --force flag is provided", async function() {
this.timeout(20000);
// Verify initial state - all stopped
assert.equal(await getInstanceStatus(testInstId1), "stopped");
assert.equal(await getInstanceStatus(testInstId2), "stopped");
assert.equal(await getInstanceStatus(testInstId3), "stopped");
// Run start-all with force
await execCtl("instance start-all --force");
// Verify results - all instances should be started including excluded one
assert.equal(await getInstanceStatus(testInstId1), "running");
assert.equal(await getInstanceStatus(testInstId2), "running");
assert.equal(await getInstanceStatus(testInstId3), "running");
});
});
describe("stop-all command", function() {
beforeEach(async function() {
this.timeout(15000);
// Ensure a known state before each test
await execCtl(`instance stop ${testInstName1}`).catch(() => {});
await execCtl(`instance stop ${testInstName2}`).catch(() => {});
await execCtl(`instance stop ${testInstName3}`).catch(() => {});
});
afterEach(async function() {
this.timeout(15000);
// Clean up: stop any instances that were started during tests
await execCtl(`instance stop ${testInstName1}`).catch(() => {});
await execCtl(`instance stop ${testInstName2}`).catch(() => {});
await execCtl(`instance stop ${testInstName3}`).catch(() => {});
});
it("stops only running or starting instances", async function() {
this.timeout(20000);
// Start some instances to test stop-all
await execCtl(`instance start ${testInstName1}`);
await execCtl(`instance start ${testInstName3}`);
// Verify initial state
assert.equal(await getInstanceStatus(testInstId1), "running");
assert.equal(await getInstanceStatus(testInstId2), "stopped");
assert.equal(await getInstanceStatus(testInstId3), "running");
await execCtl("instance stop-all");
// Verify results - running instances should be stopped, stopped ones unchanged
assert.equal(await getInstanceStatus(testInstId1), "stopped");
assert.equal(await getInstanceStatus(testInstId2), "stopped");
assert.equal(await getInstanceStatus(testInstId3), "stopped");
});
});
describe("stop while starting", function() {
it("stops the instance once it has started", async function() {
this.timeout(30000);
const control = getControl();
const started = control.sendTo({ instanceId: testInstId1 }, new lib.InstanceStartRequest());
// Unroutable until the host has connected the instance
for (;;) {
try {
await control.sendTo({ instanceId: testInstId1 }, new lib.InstanceStopRequest());
break;
} catch (err) {
if (!(err instanceof lib.UnavailableError)) {
throw err;
}
await lib.wait(10);
}
}
await started;
assert.equal(await getInstanceStatus(testInstId1), "stopped");
});
});
});