/* 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, logger } from "@clusterio/lib"; import { selectTargetCommand, initialize as initializeCtl } from "@clusterio/ctl"; import { _listFactorioVersions } from "@clusterio/host/dist/node/src/server.js"; import { pairHost } from "@clusterio/host"; import "../setup_logging.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.hostPairingUpdates = []; 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.HostPairingUpdatesEvent.name, true) ).catch(err => logger.error(`Error setting host pairing 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.HostPairingUpdatesEvent, this.handleHostPairingUpdatesEvent.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 handleHostPairingUpdatesEvent(event) { this.hostPairingUpdates.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 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 function getFactorioVersion() { return latestFactorioVersion; } 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; let latestFactorioVersion; 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; // Id of the test host, assigned by the controller when it is paired in the root before hook export let hostId; 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/dist/node/controller.js ${args[0]}`; return exec(...args); } export function execHost(...args) { args[0] = `node --enable-source-maps ../../packages/host/dist/node/host.js ${args[0]}`; return exec(...args); } export function execCtlProcess(...args) { args[0] = `node --enable-source-maps ../../packages/ctl/dist/node/ctl.js ${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; } /** * Pair a new host with the test controller, approved right away with the admin token * * @param {string} name - Name to give the host. * @returns {Promise<{ hostId: number, token: string, name: string }>} the paired host. */ export async function pairTestHost(name) { return await pairHost({ controllerUrl: url, name, token: controlToken }); } /** * Write a new host config for a paired host, with the fields the installer and pairing set * * @param {string} configPath - Path to write the config to. * @param {{ hostId: number, token: string, name: string }} pairing - Host returned by pairing. * @param {Record} fields - Other fields to set in the config. */ export async function writeHostConfig(configPath, pairing, fields = {}) { const hostConfig = new lib.HostConfig("host"); hostConfig.set("host.id", pairing.hostId); hostConfig.set("host.name", pairing.name); hostConfig.set("host.controller_url", url); hostConfig.set("host.controller_token", pairing.token); for (const [name, value] of Object.entries(fields)) { hostConfig.set(name, value); } await fs.writeFile(configPath, JSON.stringify(hostConfig, null, "\t"), { mode: 0o600 }); } /** @returns {Promise} */ 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 onData = line => { line = line.toString("utf8"); if (waitFor.test(line)) { if (silent) { stdout.off("data", onData); stderr.off("data", onData); } resolve(process); } log(name, line); }; stdout.on("data", onData); stderr.on("data", onData); 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."); } // 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/player_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; 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); const pairing = await pairTestHost("host"); hostId = pairing.hostId; await writeHostConfig(hostConfigPath, pairing); 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.1.${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(); } });