"use strict"; const assert = require("assert").strict; const lib = require("@clusterio/lib"); const { ModDependencyResolveRequest, ModDependency, ModInfo } = lib; const { testMatrix, testRoundTripJsonSerialisable } = require("../common"); const { Controller, ControlConnection } = require("@clusterio/controller"); const { slowTest } = require("../integration"); describe("messages/mod", function() { /** @type {Controller} */ let controller; /** @type {ControlConnection} */ let controlConnection; /** @type {Map} */ let ModReleases = new Map(); /** * @param {string} name * @param {string} version * @param {string} factorioVersion * @param {string[]} dependencies */ function setPortalModRelease(name, version, factorioVersion, dependencies) { ModReleases.set(name, { name: name, releases: [{ version: version, info_json: { factorio_version: factorioVersion, dependencies: ["base", ...dependencies], }, }], }); } const _ModStore_fetchModReleases = lib.ModStore.fetchModReleases; after(function() { lib.ModStore.fetchModReleases = _ModStore_fetchModReleases; }); beforeEach(function() { ModReleases = new Map(); lib.ModStore.fetchModReleases = function(modName) { const mod = ModReleases.get(modName); if (mod) { return mod; } throw new Error("Mock 404"); }; const controllerConfig = new lib.ControllerConfig("controller"); const connection = new lib.VirtualConnector( lib.Address.fromShorthand("controller"), lib.Address.fromShorthand({ controlId: 1 }), ); controller = new Controller(lib.logger, [], controllerConfig); const user = controller.users.getOrCreateUser("test"); controlConnection = new ControlConnection({ version: "2.0.0" }, connection, controller, user, 1); }); describe("ModDependencyResolveRequest", function() { describe("static", function() { const factorioVersions = ["1.0", "1.1", "2.0"]; const tests = testMatrix( [ // mods [], [new ModDependency("foo")], [new ModDependency("? foo"), new ModDependency("bar > 2.0.5")], ], factorioVersions, // factorioVersion [undefined, true, false], ); it("should be round trip json serialisable", function() { testRoundTripJsonSerialisable(ModDependencyResolveRequest, tests); }); it("should be construable", function() { for (const [mods, factorioVersion, checkForUpdates] of tests) { const request = new ModDependencyResolveRequest(mods, factorioVersion, checkForUpdates); assert.equal(request.factorioVersion, factorioVersion); assert.equal(request.checkForUpdates, checkForUpdates ?? false); assert.equal(request.mods, mods); } }); it("should be constructed from a ModPack", function() { for (const factorioVersion of factorioVersions) { const mods = [ { name: "foo", version: "1.0.0", enabled: true }, { name: "bar", version: "2.0.0", enabled: false }, ]; const modPack = lib.ModPack.fromJSON({ mods: mods, factorio_version: factorioVersion }); const request = ModDependencyResolveRequest.fromModPack(modPack); assert.equal(request.factorioVersion, factorioVersion, factorioVersion); assert.equal(request.mods.length, modPack.mods.size, factorioVersion); assert.deepEqual( request.mods.find(m => m.name === "foo"), new ModDependency("foo = 1.0.0"), factorioVersion); assert.deepEqual( request.mods.find(m => m.name === "bar"), new ModDependency("bar = 2.0.0"), factorioVersion); } }); it("should be constructed from a ModPack (enabled only)", function() { for (const factorioVersion of factorioVersions) { const mods = [ { name: "foo", version: "1.0.0", enabled: true }, { name: "bar", version: "2.0.0", enabled: false }, ]; const modPack = lib.ModPack.fromJSON({ mods: mods, factorio_version: factorioVersion }); const request = ModDependencyResolveRequest.fromModPackEnabled(modPack); assert.equal(request.factorioVersion, factorioVersion); assert.equal( request.mods.length, [...modPack.mods.values()].filter(m => m.enabled).length, factorioVersion); assert.deepEqual( request.mods.find(m => m.name === "foo"), new ModDependency("foo = 1.0.0"), factorioVersion); assert.deepEqual( request.mods.find(m => m.name === "bar"), undefined, factorioVersion); } }); it("should be constructed from a ModPack (check for updates)", function() { for (const factorioVersion of factorioVersions) { const mods = [ { name: "foo", version: "1.0.0", enabled: true }, { name: "bar", version: "2.0.0", enabled: false }, ]; const modPack = lib.ModPack.fromJSON({ mods: mods, factorio_version: factorioVersion }); const request = ModDependencyResolveRequest.fromModPack(modPack, true); assert.equal(request.factorioVersion, factorioVersion, factorioVersion); assert.equal(request.mods.length, modPack.mods.size, factorioVersion); assert.deepEqual( request.mods.find(m => m.name === "foo"), new ModDependency("foo >= 1.0.0"), factorioVersion); assert.deepEqual( request.mods.find(m => m.name === "bar"), new ModDependency("bar >= 2.0.0"), factorioVersion); } }); it("should be constructed from a ModPack (enabled only) (check for updates)", function() { for (const factorioVersion of factorioVersions) { const mods = [ { name: "foo", version: "1.0.0", enabled: true }, { name: "bar", version: "2.0.0", enabled: false }, ]; const modPack = lib.ModPack.fromJSON({ mods: mods, factorio_version: factorioVersion }); const request = ModDependencyResolveRequest.fromModPackEnabled(modPack, true); assert.equal(request.factorioVersion, factorioVersion); assert.equal( request.mods.length, [...modPack.mods.values()].filter(m => m.enabled).length, factorioVersion); assert.deepEqual( request.mods.find(m => m.name === "foo"), new ModDependency("foo >= 1.0.0"), factorioVersion); assert.deepEqual( request.mods.find(m => m.name === "bar"), undefined, factorioVersion); } }); }); describe("response", function() { const tests = testMatrix( [ // dependencies [], [ ModInfo.fromJSON({ name: "foo", version: "1.0.0" }), ], [ ModInfo.fromJSON({ name: "foo", version: "1.0.0" }), ModInfo.fromJSON({ name: "bar", version: "2.0.0" }), ], ], [ // errors new Map([]), new Map([["foo", "notFound"]]), new Map([["foo", "unsatisfiable"], ["bar", "incompatible"], ["baz", "invalidDependency"]]), ], ); it("should be round trip json serialisable", function() { testRoundTripJsonSerialisable(ModDependencyResolveRequest.Response, tests); }); it("should be construable", function() { for (const [dependencies, errors] of tests) { const test = JSON.stringify([dependencies, errors]); const request = new ModDependencyResolveRequest.Response(dependencies, errors); assert.deepEqual(request.dependencies, dependencies, test); assert.deepEqual(request.errors, errors, test); } }); }); describe("handle", function() { const factorioVersions = ["1.0", "1.1", "2.0"]; it("resolves dependencies", async function() { for (const factorioVersion of factorioVersions) { setPortalModRelease("root", "1.0.0", factorioVersion, ["foo", "bar < 2.0.0", "? baz"]); setPortalModRelease("foo", "1.0.0", factorioVersion, ["foo-dep"]); setPortalModRelease("foo-dep", "1.0.0", factorioVersion, []); setPortalModRelease("bar", "1.0.0", factorioVersion, []); setPortalModRelease("baz", "1.0.0", factorioVersion, []); setPortalModRelease("root-2", "1.0.0", factorioVersion, ["foo", "bar-2"]); setPortalModRelease("bar-2", "1.0.0", factorioVersion, []); const result = await controlConnection.handleModDependencyResolveRequest( new lib.ModDependencyResolveRequest( [new ModDependency("root"), new ModDependency("root-2")], factorioVersion, ) ); assert.deepEqual(result.errors, new Map([ ["base", "notFound"], ])); const depIds = new Set(result.dependencies.map(mod => mod.id)); assert.deepEqual(depIds, new Set([ "root_1.0.0", "foo_1.0.0", "foo-dep_1.0.0", "bar_1.0.0", "root-2_1.0.0", "bar-2_1.0.0", ])); } }); it("highlights dependencies with no suitable version", async function() { for (const factorioVersion of factorioVersions) { setPortalModRelease("root", "1.0.0", factorioVersion, ["bar < 2.0.0"]); setPortalModRelease("bar", "2.0.0", factorioVersion, []); const result = await controlConnection.handleModDependencyResolveRequest( new lib.ModDependencyResolveRequest([new ModDependency("root")], factorioVersion) ); assert.deepEqual(result.errors, new Map([ ["base", "notFound"], ["bar", "notFound"], ])); assert.equal(result.dependencies.length, 1); assert.equal(result.dependencies[0].id, "root_1.0.0"); } }); it("highlights dependencies with no releases", async function() { for (const factorioVersion of factorioVersions) { setPortalModRelease("root", "1.0.0", factorioVersion, ["foo"]); const result = await controlConnection.handleModDependencyResolveRequest( new lib.ModDependencyResolveRequest([new ModDependency("root")], factorioVersion) ); assert.deepEqual(result.errors, new Map([ ["base", "notFound"], ["foo", "notFound"], ])); assert.equal(result.dependencies.length, 1); assert.equal(result.dependencies[0].id, "root_1.0.0"); } }); it("highlights dependencies which are incompatible", async function() { for (const factorioVersion of factorioVersions) { setPortalModRelease("root", "1.0.0", factorioVersion, ["foo", "! foo-dep"]); setPortalModRelease("foo", "1.0.0", factorioVersion, ["foo-dep"]); setPortalModRelease("foo-dep", "1.0.0", factorioVersion, []); const result = await controlConnection.handleModDependencyResolveRequest( new lib.ModDependencyResolveRequest([new ModDependency("root")], factorioVersion) ); assert.deepEqual(result.errors, new Map([ ["base", "notFound"], ["foo-dep", "incompatible"], ])); const depIds = new Set(result.dependencies.map(mod => mod.id)); assert.deepEqual(depIds, new Set(["root_1.0.0", "foo_1.0.0"])); } }); it("highlights dependencies with conflicting versions", async function() { for (const factorioVersion of factorioVersions) { setPortalModRelease("root", "1.0.0", factorioVersion, ["foo", "bar"]); setPortalModRelease("foo", "1.0.0", factorioVersion, ["dep > 2.0.0"]); setPortalModRelease("bar", "1.0.0", factorioVersion, ["dep < 1.5.0"]); const result = await controlConnection.handleModDependencyResolveRequest( new lib.ModDependencyResolveRequest([new ModDependency("root")], factorioVersion) ); assert.deepEqual(result.errors, new Map([ ["base", "notFound"], ["dep", "unsatisfiable"], ])); const depIds = new Set(result.dependencies.map(mod => mod.id)); assert.deepEqual(depIds, new Set(["root_1.0.0", "foo_1.0.0", "bar_1.0.0"])); } }); it("highlights incompatible builtin mods", async function() { for (const factorioVersion of factorioVersions) { setPortalModRelease("root", "1.0.0", factorioVersion, ["foo"]); setPortalModRelease("foo", "1.0.0", factorioVersion, ["! base"]); const result = await controlConnection.handleModDependencyResolveRequest( new lib.ModDependencyResolveRequest([new ModDependency("root")], factorioVersion) ); assert.deepEqual(result.errors, new Map([ ["base", "incompatible"], ])); const depIds = new Set(result.dependencies.map(mod => mod.id)); assert.deepEqual(depIds, new Set(["root_1.0.0", "foo_1.0.0"])); } }); it("does not ignore optional dependencies when required by another", async function() { for (const factorioVersion of factorioVersions) { setPortalModRelease("root", "1.0.0", factorioVersion, ["foo", "? baz"]); setPortalModRelease("foo", "1.0.0", factorioVersion, ["baz"]); setPortalModRelease("baz", "1.0.0", factorioVersion, []); const result = await controlConnection.handleModDependencyResolveRequest( new lib.ModDependencyResolveRequest([new ModDependency("root")], factorioVersion) ); assert.deepEqual(result.errors, new Map([ ["base", "notFound"], ])); const depIds = new Set(result.dependencies.map(mod => mod.id)); assert.deepEqual(depIds, new Set(["root_1.0.0", "foo_1.0.0", "baz_1.0.0"])); } }); it("prefers the locally installed version if matching", async function() { controller.modStore.addMod(lib.ModInfo.fromJSON({ name: "foo", version: "1.0.0" })); for (const factorioVersion of factorioVersions) { setPortalModRelease("root", "1.0.0", factorioVersion, ["foo"]); setPortalModRelease("foo", "2.0.0", factorioVersion, []); const result = await controlConnection.handleModDependencyResolveRequest( new lib.ModDependencyResolveRequest([new ModDependency("root")], factorioVersion) ); assert.deepEqual(result.errors, new Map([ ["base", "notFound"], ])); const depIds = new Set(result.dependencies.map(mod => mod.id)); assert.deepEqual(depIds, new Set(["root_1.0.0", "foo_1.0.0"])); } }); it("prefers the mod portal version when checking for updates", async function() { controller.modStore.addMod(lib.ModInfo.fromJSON({ name: "foo", version: "1.0.0" })); for (const factorioVersion of factorioVersions) { setPortalModRelease("root", "1.0.0", factorioVersion, ["foo"]); setPortalModRelease("foo", "2.0.0", factorioVersion, []); const result = await controlConnection.handleModDependencyResolveRequest( new lib.ModDependencyResolveRequest([new ModDependency("root")], factorioVersion, true) ); assert.deepEqual(result.errors, new Map([ ["base", "notFound"], ])); const depIds = new Set(result.dependencies.map(mod => mod.id)); assert.deepEqual(depIds, new Set(["root_1.0.0", "foo_2.0.0"])); } }); it("prefers the latest mod portal version when using the mod portal", async function() { for (const factorioVersion of factorioVersions) { setPortalModRelease("root", "1.0.0", factorioVersion, ["foo", "bar < 2.0.0"]); ModReleases.set("foo", { name: "foo", releases: [{ version: "1.0.0", info_json: { factorio_version: factorioVersion, dependencies: [] }, }, { version: "2.0.0", info_json: { factorio_version: factorioVersion, dependencies: [] }, }], }); ModReleases.set("bar", { name: "bar", releases: [{ version: "1.0.0", info_json: { factorio_version: factorioVersion, dependencies: [] }, }, { version: "1.5.0", info_json: { factorio_version: factorioVersion, dependencies: [] }, }, { version: "2.0.0", info_json: { factorio_version: factorioVersion, dependencies: [] }, }], }); const result = await controlConnection.handleModDependencyResolveRequest( new lib.ModDependencyResolveRequest([new ModDependency("root")], factorioVersion) ); assert.deepEqual(result.errors, new Map([ ["base", "notFound"], ])); const depIds = new Set(result.dependencies.map(mod => mod.id)); assert.deepEqual(depIds, new Set(["root_1.0.0", "foo_2.0.0", "bar_1.5.0"])); } }); it("rejects when a network error occurs", async function() { lib.ModStore.fetchModReleases = function() { throw new Error("Mock network error"); }; for (const factorioVersion of factorioVersions) { await assert.rejects(controlConnection.handleModDependencyResolveRequest( new lib.ModDependencyResolveRequest([new ModDependency("root")], factorioVersion) )); } }); it("resolves dependencies (live)", async function() { this.timeout(60000); // Increase timeout to 60 seconds for live API call lib.ModStore.fetchModReleases = _ModStore_fetchModReleases; const result = await controlConnection.handleModDependencyResolveRequest( new lib.ModDependencyResolveRequest([new ModDependency("pymodpack = 3.0.0")], "2.0") ); assert.deepEqual(result.errors, new Map([ ["base", "notFound"], ])); const depNames = new Set(result.dependencies.map(mod => mod.name)); assert.deepEqual(depNames, new Set([ // Can't use ids as the versions may change "pymodpack", "pypostprocessing", "pyalternativeenergy", "pyalternativeenergygraphics", "pyalienlife", "pyalienlifegraphics", "pyalienlifegraphics2", "pyalienlifegraphics3", "pycoalprocessing", "pycoalprocessinggraphics", "pyfusionenergy", "pyfusionenergygraphics", "pypetroleumhandling", "pypetroleumhandlinggraphics", "pyrawores", "pyraworesgraphics", "pyhightech", "pyhightechgraphics", "pyindustry", "pyindustrygraphics", ])); }); }); }); });