/* eslint-disable no-console */ "use strict"; const path = require("path"); const fs = require("node:fs/promises"); const { readFileSync } = require("node:fs"); const child_process = require("child_process"); const jwt = require("jsonwebtoken"); const util = require("util"); const events = require("events"); const lib = require("@clusterio/lib"); const { LineSplitter, ConsoleTransport, logger } = lib; const { selectTargetCommand, initialize: initializeCtl } = require("@clusterio/ctl"); const { _listFactorioVersions } = require("@clusterio/host/dist/node/src/server"); // Make sure permissions from plugins are loaded require("../../plugins/global_chat/dist/node/index"); require("../../plugins/player_auth/dist/node/index"); require("../../plugins/research_sync/dist/node/index"); require("../../plugins/statistics_exporter/dist/node/index"); require("../../plugins/subspace_storage/dist/node/index"); 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 } } class TestControlConnector extends lib.WebSocketClientConnector { register() { this.sendHandshake( new lib.MessageRegisterControl( new lib.RegisterControlData( this.token, "test", ) ) ); } } 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. function slowTest(test) { if (process.env.FAST_TEST) { test.skip(); } test.timeout(30000); } // Mark that this test or suite of tests requires a factorio install to run. function requiresFactorio(testOrSuite) { if (testOrSuite.skip) { if (!haveFactorioInstall) { testOrSuite.skip(); } } else { before(function() { if (!haveFactorioInstall) { this.skip(); } }); } } function hasFactorio() { return haveFactorioInstall; } 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"); let url = "https://localhost:4443/"; let controlToken = jwt.sign({ aud: "user", user: "test" }, Buffer.from("TestSecretDoNotUse", "base64")); let instancesDir = path.join("temp", "test", "instances"); let modsDir = path.join("temp", "test", "mods"); let databaseDir = path.join("temp", "test", "database"); let factorioDir = getFactorioDir(baseHostConfig); let pluginListPath = path.join("temp", "test", "plugin-list.json"); let controllerConfigPath = path.join("temp", "test", "config-controller.json"); let hostConfigPath = path.join("temp", "test", "config-host.json"); let controlConfigPath = path.join("temp", "test", "config-control.json"); function childOptions(options) { return { cwd: path.join("temp", "test"), env: { ...process.env }, ...options, }; } function exec(command, options = {}) { // Uncomment to show commands run in tests // console.log(command); return util.promisify(child_process.exec)(command, childOptions(options)); } function execController(...args) { args[0] = `node --enable-source-maps ../../packages/controller ${args[0]}`; return exec(...args); } function execHost(...args) { args[0] = `node --enable-source-maps ../../packages/host ${args[0]}`; return exec(...args); } function execCtlProcess(...args) { args[0] = `node --enable-source-maps ../../packages/ctl ${args[0]}`; return exec(...args); } let inExecCtl = false; async function execCtl(command) { inExecCtl = true; process.chdir("temp/test"); try { const initArgs = await initializeCtl(command, control.plugins, true); if (!initArgs.shouldRun) { return; } control.config = initArgs.controlConfig; 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("../.."); } }); async function sendRcon(instanceId, command) { return await control.sendTo({ instanceId }, new lib.InstanceSendRconRequest(command)); } function getControl() { return control; } /** @returns {Promise} */ 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); }); } 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."); } 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(controlConfigPath, { 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 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 ../../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"); controllerProcess = await spawnNode("controller:", "../../packages/controller run", /Started controller/); haveFactorioInstall = (await _listFactorioVersions(factorioDir)).versions.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 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} control-config list`, undefined, true); control.config = initArgs.controlConfig; control.plugins = initArgs.ctlPlugins; const testPack = lib.ModPack.fromJSON({}); testPack.id = 12; testPack.name = "subspace_storage-pack"; testPack.factorioVersion = "2.0.0"; testPack.mods.set("clusterio_lib", { name: "clusterio_lib", enabled: true, version: "2.0.20" }); 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(); } }); module.exports = { TestControl, TestControlConnector, TestHostConnector, execController, execCtlProcess, slowTest, requiresFactorio, get, exec, execCtl, execHost, sendRcon, getControl, spawn, spawnNode, url, controlToken, instancesDir, modsDir, databaseDir, factorioDir, hasFactorio, controllerConfigPath, hostConfigPath, controlConfigPath, };