Files
factorio-clusterio/test/integration/index.js
T
Hornwitser 2c52c265cf Require config files to be explicitly created (#1040)
The behaviour of creating the config file if it's missing while
convenient causes confusion and problems when the tools are run
in the wrong folder.  They end up creating a shadow installation
and misdirect the user with nonsensical error messages, instead
pointing to the real problem which is that the tool can't find
the configuration needed to actually run.

Require a config file to be present at startup and refuse to run if this
file is missing.  To support the odd use-case of creating the config
when missing a --create-config flag is added. To conveniently generate
a config file to edit out from the "config create" command is added.
2026-09-15 22:16:54 +02:00

443 lines
14 KiB
JavaScript

/* eslint-disable no-console */
import path from "node:path";
import fs from "node:fs/promises";
import { readFileSync } from "node:fs";
import child_process from "node:child_process";
import jwt from "jsonwebtoken";
import util from "node:util";
import events from "node:events";
import * as lib from "@clusterio/lib";
import { LineSplitter, ConsoleTransport, logger } from "@clusterio/lib";
import { selectTargetCommand, initialize as initializeCtl } from "@clusterio/ctl";
import { _listFactorioVersions } from "@clusterio/host/dist/node/src/server.js";
// Make sure permissions from plugins are loaded
import "../../plugins/global_chat/dist/node/index.js";
import "../../plugins/player_auth/dist/node/index.js";
import "../../plugins/research_sync/dist/node/index.js";
import "../../plugins/statistics_exporter/dist/node/index.js";
import "../../plugins/subspace_storage/dist/node/index.js";
export class TestControl extends lib.Link {
constructor(connector, subscribe = true) {
super(connector);
this.hostUpdates = [];
this.instanceUpdates = [];
this.saveUpdates = [];
this.modUpdates = [];
this.modPackUpdates = [];
this.userUpdates = [];
this.connector.on("connect", () => {
if (!subscribe) {
return;
}
this.send(
new lib.SubscriptionRequest(lib.HostUpdatesEvent.name, true)
).catch(err => logger.error(`Error setting host subscriptions:\n${err.stack}`));
this.send(
new lib.SubscriptionRequest(lib.InstanceDetailsUpdatesEvent.name, true)
).catch(err => logger.error(`Error setting instance subscriptions:\n${err.stack}`));
this.send(
new lib.SubscriptionRequest(lib.InstanceSaveDetailsUpdatesEvent.name, true)
).catch(err => logger.error(`Error setting save subscriptions:\n${err.stack}`));
this.send(
new lib.SubscriptionRequest(lib.ModUpdatesEvent.name, true)
).catch(err => logger.error(`Error setting mod subscriptions:\n${err.stack}`));
this.send(
new lib.SubscriptionRequest(lib.ModPackUpdatesEvent.name, true)
).catch(err => logger.error(`Error setting mod pack subscriptions:\n${err.stack}`));
this.send(
new lib.SubscriptionRequest(lib.UserUpdatesEvent.name, true)
).catch(err => logger.error(`Error setting user subscriptions:\n${err.stack}`));
});
this.handle(lib.AccountUpdateEvent);
this.handle(lib.HostUpdatesEvent, this.handleHostUpdatesEvent.bind(this));
this.handle(lib.InstanceDetailsUpdatesEvent, this.handleInstanceDetailsUpdatesEvent.bind(this));
this.handle(lib.InstanceSaveDetailsUpdatesEvent, this.handleInstanceSaveDetailsUpdatesEvent.bind(this));
this.handle(lib.ModUpdatesEvent, this.handleModUpdatesEvent.bind(this));
this.handle(lib.ModPackUpdatesEvent, this.handleModPackUpdatesEvent.bind(this));
this.handle(lib.UserUpdatesEvent, this.handleUserUpdatesEvent.bind(this));
}
async handleHostUpdatesEvent(event) {
this.hostUpdates.push(...event.updates);
}
async handleInstanceDetailsUpdatesEvent(event) {
this.instanceUpdates.push(...event.updates);
}
async handleInstanceSaveDetailsUpdatesEvent(event) {
this.saveUpdates.push(event);
}
async handleModUpdatesEvent(event) {
this.modUpdates.push(...event.updates);
}
async handleModPackUpdatesEvent(event) {
this.modPackUpdates.push(...event.updates);
}
async handleUserUpdatesEvent(event) {
this.userUpdates.push(...event.updates);
}
async setLogSubscriptions(args) {
// Do nothing
}
}
export class TestControlConnector extends lib.WebSocketClientConnector {
register() {
this.sendHandshake(
new lib.MessageRegisterControl(
new lib.RegisterControlData(
this.token, "test",
)
)
);
}
}
export class TestHostConnector extends lib.WebSocketClientConnector {
register() {
this.sendHandshake(
new lib.MessageRegisterHost(
new lib.RegisterHostData(
this.token, "test",
this.hostId, {},
)
)
);
}
}
// Mark that this test takes a lot of time, or depends on a test that takes a lot of time.
export function slowTest(test) {
if (process.env.FAST_TEST) {
test.skip();
}
test.timeout(30000);
}
// Mark that this test depends on an external API and may be slow or flaky.
export function externalTest(test) {
if (process.env.NO_EXTERNAL_TEST) {
test.skip();
}
test.timeout(60000);
}
// Mark that this test or suite of tests requires a factorio install to run.
export function requiresFactorio(testOrSuite) {
if (testOrSuite.skip) {
if (!haveFactorioInstall) {
testOrSuite.skip();
}
} else {
before(function() {
if (!haveFactorioInstall) {
this.skip();
}
});
}
}
export function hasFactorio() {
return haveFactorioInstall;
}
export async function get(urlPath) {
const url = new URL("https://localhost:4443");
url.pathname = urlPath;
let res = await fetch(url);
if (res.status !== 200) {
const body = Buffer.from(await res.arrayBuffer());
throw new Error(`Got response code ${res.status}, content: ${body.toString()}`);
}
return res;
}
function loadJSON(filePath) {
try {
// eslint-disable-next-line node/no-sync
return JSON.parse(readFileSync(filePath, "utf8"));
} catch (err) {
if (err.code === "ENOENT") {
return undefined;
}
throw err;
}
}
function getFactorioDir(hostConfig) {
const factorioDir = hostConfig?.["host.factorio_directory"] ?? "factorio";
return path.join(factorioDir);
}
let controllerProcess;
let hostProcess;
let control;
let haveFactorioInstall;
const baseHostConfig = loadJSON("config-host.json");
export let url = "https://localhost:4443/";
// Signed in the root before hook, as the user has to exist before the token is issued
export let controlToken;
export let instancesDir = path.join("temp", "test", "instances");
export let modsDir = path.join("temp", "test", "mods");
export let databaseDir = path.join("temp", "test", "database");
export let factorioDir = getFactorioDir(baseHostConfig);
let pluginListPath = path.join("temp", "test", "plugin-list.json");
export let controllerConfigPath = path.join("temp", "test", "config-controller.json");
export let hostConfigPath = path.join("temp", "test", "config-host.json");
export let ctlConfigPath = path.join("temp", "test", "config-ctl.json");
function childOptions(options) {
return {
cwd: path.join("temp", "test"),
env: { ...process.env },
...options,
};
}
export function exec(command, options = {}) {
// Uncomment to show commands run in tests
// console.log(command);
return util.promisify(child_process.exec)(command, childOptions(options));
}
export function execController(...args) {
args[0] = `node --enable-source-maps ../../packages/controller ${args[0]}`;
return exec(...args);
}
export function execHost(...args) {
args[0] = `node --enable-source-maps ../../packages/host ${args[0]}`;
return exec(...args);
}
export function execCtlProcess(...args) {
args[0] = `node --enable-source-maps ../../packages/ctl ${args[0]}`;
return exec(...args);
}
let inExecCtl = false;
export async function execCtl(command) {
inExecCtl = true;
process.chdir("temp/test");
try {
const initArgs = await initializeCtl(command, control.hooks, true);
if (!initArgs.shouldRun) {
return;
}
control.config = initArgs.ctlConfig;
const targetCommand = selectTargetCommand(initArgs.args, initArgs.rootCommands);
await targetCommand.run(initArgs.args, control);
} finally {
process.chdir("../..");
inExecCtl = false;
}
}
afterEach(function() {
if (inExecCtl) {
// This is needed in case a test times out and never runs the finally block
process.chdir("../..");
}
});
export async function sendRcon(instanceId, command) {
return await control.sendTo({ instanceId }, new lib.InstanceSendRconRequest(command));
}
export function getControl() {
return control;
}
/** @returns {Promise<child_process.ChildProcess>} */
export function spawn(name, cmd, waitFor, options = {}) {
const silent = process.env.SILENT_TEST;
const bootstrap = !controllerProcess || !hostProcess;
function log(...args) {
if (!silent || bootstrap) {
console.log(...args);
}
}
return new Promise((resolve, reject) => {
log(cmd);
let parts = cmd.split(" ");
let process = child_process.spawn(parts[0], parts.slice(1), childOptions(options));
let stdout = new LineSplitter({ readableObjectMode: true });
let stderr = new LineSplitter({ readableObjectMode: true });
let onDataOut = line => {
line = line.toString("utf8");
if (waitFor.test(line)) {
if (silent) {
stdout.off("data", onDataOut);
stderr.off("data", onDataErr);
}
resolve(process);
}
log(name, line);
};
let onDataErr = line => {
log(name, line.toString("utf8"));
};
stdout.on("data", onDataOut);
stderr.on("data", onDataErr);
process.stdout.pipe(stdout);
process.stderr.pipe(stderr);
});
}
export function spawnNode(name, cmd, waitFor, options) {
return spawn(name, `node --enable-source-maps ${cmd}`, waitFor, options);
}
before(async function() {
this.timeout(40000);
const silent = process.env.SILENT_TEST;
if (silent) {
console.log("SILENT_TEST is present in env, loggers after bootstrap will be muted.");
}
// Some integration tests may cause log events
logger.add(new ConsoleTransport({
level: "info",
format: new lib.TerminalFormat(),
filter: () => !silent,
}));
// If fast test is enabled then output that it is
if (process.env.FAST_TEST) {
console.log("FAST_TEST is present in env, slow tests will be skipped.");
}
if (process.env.NO_EXTERNAL_TEST) {
console.log("NO_EXTERNAL_TEST is present in env, tests depending on external APIs will be skipped.");
}
await fs.rm(instancesDir, { force: true, recursive: true, maxRetries: 10 });
await fs.rm(modsDir, { force: true, recursive: true, maxRetries: 10 });
await fs.rm(databaseDir, { force: true, recursive: true, maxRetries: 10 });
await fs.rm(pluginListPath, { force: true });
await fs.rm(controllerConfigPath, { force: true });
await fs.rm(hostConfigPath, { force: true });
await fs.rm(ctlConfigPath, { force: true });
await fs.mkdir(path.join("temp", "test"), { recursive: true });
console.log("Building Mods");
await fs.mkdir(modsDir, { recursive: true });
await lib.build({
build: true,
pack: true,
sourceDir: "packages/host/lua/clusterio_lib",
outputDir: modsDir,
});
console.log("Setting Controller Config");
await execController("config create");
await execController("config set controller.auth_secret TestSecretDoNotUse");
await execController("config set controller.http_port 8880");
await execController("config set controller.https_port 4443");
await execController("config set controller.heartbeat_interval 0.25");
await execController("config set controller.session_timeout 2");
await execController("config set controller.tls_certificate ../../test/file/tls/cert.pem");
await execController("config set controller.tls_private_key ../../test/file/tls/key.pem");
console.log("Setting Controller Plugins");
await execCtlProcess("plugin add ../../test/file/test_plugin");
await execCtlProcess("plugin add ../../plugins/global_chat");
await execCtlProcess("plugin add ../../plugins/research_sync");
await execCtlProcess("plugin add ../../plugins/statistics_exporter");
await execCtlProcess("plugin add ../../plugins/subspace_storage");
await execCtlProcess("plugin add ../../plugins/inventory_sync");
await execCtlProcess("plugin add ../../plugins/player_auth");
console.log("Bootstrapping");
await execController("bootstrap create-admin test");
await execController("bootstrap create-ctl-config test");
controlToken = jwt.sign({ aud: "user", user: "test" }, Buffer.from("TestSecretDoNotUse", "base64"));
controllerProcess = await spawnNode("controller:", "../../packages/controller run", /Started controller/);
const factorioVersions = (await _listFactorioVersions(factorioDir)).versions;
const latestFactorioVersion = [...factorioVersions.values()]
.sort((a, b) => lib.integerFullVersion(b) - lib.integerFullVersion(a))[0];
haveFactorioInstall = factorioVersions.size > 0;
const relativeFactorioDir = path.isAbsolute(factorioDir) ? factorioDir : path.join("..", "..", factorioDir);
await execCtlProcess("host create-config --id 4 --name host --generate-token");
await execHost("config create");
await execHost(`config set host.factorio_directory ${relativeFactorioDir}`);
hostProcess = await spawnNode("host:", "../../packages/host run", /Started host/);
const controlConnector = new TestControlConnector(url, 2);
controlConnector.token = controlToken;
control = new TestControl(controlConnector);
await controlConnector.connect();
const initArgs = await initializeCtl(`--plugin-list ${pluginListPath} config create`, undefined, true);
control.config = initArgs.ctlConfig;
control.hooks = initArgs.ctlHooks;
const testPack = lib.ModPack.fromJSON({});
testPack.id = 12;
testPack.name = "subspace_storage-pack";
testPack.factorioVersion = latestFactorioVersion;
const [major, minor] = lib.normaliseMajorMinorVersion(latestFactorioVersion).split(".");
testPack.mods.set("clusterio_lib", { name: "clusterio_lib", enabled: true, version: `2.0.${major}${minor}` });
await control.sendTo("controller", new lib.ModPackCreateRequest(testPack));
await control.sendTo(
"controller",
new lib.ControllerConfigSetFieldRequest("controller.default_mod_pack_id", "12"),
);
});
after(async function() {
this.timeout(20000);
if (hostProcess) {
console.log("Shutting down host");
hostProcess.kill("SIGINT");
await events.once(hostProcess, "exit");
}
if (controllerProcess) {
console.log("Shutting down controller");
controllerProcess.kill("SIGINT");
await events.once(controllerProcess, "exit");
}
if (control) {
await control.connector.close();
}
if (haveFactorioInstall === false) {
console.warn("Many tests may have been skipped due to no factorio install being detected");
}
});
// Ensure the test processes are stopped.
process.on("exit", () => {
if (hostProcess) { hostProcess.kill(); }
if (controllerProcess) { controllerProcess.kill(); }
});