import assert from "node:assert/strict"; import dgram from "node:dgram"; import events from "node:events"; import fs from "node:fs/promises"; import path from "node:path"; import * as hostServer from "@clusterio/host/dist/node/src/server.js"; import { Link, wait } from "@clusterio/lib"; import { testLines } from "../lib/factorio/lines.js"; import { slowTest, externalTest } from "../common.js"; import "../setup_logging.js"; describe("host/server", function() { describe("_getFactorioVersion()", function() { it("should get the version from a changelog", async function() { let version = await hostServer._getFactorioVersion(path.join("test", "file", "changelogs", "good")); assert.equal(version, "0.1.1"); }); it("should return null if unable to find the version", async function() { let version = await hostServer._getFactorioVersion(path.join("test", "file", "changelogs", "bad")); assert.equal(version, null); }); it("should return null if file does not exist", async function() { let version = await hostServer._getFactorioVersion(path.join("test", "file", "changelogs", "not-exists")); assert.equal(version, null); }); }); describe("_versionOrder()", function() { it("should sort an array of versions", function() { let versions = ["1.2.3", "0.1.4", "0.1.2", "1.2.3", "0.1.5", "1.10.2"]; versions.sort(hostServer._versionOrder); assert.deepEqual( versions, ["1.10.2", "1.2.3", "1.2.3", "0.1.5", "0.1.4", "0.1.2"] ); }); }); describe("_findVersion()", function() { describe("direct install", function() { it("should search given directory for latest Factorio install", async function() { const installDir = path.join("test", "file", "factorio", "0.1.2"); const [dir, version] = await hostServer.findVersion(installDir, "latest"); assert.equal(dir, path.join(installDir, "data")); assert.equal(version, "0.1.2"); }); it("should search given directory for given Factorio install", async function() { const installDir = path.join("test", "file", "factorio", "0.1.1"); const [dir, version] = await hostServer.findVersion(installDir, "0.1.1"); assert.equal(dir, path.join(installDir, "data")); assert.equal(version, "0.1.1"); }); it("should search given directory for partly given Factorio install", async function() { const installDir = path.join("test", "file", "factorio", "0.1.2"); const [dir, version] = await hostServer.findVersion(installDir, "0.1"); assert.equal(dir, path.join(installDir, "data")); assert.equal(version, "0.1.2"); }); it("should reject if the version does not match", async function() { let installDir = path.join("test", "file", "factorio", "0.1.1"); await assert.rejects( hostServer.findVersion(installDir, "0.1.2"), new Error( `Unable to find Factorio version 0.1.2: ${installDir} is a direct (single-version) ` + `install of 0.1.1. Use a versioned layout, where ${installDir} contains a subdirectory ` + "per version, to run other versions or download them automatically." ) ); }); }); describe("mutli install", function() { it("should reject if no factorio install with the given version was found", async function() { let installDir = path.join("test", "file", "factorio"); await assert.rejects( hostServer.findVersion(installDir, "0.1.3"), new Error("Unable to find Factorio version 0.1.3") ); }); it("should search given directory for given Factorio install", async function() { const installDir = path.join("test", "file", "factorio"); const [dir, version] = await hostServer.findVersion(installDir, "0.1.1"); assert.equal(dir, path.join(installDir, "0.1.1", "data")); assert.equal(version, "0.1.1"); }); it("should search given directory for partly given Factorio install", async function() { const installDir = path.join("test", "file", "factorio"); const [dir, version] = await hostServer.findVersion(installDir, "0.1"); assert.equal(dir, path.join(installDir, "0.1.2", "data")); assert.equal(version, "0.1.2"); }); it("should reject if no factorio install with the given version was found", async function() { let installDir = path.join("test", "file", "factorio"); await assert.rejects( hostServer.findVersion(installDir, "0.1.3"), new Error("Unable to find Factorio version 0.1.3") ); }); it("should reject if no factorio install was found", async function() { let installDir = path.join("test", "file"); await assert.rejects( hostServer.findVersion(installDir, "0.0.0"), new Error(`Unable to find any Factorio install in ${installDir}`) ); }); }); describe("headless argument", function() { const mixedDir = path.join("test", "file", "factorio-mixed"); it("should find only full installs when false", async function() { const [dir] = await hostServer.findVersion(mixedDir, "0.1.2", false); assert.equal(dir, path.join(mixedDir, "full", "data")); }); it("should find only headless installs when true", async function() { const [dir] = await hostServer.findVersion(mixedDir, "0.1.2", true); assert.equal(dir, path.join(mixedDir, "headless", "data")); }); it("should skip installs of the other build when matching a partial version", async function() { const installDir = path.join("test", "file", "factorio"); const [dir, version] = await hostServer.findVersion(installDir, "0.1", false); assert.deepEqual([dir, version], [path.join(installDir, "0.1.2", "data"), "0.1.2"]); }); it("should reject a direct install of the other build", async function() { const installDir = path.join(mixedDir, "headless"); await assert.rejects( hostServer.findVersion(installDir, "0.1.2", false), new Error( `Unable to find a full install of Factorio 0.1.2: ${installDir} is a direct ` + "(single-version) install of the headless build." ) ); }); it("should reject if no install of that build has the version", async function() { await assert.rejects( hostServer.findVersion(path.join("test", "file", "factorio"), "0.1.2", true), new Error("Unable to find headless Factorio version 0.1.2") ); }); }); }); describe("_listFactorioVersions()", function() { it("should list the version in a direct install", async function() { const installDir = path.join("test", "file", "factorio", "0.1.1"); const installedVersions = await hostServer._listFactorioVersions(installDir); assert.deepEqual(installedVersions, { direct: true, versions: new Set(["0.1.1"]), }); }); it("should list all versions in a directory", async function() { const installDir = path.join("test", "file", "factorio"); const installedVersions = await hostServer._listFactorioVersions(installDir); assert.deepEqual(installedVersions, { direct: false, versions: new Set(["0.1.1", "0.1.2"]), }); }); }); describe("downloadAndExtractZip", function() { let _fetch; beforeEach(function() { _fetch = global.fetch; }); afterEach(function() { global.fetch = _fetch; }); it("works", async function() { slowTest(this); externalTest(this); const url = "https://github.com/clusterio/clusterio/archive/refs/tags/v2.0.0-alpha.22.zip"; const downloads = path.join("temp", "test", "downloads"); await fs.rm(downloads, { force: true, recursive: true, maxRetries: 10 }); await fs.mkdir(downloads, { recursive: true }); await hostServer._downloadAndExtractZip(url, path.join(downloads, "zip")); await fs.access(path.join(downloads, "zip", "packages", "controller", "package.json")); }); it("errors and bad status", async function() { global.fetch = () => ({ ok: false, status: -1, statusText: "Fetch called" }); await assert.rejects(hostServer._downloadAndExtractZip("url does not matter"), /-1 Fetch called/); }); }); describe("downloadAndExtractTar", function() { let _fetch; beforeEach(function() { _fetch = global.fetch; }); afterEach(function() { global.fetch = _fetch; }); it("works", async function() { slowTest(this); externalTest(this); const url = "https://github.com/clusterio/clusterio/archive/refs/tags/v2.0.0-alpha.22.tar.gz"; const downloads = path.join("temp", "test", "downloads"); await fs.rm(downloads, { force: true, recursive: true, maxRetries: 10 }); await fs.mkdir(downloads, { recursive: true }); await hostServer._downloadAndExtractTar(url, path.join(downloads, "tar")); await fs.access(path.join(downloads, "tar", "packages", "controller", "package.json")); }); it("errors and bad status", async function() { global.fetch = () => ({ ok: false, status: -1, statusText: "Fetch called" }); await assert.rejects(hostServer._downloadAndExtractTar("url does not matter"), /-1 Fetch called/); }); }); describe("randomDynamicPort()", function() { it("should return a port number", function() { let port = hostServer._randomDynamicPort(); assert.equal(typeof port, "number"); assert(Number.isInteger(port)); assert(port >= 0 && port < 2**16); }); it("should return a port number in the dynamic range", function() { function validate(port) { return (49152 <= port && port <= 65535); } for (let i=0; i < 20; i++) { assert(validate(hostServer._randomDynamicPort())); } }); }); describe("generatePassword()", function() { it("should return a string", async function() { let password = await hostServer._generatePassword(1); assert.equal(typeof password, "string"); }); it("should return a string of the given length", async function() { let password = await hostServer._generatePassword(10); assert.equal(password.length, 10); }); it("should contain only a-z, A-Z, 0-9", async function() { let password = await hostServer._generatePassword(10); assert(/^[a-zA-Z0-9]+$/.test(password), `${password} failed test`); }); }); describe("parseOutput()", function() { it("should parse the test lines", function() { for (let [line, reference] of testLines) { reference.source = "test"; let output = hostServer._parseOutput(line, "test"); assert.deepEqual(output, reference); } }); }); describe("class FactorioServer", function() { let writePath = path.join("temp", "test", "server"); let server; before(async function() { server = await hostServer.FactorioServer.fromOptions(path.join("test", "file", "factorio"), writePath, {}); }); describe("constructor()", function() { it("should handle dashes in write path with strapPaths enabled", function() { // eslint-disable-next-line no-new new hostServer.FactorioServer( path.join("test", "file", "factorio"), path.join("temp", "test", "server-1"), path.join("test", "file", "factorio", "0.1.2", "data"), "0.1.2", { stripPaths: true, rconPassword: "" } ); }); }); describe(".fromOptions()", function() { it("should resolve the requested version", async function() { const found = await hostServer.FactorioServer.fromOptions( path.join("test", "file", "factorio"), writePath, { version: "0.1.1" } ); assert.equal(found.version, "0.1.1"); assert.equal(found.dataPath(), path.join("test", "file", "factorio", "0.1.1", "data")); assert.equal(found._state, "init"); }); it("should generate an RCON password if none is given", async function() { assert.equal(typeof server.rconPassword, "string"); assert.equal(server.rconPassword.length, 10); const given = await hostServer.FactorioServer.fromOptions( path.join("test", "file", "factorio"), writePath, { rconPassword: "secret" } ); assert.equal(given.rconPassword, "secret"); }); it("should reject if the version is not installed", async function() { await assert.rejects( hostServer.FactorioServer.fromOptions( path.join("test", "file", "factorio"), writePath, { version: "0.1.5" } ), /Unable to find Factorio version 0.1.5/ ); }); }); describe(".version", function() { it("should return the version detected", function() { assert.equal(server.version, "0.1.2"); }); }); describe("._handleIpc()", function() { it("should emit the correct ipc event", async function() { let waiter = events.once(server, "ipc-channel"); await server._handleIpc(Buffer.from('\f$ipc:channel?j"value"')); let result = await waiter; assert.equal(result[0], "value"); }); it("should handle special characters in channel name", async function() { let waiter = events.once(server, "ipc-$ ?\x00\x0a:"); await server._handleIpc(Buffer.from('\f$ipc:$ \\x3f\\x00\\x0a:?j"value"')); let result = await waiter; assert.equal(result[0], "value"); }); it("should throw on malformed ipc line", async function() { await assert.rejects( server._handleIpc(Buffer.from("\f$ipc:blah")), new Error('Malformed IPC line "\f$ipc:blah"') ); }); it("should throw on unknown type", async function() { await assert.rejects( server._handleIpc(Buffer.from("\f$ipc:channel??")), new Error("Unknown IPC type '?'") ); }); it("should throw on unknown file type", async function() { await assert.rejects( server._handleIpc(Buffer.from("\f$ipc:channel?ffoo.invalid")), new Error("Unknown IPC file format 'invalid'") ); }); it("should throw on file name with slash", async function() { await assert.rejects( server._handleIpc(Buffer.from("\f$ipc:channel?fa/b")), new Error("Invalid IPC file name 'a/b'") ); }); it("should load and delete json file", async function() { await fs.mkdir(server.writePath("script-output"), { recursive: true }); let filePath = server.writePath("script-output", "data.json"); await fs.writeFile(filePath, '{"data":"spam"}'); let waiter = events.once(server, "ipc-channel"); await server._handleIpc(Buffer.from("\f$ipc:channel?fdata.json")); let result = await waiter; assert.deepEqual(result[0], { "data": "spam" }); await assert.rejects(fs.access(filePath), "File was not deleted"); }); }); describe(".handle()", function() { it("should log validation errors from ipc handlers", async function() { let logged = []; let ipcServer = await hostServer.FactorioServer.fromOptions( path.join("test", "file", "factorio"), writePath, { logger: { error: msg => { logged.push(msg); } } } ); class NumberEvent { static type = "event"; static src = "instance"; static dst = "controller"; constructor(value) { this.value = value; } static jsonSchema = { type: "number" }; static fromJSON(json) { return new this(json); } } let eventFromJSON = Link.eventFromJSON(NumberEvent, "NumberEvent"); ipcServer.handle("bad_event", async () => { eventFromJSON("not a number"); }); ipcServer.emit("ipc-bad_event", {}); await new Promise(resolve => setImmediate(resolve)); assert.equal(logged.length, 1); assert(logged[0].startsWith("Error handling ipc event:\nError: Event NumberEvent failed validation\n")); assert(logged[0].includes('"message": "must be number"')); }); }); describe("Lua UDP", function() { let udpServer; let received; before(async function() { // Stand-in for the Factorio server's --enable-lua-udp socket udpServer = dgram.createSocket("udp4"); received = []; udpServer.on("message", (msg, rinfo) => received.push([msg, rinfo])); await events.once(udpServer.bind(0, "127.0.0.1"), "listening"); server.luaUdpPort = udpServer.address().port; }); after(function() { udpServer.close(); }); afterEach(function() { server._stopUdp(); server._state = "init"; }); function sendFromGame(message) { udpServer.send(Buffer.from(message), server.hostUdpPort, "127.0.0.1"); } function captureLogger(level) { const logger = server._logger; const lines = []; server._logger = { [level]: msg => lines.push(msg) }; return { lines, restore: () => { server._logger = logger; } }; } it("should refuse to start on Factorio before 2.1.10", async function() { const version = server._version; server._version = "2.1.9"; server.enableLuaUdp = true; try { const error = new Error("Lua UDP requires Factorio 2.1.10 or later"); await assert.rejects(server.start("save.zip"), error); await assert.rejects(server.startScenario("test"), error); assert.equal(server._state, "init"); } finally { server._version = version; server.enableLuaUdp = false; } }); it("should open the socket when RCON starts", async function() { const startRcon = server._startRcon; server._startRcon = async () => {}; server.enableLuaUdp = true; try { server._handleOutput(Buffer.from( " 0.500 Info RemoteCommandProcessor.cpp:133: Starting RCON interface" ), "stdout"); while (!server._udpSocket) { await wait(1); } } finally { server._startRcon = startRcon; server.enableLuaUdp = false; } assert(server.hostUdpPort > 0, "socket was not opened"); }); it("should not have a host port when not running", function() { assert.equal(server.hostUdpPort, undefined); }); it("should reject sendUdp when the socket is not open", async function() { server._state = "running"; await assert.rejects(server.sendUdp("data"), new Error("UDP socket is not open")); }); it("should send packets to the Lua UDP port", async function() { await server._startUdp(); server._state = "running"; assert(server.hostUdpPort > 0, "hostUdpPort not set"); await server.sendUdp("hello"); await server.sendUdp(Buffer.from("world")); while (received.length < 2) { await wait(1); } assert.equal(received[0][0].toString(), "hello"); assert.equal(received[1][0].toString(), "world"); assert.equal(received[0][1].port, server.hostUdpPort); }); it("should emit udp events for packets from the game", async function() { await server._startUdp(); let udpReceived = []; let onUdp = data => udpReceived.push(data); // Escaped characters in the channel name are unescaped server.on("udp-udp?channel", onUdp); let waiter = events.once(server, "udp-udp?channel"); // Packets not from the Factorio server's port are ignored let other = dgram.createSocket("udp4"); try { await events.once(other.bind(0, "127.0.0.1"), "listening"); other.send(Buffer.from("udp\\x3fchannel?bad"), server.hostUdpPort, "127.0.0.1"); sendFromGame("udp\\x3fchannel?spam?eggs"); await waiter; } finally { other.close(); server.off("udp-udp?channel", onUdp); } assert(Buffer.isBuffer(udpReceived[0]), "data is not a Buffer"); assert.deepEqual(udpReceived.map(String), ["spam?eggs"]); }); it("should warn on malformed packets and unhandled channels", async function() { await server._startUdp(); const { lines, restore } = captureLogger("warn"); try { sendFromGame("no separator"); sendFromGame("unhandled?data"); while (lines.length < 2) { await wait(1); } } finally { restore(); } assert.deepEqual(lines, [ 'Ignoring malformed UDP packet "no separator"', "Warning: Unhandled udp-unhandled", ]); }); it("should invoke handlers registered with handleUdp", async function() { let handled = []; server.handleUdp("handled", async data => { handled.push(data.toString()); }); server.handleUdp("failing", async () => { throw new Error("boom"); }); const { lines, restore } = captureLogger("error"); try { server.emit("udp-handled", Buffer.from("data")); server.emit("udp-failing", Buffer.from("data")); await new Promise(resolve => setImmediate(resolve)); } finally { restore(); } assert.deepEqual(handled, ["data"]); assert.equal(lines.length, 1); assert(lines[0].startsWith("Error handling udp event:\nError: boom"), lines[0]); }); it("should log errors thrown synchronously by handleUdp handlers", async function() { await server._startUdp(); server.handleUdp("sync", () => { throw new Error("sync boom"); }); const { lines, restore } = captureLogger("error"); try { sendFromGame("sync?data"); while (lines.length < 1) { await wait(1); } } finally { restore(); } assert(lines[0].startsWith("Error handling udp event:\nError: sync boom"), lines[0]); }); it("should forward socket errors", async function() { await server._startUdp(); let waiter = events.once(server, "error"); server._udpSocket.emit("error", new Error("socket failed")); let [err] = await waiter; assert.equal(err.message, "socket failed"); }); it("should reject sendUdp when sending fails", async function() { await server._startUdp(); server._state = "running"; // Larger than the maximum size of a UDP datagram await assert.rejects(server.sendUdp(Buffer.alloc(70000)), { code: "EMSGSIZE" }); }); it("should throw if the socket is already open", async function() { await server._startUdp(); await assert.rejects(server._startUdp(), new Error("UDP socket is already open")); }); it("should log an error if opening the socket fails", async function() { const createSocket = dgram.createSocket; dgram.createSocket = (...args) => { const socket = createSocket(...args); socket.bind = () => process.nextTick(() => socket.emit("error", new Error("bind failed"))); return socket; }; const { lines, restore } = captureLogger("error"); try { await server._startUdp(); } finally { dgram.createSocket = createSocket; restore(); } assert.equal(server._udpSocket, null); assert(lines[0].startsWith("Failed to open UDP socket:\nError: bind failed"), lines[0]); }); it("should close the socket when the server exits", async function() { await server._startUdp(); server._server = new events.EventEmitter(); server._state = "stopping"; server._watchExit(); server._server.emit("exit", 0, null); assert.equal(server._udpSocket, null); assert.equal(server._state, "init"); }); it("should close the socket when the server is killed for hanging", async function() { await server._startUdp(); let killed = false; server._server = { kill: () => { killed = true; } }; const { restore } = captureLogger("error"); try { server.onStopTimeout(); } finally { restore(); server._server = null; } assert(killed, "server was not killed"); assert.equal(server._udpSocket, null); }); }); describe(".stop()", function() { it("should handle server quitting on its own during stop", async function() { server.shutdownTimeoutMs = 20; server._server = new events.EventEmitter(); server._server.kill = () => true; server._state = "running"; server._rconReady = false; server._rconClient = { async sendRcon() { }, async end() { server._rconClient = null; }, }; server._watchExit(); const stop = server.stop(); stop.catch(() => {}); process.nextTick(() => { server.emit("rcon-ready"); server._server.emit("exit"); }); await stop; await wait(21); // Wait until after shutdown timeout }); }); describe("checkForUpdates()", function() { let _fetch; let fetchCalledWith; let _platform = process.platform; beforeEach(function() { _fetch = global.fetch; fetchCalledWith = null; global.fetch = async function(url) { fetchCalledWith = url; return { ok: false, status: -1, statusText: "Fetch called", }; }; }); afterEach(function() { global.fetch = _fetch; Object.defineProperty(process, "platform", { value: _platform, }); }); for (const [suite, target] of [ ["full version", "0.1.1"], ["partial version", "0.1"], ["latest version", "latest"], ]) { /* eslint-disable no-loop-func */ describe(suite, function() { const factorioDir = path.join("test", "file", "factorio"); // A target that matches the 0.1.5 download but not the installed 0.1.1 const newerTarget = target === "0.1.1" ? "0.1.5" : target; it("should do nothing when there are no versions", async function() { await hostServer.checkForUpdates(factorioDir, target, []); assert.equal(fetchCalledWith, null); }); if (target !== "latest") { it("should do nothing when no version matches", async function() { await hostServer.checkForUpdates(factorioDir, target, [{ stable: true, version: "0.2.1", headlessUrl: "test1", }, { stable: false, version: "0.2.0", headlessUrl: "test2", }]); assert.equal(fetchCalledWith, null); }); } it("should do nothing when there is no newer version", async function() { await hostServer.checkForUpdates(factorioDir, target, [{ stable: true, version: "0.1.1", headlessUrl: "test1", }, { stable: false, version: "0.1.0", headlessUrl: "test2", }]); assert.equal(fetchCalledWith, null); }); it("should do nothing for direct installs", async function() { await hostServer.checkForUpdates(path.join(factorioDir, "0.1.1"), newerTarget, [{ stable: true, version: "0.1.5", headlessUrl: "test1", }, { stable: true, version: "0.1.1", headlessUrl: "test1", }, { stable: false, version: "0.1.0", headlessUrl: "test2", }]); assert.equal(fetchCalledWith, null); }); it("should do nothing when on windows", async function() { let logLine = null; const logger = { info: line => { logLine = line; } }; Object.defineProperty(process, "platform", { value: "win32" }); await hostServer.checkForUpdates(factorioDir, newerTarget, [{ stable: true, version: "0.1.5", headlessUrl: "test1", }, { stable: true, version: "0.1.1", headlessUrl: "test2", }, { stable: false, version: "0.1.0", headlessUrl: "test3", }], logger); assert.equal(fetchCalledWith, null); assert.ok(logLine !== null); assert.ok(logLine.endsWith("(automatic downloads are only supported on Linux).")); }); it("should do attempt to download on linux", async function() { let logLine = null; const logger = { info: line => { logLine = line; } }; Object.defineProperty(process, "platform", { value: "linux" }); await assert.rejects(hostServer.checkForUpdates(factorioDir, newerTarget, [{ stable: true, version: "0.1.5", headlessUrl: "test1", }, { stable: true, version: "0.1.1", headlessUrl: "test2", }, { stable: false, version: "0.1.0", headlessUrl: "test3", }], logger), new Error("Failed to fetch test1: -1 Fetch called")); assert.equal(fetchCalledWith, "test1"); assert.ok(logLine !== null); assert.ok(logLine.endsWith("starting download...")); }); }); /* eslint-enable no-loop-func */ } describe("concurrent downloads", function() { const versions = [{ stable: true, version: "0.1.5", headlessUrl: "test1" }]; // Holds the mocked download open until release() and resolves waiting once a check waits for it function holdDownload(factorioDir) { const held = { fetches: 0 }; const release = new Promise(resolve => { held.release = resolve; }); held.fetching = new Promise(resolve => { global.fetch = async function() { held.fetches += 1; resolve(); await release; return { ok: false, status: -1, statusText: "Fetch called" }; }; }); let waited; held.waiting = new Promise(resolve => { waited = resolve; }); const logger = { info: line => line.endsWith("waiting for download...") && waited() }; held.check = () => hostServer.checkForUpdates(factorioDir, "latest", versions, logger); Object.defineProperty(process, "platform", { value: "linux" }); return held; } it("should download once when servers check at the same time", async function() { const held = holdDownload(path.join("test", "file", "factorio")); let settled = 0; const checks = [0, 1].map(() => held.check().finally(() => { settled += 1; })); await held.waiting; assert.equal(held.fetches, 1); assert.equal(settled, 0); held.release(); await Promise.all(checks.map(check => assert.rejects(check, /Fetch called/))); assert.equal(held.fetches, 1); }); it("should wait for a download in progress whose version already looks installed", async function() { const factorioDir = path.join("temp", "test", "download-lock"); await fs.rm(factorioDir, { force: true, recursive: true }); await fs.mkdir(factorioDir, { recursive: true }); const held = holdDownload(factorioDir); const first = held.check(); await held.fetching; // The extraction has written the changelog but has not finished const dataDir = path.join(factorioDir, "0.1.5", "data"); await fs.mkdir(dataDir, { recursive: true }); await fs.writeFile(path.join(dataDir, "changelog.txt"), "Version: 0.1.5\n"); const second = held.check(); await held.waiting; held.release(); await Promise.all([first, second].map(check => assert.rejects(check, /Fetch called/))); assert.equal(held.fetches, 1); }); }); it("should download a version correctly (live api)", async function() { slowTest(this); externalTest(this); if (_platform !== "linux") { this.skip(); } const factorioDir = path.join("temp", "test", "factorioDownload"); global.fetch = _fetch; await fs.rm(factorioDir, { force: true, recursive: true, maxRetries: 10 }); await fs.mkdir(factorioDir, { recursive: true }); await hostServer.checkForUpdates(factorioDir, "latest", [{ stable: true, version: "2.0.73", headlessUrl: "https://www.factorio.com/get-download/2.0.73/headless/linux64", }]); }); }); }); });