diff --git a/packages/controller/src/BaseConnection.ts b/packages/controller/src/BaseConnection.ts index bda042aa..8efbf14c 100644 --- a/packages/controller/src/BaseConnection.ts +++ b/packages/controller/src/BaseConnection.ts @@ -78,7 +78,7 @@ export default class BaseConnection extends lib.Link { return modPack; } - getMod(mod: { name: string, version: string, sha1?: string }) { + getMod(mod: { name: string, version: lib.FullVersion, sha1?: string }) { let filename = lib.ModInfo.filename(mod.name, mod.version); let modInfo = this._controller.modStore.files.get(filename); if (!modInfo) { diff --git a/packages/controller/src/ControlConnection.ts b/packages/controller/src/ControlConnection.ts index c54a3c45..b9f70cb1 100644 --- a/packages/controller/src/ControlConnection.ts +++ b/packages/controller/src/ControlConnection.ts @@ -100,6 +100,8 @@ export default class ControlConnection extends BaseConnection { this.handle(lib.ModListRequest, this.handleModListRequest.bind(this)); this.handle(lib.ModSearchRequest, this.handleModSearchRequest.bind(this)); this.handle(lib.ModPortalGetAllRequest, this.handleModPortalGetAllRequest.bind(this)); + this.handle(lib.ModPortalDownloadRequest, this.handleModPortalDownloadRequest.bind(this)); + this.handle(lib.ModDependencyResolveRequest, this.handleModDependencyResolveRequest.bind(this)); this.handle(lib.ModDeleteRequest, this.handleModDeleteRequest.bind(this)); this.handle(lib.LogSetSubscriptionsRequest, this.handleLogSetSubscriptionsRequest.bind(this)); this.handle(lib.LogQueryRequest, this.handleLogQueryRequest.bind(this)); @@ -124,7 +126,6 @@ export default class ControlConnection extends BaseConnection { this.handle(lib.PluginUpdateRequest, this.handlePluginUpdateRequest.bind(this)); this.handle(lib.PluginInstallRequest, this.handlePluginInstallRequest.bind(this)); this.handle(lib.DebugDumpWsRequest, this.handleDebugDumpWsRequest.bind(this)); - this.handle(lib.ModPortalDownloadRequest, this.handleModPortalDownloadRequest.bind(this)); } validateIngress(message: lib.MessageRequest | lib.MessageEvent) { @@ -564,52 +565,181 @@ export default class ControlConnection extends BaseConnection { * Handle request to download a mod from the Factorio Mod Portal to the controller. * @param request - Request object with mod name, version, and factorioVersion. */ - async handleModPortalDownloadRequest(request: lib.ModPortalDownloadRequest): Promise { - const { name, version, factorioVersion } = request; - const filename = lib.ModInfo.filename(name, version); + async handleModPortalDownloadRequest(request: lib.ModPortalDownloadRequest) { + const factorioVersion = request.factorioVersion; + const toDownload = this._controller.modStore.filterInstalled(request.mods, false); - // Check if mod already exists - if (this._controller.modStore.files.has(filename)) { - logger.verbose(`Mod ${filename} already exists, skipping download.`); - return this._controller.modStore.files.get(filename)!; - } + if (toDownload.length > 0) { + // Get Factorio credentials from config + const username = this._controller.config.get("controller.factorio_username"); + const token = this._controller.config.get("controller.factorio_token"); - // Get Factorio credentials from config - const username = this._controller.config.get("controller.factorio_username"); - const token = this._controller.config.get("controller.factorio_token"); - - if (!username || !token) { - throw new lib.RequestError("Factorio credentials (username, token) not configured on the controller."); - } - - try { - logger.info(`Downloading mod ${filename} for Factorio ${factorioVersion} from portal.`); - const modMap = new Map([[name, version]]); - await this._controller.modStore.downloadMods(modMap, username || "", token || "", factorioVersion); - - // Verify download and return ModInfo - const downloadedMod = this._controller.modStore.files.get(filename); - if (!downloadedMod) { - throw new lib.RequestError(`Failed to find mod ${filename} in store after download attempt.`); + if (!username || username === "" || !token || token === "") { + throw new lib.RequestError("Factorio credentials (username, token) not configured on the controller."); } - logger.info(`Successfully downloaded mod ${filename}.`); - return downloadedMod; - } catch (error: any) { - logger.error(`Error downloading mod ${filename} from portal: ${error.message}`); - // Improve error message clarity - let errorMessage = `Mod portal download failed for ${name} v${version}`; - if (error instanceof lib.RequestError) { - errorMessage += `: ${error.message}`; - } else if (error.message.includes("401") || error.message.includes("Unauthorized")) { - errorMessage += ": Authentication failed. Check Factorio username/token in controller config."; - } else if (error.message.includes("404") || error.message.includes("Not Found")) { - errorMessage += ": Mod version not found on the portal."; + try { + logger.info(`Downloading ${toDownload.length} mods for Factorio ${factorioVersion} from portal.`); + await this._controller.modStore.downloadMods(toDownload, username, token, factorioVersion); + + } catch (error: any) { + logger.error(`Error downloading mods from portal: ${error.message}`); + // Improve error message clarity + let errorMessage = "Mod portal download failed"; + if (error instanceof lib.RequestError) { + errorMessage += `: ${error.message}`; + } else if (error.message.includes("401") || error.message.includes("Unauthorized")) { + errorMessage += ": Authentication failed. Check Factorio username/token in controller config."; + } else if (error.message.includes("404") || error.message.includes("Not Found")) { + errorMessage += ": Mod version not found on the portal."; + } else { + errorMessage += `: ${error.message}`; + } + throw new lib.RequestError(errorMessage); + } + } + + // Verify all mods are present + const modInfos = []; + const installedMods = [...this._controller.modStore.mods()]; + for (const mod of request.mods) { + const modInfo = installedMods.find(m => m.name === mod.name && mod.version.testVersion(m.version)); + if (!modInfo) { + throw new lib.RequestError(`Failed to find mod ${mod.name} in store after download attempt.`); + } + modInfos.push(modInfo); + } + + return modInfos; + } + + async handleModDependencyResolveRequest(request: lib.ModDependencyResolveRequest) { + const {factorioVersion, checkForUpdates} = request; + const searchQueue = [...request.mods]; + const localInfos = [...this._controller.modStore.mods()]; + + const _builtInModNames = lib.ModPack.getBuiltinModNames(factorioVersion); + const builtInModNames = new Set(_builtInModNames); + const skipSearch = new Set(_builtInModNames); + + type ModPortalModType = InstanceType["mods"][number]; + type ModPortalReleaseType = NonNullable[number]; + const dependencyRequirements = new Map(); + const optionalRequirements = new Map(); + const candidateReleases = new Map(); + const incompatible = new Set(); + const invalid = new Set(); + + const _modPortalReleases = new Map(); + async function fetchModReleases(modName: string) { + let releases = _modPortalReleases.get(modName); + if (releases === undefined) { + try { + releases = await lib.ModStore.fetchModReleases(modName); + } catch (error: any) { + if (!error.message.includes("404") && !error.message.includes("Not Found")) { + logger.error(`Error fetching mod from portal: ${error.message}`); + throw new lib.RequestError(`Dependency resolution failed: ${error.message}`); + } else { + releases = null; + } + } + } + _modPortalReleases.set(modName, releases); + return releases; + } + + while (searchQueue.length > 0) { + const mod = searchQueue.shift()!; + if (mod.incompatible) { + incompatible.add(mod.name); + continue; + } + + // Get the current version range (optionals are tracked in case they become required) + let versionRange = dependencyRequirements.get(mod.name); + if (!versionRange) { + versionRange = optionalRequirements.get(mod.name) ?? new lib.ModVersionRange(); + if (mod.required) { + dependencyRequirements.set(mod.name, versionRange); + optionalRequirements.delete(mod.name); + } else { + optionalRequirements.set(mod.name, versionRange); + } + } + + // Update the version range if needed + if (mod.version) { + versionRange.combineVersion(mod.version); + if (!versionRange.valid) { + candidateReleases.delete(mod.name); + invalid.add(mod.name); + } + } + + // Do not add dependencies for invalids, optionals, skips, or valid candidates + const existingCandidate = candidateReleases.get(mod.name); + if (invalid.has(mod.name) || optionalRequirements.has(mod.name) || skipSearch.has(mod.name) || ( + existingCandidate && versionRange.testVersion(existingCandidate.version) + )) { + continue; + } + + // Check if a local version fits the requirements + let candidate = localInfos + .filter(info => (info.name === mod.name && versionRange.testVersion(info.version))) + .reduce((max, cur) => ( + max && max.integerVersion > cur.integerVersion ? max : cur + ), undefined); + + // Check if a mod poral version fits the requirements + if (!candidate || checkForUpdates) { + const modReleases = await fetchModReleases(mod.name); + if (modReleases && modReleases.releases) { + const release = modReleases.releases + .filter(info => ( + info.info_json.factorio_version === factorioVersion + && versionRange.testVersion(info.version) + )) + .reduce((max, cur) => ( + max && lib.integerFullVersion(max.version) > lib.integerFullVersion(cur.version) ? max : cur + ), undefined); + + // We only select the mod portal release if it is more recent than the local candidate + if (release && (!candidate || lib.integerFullVersion(release.version) > candidate.integerVersion)) { + candidate = lib.ModInfo.fromJSON({ + ...release.info_json, // Also includes dependences + sha1: release.sha1, + version: release.version, + name: modReleases.name, + author: modReleases.owner, + title: modReleases.title, + }); + } + } + } + + // Queue the dependencies of the new candidate + if (candidate) { + candidateReleases.set(mod.name, candidate); + for (const dep of candidate.dependencies) { + searchQueue.push(dep); + } } else { - errorMessage += `: ${error.message}`; + candidateReleases.delete(mod.name); + skipSearch.add(mod.name); } - throw new lib.RequestError(errorMessage); } + + return new lib.ModDependencyResolveRequest.Response( + [...candidateReleases.values()], + [...invalid.values(), ...[...incompatible.values()].filter(mod => ( + candidateReleases.has(mod) || builtInModNames.has(mod) + ))], + [...dependencyRequirements.keys()].filter(mod => ( + !candidateReleases.has(mod) && !incompatible.has(mod) && !invalid.has(mod) + )), + ); } async handleModDeleteRequest(request: lib.ModDeleteRequest) { diff --git a/packages/controller/src/Controller.ts b/packages/controller/src/Controller.ts index e3d13d02..0fa27628 100644 --- a/packages/controller/src/Controller.ts +++ b/packages/controller/src/Controller.ts @@ -135,7 +135,8 @@ export default class Controller { const modPacks = new lib.SubscribableDatastore(...await new lib.JsonIdDatastoreProvider( path.join(databaseDirectory, "mod-packs.json"), - lib.ModPack.fromJSON.bind(lib.ModPack) + lib.ModPack.fromJSON.bind(lib.ModPack), + this.migrateModPacks, ).bootstrap()); const roles = new lib.SubscribableDatastore(...await new lib.JsonIdDatastoreProvider( @@ -700,6 +701,17 @@ export default class Controller { return instance; } + static migrateModPacks(rawJson: unknown[]): Static[] { + const serialized = rawJson as Static[]; + return serialized.map(json => { + for (const mod of json.mods) { + // migrate: 2.0.0.alpha.22 - json schema now enforces X.Y.Z for mod version, builtins would use X.Y only + mod.version = lib.normaliseFullVersion(mod.version); + } + return json; + }); + } + static migrateRoles(rawJson: unknown[]): Static[] { const serialized = rawJson as Static[]; return serialized.map(json => { diff --git a/packages/controller/src/InstanceInfo.ts b/packages/controller/src/InstanceInfo.ts index 36959ee6..05d63596 100644 --- a/packages/controller/src/InstanceInfo.ts +++ b/packages/controller/src/InstanceInfo.ts @@ -11,7 +11,7 @@ export default class InstanceInfo { public config: lib.InstanceConfig, public status: lib.InstanceStatus, public gamePort?: number, - public factorioVersion?: string, + public factorioVersion?: lib.TargetVersion, public updatedAtMs = 0, ) { this.config = config; @@ -23,7 +23,7 @@ export default class InstanceInfo { "status": lib.InstanceStatus, "gamePort": Type.Optional(Type.Number()), "updatedAtMs": Type.Optional(Type.Number()), - "factorioVersion": Type.Optional(Type.String()), + "factorioVersion": Type.Optional(lib.TargetVersionSchema), }); static fromJSON(json: Static, location: lib.ConfigLocation) { diff --git a/packages/ctl/src/commands.ts b/packages/ctl/src/commands.ts index 59be29b0..142e26d0 100644 --- a/packages/ctl/src/commands.ts +++ b/packages/ctl/src/commands.ts @@ -1362,7 +1362,7 @@ function setModPackMods(modPack: lib.ModPack, mods: string[] | undefined) { if (!version) { throw new lib.CommandError("Added mod must be formatted as name:version or name:version:sha1"); } - if (!/^\d+\.\d+\.\d+$/.test(version)) { + if (!lib.isFullVersion(version)) { throw new lib.CommandError("version must match the format digit.digit.digit"); } if (sha1 && !/^[0-9a-f]{40}$/.test(sha1)) { @@ -1415,8 +1415,8 @@ modPackCommands.add(new lib.Command({ modPack.name = args.name; if (args.description) { modPack.description = args.description; } if (args.factorioVersion) { - if (!/^\d+\.\d+\.\d+?$/.test(args.factorioVersion)) { - throw new lib.CommandError("factorio-version must match the format digit.digit.digit"); + if (!lib.isPartialVersion(args.factorioVersion)) { + throw new lib.CommandError("factorio-version must match the format digit.digit[.digit]"); } modPack.factorioVersion = args.factorioVersion; } @@ -1496,8 +1496,8 @@ modPackCommands.add(new lib.Command({ if (args.name) { modPack.name = args.name; } if (args.description) { modPack.description = args.description; } if (args.factorioVersion) { - if (!/^\d+\.\d+\.\d+?$/.test(args.factorioVersion)) { - throw new lib.CommandError("factorio-version must match the format digit.digit.digit"); + if (!lib.isPartialVersion(args.factorioVersion)) { + throw new lib.CommandError("factorio-version must match the format digit.digit[.digit]"); } modPack.factorioVersion = args.factorioVersion; } @@ -1551,6 +1551,9 @@ modCommands.add(new lib.Command({ yargs.positional("mod-version", { describe: "Version of the mod", type: "string" }); }], handler: async function(args: { name: string, modVersion: string }, control: Control) { + if (!lib.isFullVersion(args.modVersion)) { + throw new lib.CommandError("mod-version must match format digit.digit.digit"); + } let modInfo = await control.send(new lib.ModGetRequest(args.name, args.modVersion)); for (let [field, value] of Object.entries(modInfo)) { if (value instanceof Array) { @@ -1638,7 +1641,7 @@ modCommands.add(new lib.Command({ ) { let response = await control.send(new lib.ModSearchRequest( args.query, - args.factorioVersion, + lib.normaliseApiVersion(args.factorioVersion as any), args.page, args.pageSize, args.sort, @@ -1717,6 +1720,9 @@ modCommands.add(new lib.Command({ yargs.positional("mod-version", { describe: "Version of mod to download", type: "string" }); }], handler: async function(args: { name: string, modVersion: string }, control: Control) { + if (!lib.isFullVersion(args.modVersion)) { + throw new lib.CommandError("mod-version must match format digit.digit.digit"); + } let streamId = await control.send(new lib.ModDownloadRequest(args.name, args.modVersion)); let url = new URL(control.config.get("control.controller_url")!); @@ -1757,6 +1763,9 @@ modCommands.add(new lib.Command({ yargs.positional("mod-version", { describe: "Version of mod to delete", type: "string" }); }], handler: async function(args: { name: string, modVersion: string }, control: Control) { + if (!lib.isFullVersion(args.modVersion)) { + throw new lib.CommandError("mod-version must match format digit.digit.digit"); + } await control.send(new lib.ModDeleteRequest(args.name, args.modVersion)); }, })); diff --git a/packages/host/src/Host.ts b/packages/host/src/Host.ts index ae4bd37b..c7409990 100644 --- a/packages/host/src/Host.ts +++ b/packages/host/src/Host.ts @@ -693,7 +693,8 @@ export default class Host extends lib.Link { instanceConnection ? instanceConnection.instance.server.gamePort : this.gamePort(instanceId), instanceConnection ? instanceConnection.instance.server.version - : instanceInfo.config.get("factorio.version"), + // TODO: factorio.version is not validated + : instanceInfo.config.get("factorio.version") as lib.TargetVersion, ) ); diff --git a/packages/host/src/Instance.ts b/packages/host/src/Instance.ts index 26815bbf..d3480c46 100644 --- a/packages/host/src/Instance.ts +++ b/packages/host/src/Instance.ts @@ -210,7 +210,8 @@ export default class Instance extends lib.Link { let serverOptions = { logger: this.logger, - version: this.config.get("factorio.version"), + // TODO: factorio.version is not validated + version: this.config.get("factorio.version") as lib.TargetVersion, executablePath: this.config.get("factorio.executable_path") ?? undefined, gamePort: this.config.get("factorio.game_port") ?? host.assignGamePort(this.id), rconPort: this.config.get("factorio.rcon_port") ?? undefined, @@ -553,7 +554,8 @@ end`.replace(/\r?\n/g, " "); this.id, status, this.server.gamePort, - status === "running"? this.server.version : this.config.get("factorio.version"), + // TODO: factorio.version is not validated + status === "running" ? this.server.version : this.config.get("factorio.version") as lib.TargetVersion, ), ); } diff --git a/packages/host/src/server.ts b/packages/host/src/server.ts index 479037c9..4ffda317 100644 --- a/packages/host/src/server.ts +++ b/packages/host/src/server.ts @@ -79,7 +79,7 @@ function versionOrder(a: string, b: string) { * @returns Array with path to data dir and version found. * @internal */ -async function findVersion(factorioDir: string, targetVersion: string) { +async function findVersion(factorioDir: string, targetVersion: lib.TargetVersion) { // There are two supported setups: having the factorio dir be the actual // install directory, and having the factorio dir be a folder containing @@ -423,7 +423,7 @@ export interface FactorioServerOptions { * Version of Factorio to use. Can also be the string "latest" to use * the latest version found in `factorioDir`. */ - version?: string, + version?: lib.TargetVersion, /** Path to executable to invoke when starting the server */ executablePath?: string; /** UDP port to host game on. */ @@ -523,7 +523,7 @@ export class FactorioServer extends events.EventEmitter { _writeDir: string; // Resolved in init - _version: string | null = null; + _version: lib.FullVersion | null = null; _dataDir: string | null = null; // Due to inconsistencies in the factorio api, we must manually watch the whitelist @@ -532,7 +532,7 @@ export class FactorioServer extends events.EventEmitter { _whitelist = new Set(); _logger: lib.Logger; - _targetVersion: string; + _targetVersion: lib.TargetVersion; _state: "new" | "init" | "create" | "running" | "stopping" = "new"; _server: child_process.ChildProcessWithoutNullStreams | null = null; _rconClient: Rcon | null= null; @@ -866,7 +866,7 @@ export class FactorioServer extends events.EventEmitter { * "latest" (the default) was specified as `factorioVersion` to the constructor. * This will be undefined before the server is initialized, or if init failed. */ - get version(): string | undefined { + get version(): lib.FullVersion | undefined { return this._version ?? undefined; } diff --git a/packages/lib/src/ModStore.ts b/packages/lib/src/ModStore.ts index 84d03154..13a94be6 100644 --- a/packages/lib/src/ModStore.ts +++ b/packages/lib/src/ModStore.ts @@ -3,10 +3,10 @@ import path from "path"; import events from "events"; import { Static } from "@sinclair/typebox"; import { logger } from "./logging"; -import { ModInfo, ModPack } from "./data"; import { safeOutputFile } from "./file_ops"; import { Writable } from "stream"; -import { ModPortalReleaseSchema, ModPortalDetailsSchema } from "./data/messages_mod"; +import { ModPortalReleaseSchema, ModPortalDetailsSchema, ModNameVersionPair } from "./data/messages_mod"; +import { ModInfo, ModPack, FullVersion, ApiVersion, ModVersionEquality } from "./data"; export interface ModStoreEvents { /** A stored mod was created, updated or deleted */ @@ -45,7 +45,7 @@ export default class ModStore extends events.EventEmitter { super(); } - getMod(name: string, version: string, sha1?: string) { + getMod(name: string, version: FullVersion, sha1?: string) { const mod = this.files.get(ModInfo.filename(name, version)); if (!mod || sha1 && sha1 !== mod.sha1) { return undefined; @@ -87,11 +87,11 @@ export default class ModStore extends events.EventEmitter { this.emit("change", modInfo); } - async loadMod(name: string, version: string) { + async loadMod(name: string, version: FullVersion) { return await this.loadFile(ModInfo.filename(name, version)); } - async deleteMod(name: string, version: string) { + async deleteMod(name: string, version: FullVersion) { return await this.deleteFile(ModInfo.filename(name, version)); } @@ -155,10 +155,46 @@ export default class ModStore extends events.EventEmitter { return new this(modsDirectory, files); } - private async downloadModFromReleases(releases: Array>, version: string, + /** + * Stream a url's content into a file. + * + * @param url - Web path to stream from. + * @param filePath - File path to stream to. + */ + private static async downloadFile(url: string | URL, filePath: string) { + try { + const response = await fetch(url); + + if (!response.ok) { + throw new Error(`Failed to fetch ${url}: ${response.status} ${response.statusText}`); + } + + if (!response.body) { + throw new Error("Response body is missing."); + } + + const fileStream = fs.createWriteStream(filePath); + const writer = Writable.toWeb(fileStream); + await response.body.pipeTo(writer); + } catch (error) { + logger.error("Error downloading file:", error); + throw error; + } + } + + /** + * Download a matching version from a selection of available versions. + * + * @param releases - Array of mod releases taken from the mod portal. + * @param version - The required version to install. + * @param username - Factorio username to download using. + * @param token - Factorio token to download using. + */ + private async downloadModFromReleases( + releases: Array>, version: ModVersionEquality, username: string, token: string ) { - const selectedRelease = releases.find(release => release.version === version); + const selectedRelease = releases.find(release => version.testVersion(release.version)); if (selectedRelease === undefined) { logger.warn(`Mod release matching version ${version} not found.`); @@ -190,96 +226,83 @@ export default class ModStore extends events.EventEmitter { } } + /** + * Downloads all provided mods, does not perform chunking or filtering of builtins + * + * @param mods - An array of mods to download + * @param username - Factorio username used for auth to download mods + * @param token - Factorio token used for auth to download mods + * @param factorioVersion - Factorio version to filter for (mods are not guaranteed to work between Middle versions) + */ private async downloadModsChunk( - modNames: string[], modVersions: string[], username: string, token: string, factorioVersion: string + mods: ModNameVersionPair[], + username: string, token: string, + factorioVersion: ApiVersion, ) { const url = new URL("https://mods.factorio.com/api/mods"); url.searchParams.set("page_size", "max"); url.searchParams.set("version", factorioVersion); const response = await fetch(url, { method: "POST", - body: new URLSearchParams({ namelist: modNames.join(",") }), + body: new URLSearchParams({ namelist: mods.map(m => m.name).join(",") }), }); + if (response.status !== 200) { throw Error(`Fetch: ${url} returned ${response.status} ${response.statusText}`); } - const mods = (await response.json() as ModsInfoResponse).results; - await Promise.all(mods.map((mod) => { + const modReleases = (await response.json() as ModsInfoResponse).results; + await Promise.all(modReleases.map((mod) => { + const modVersion = mods.find(m => m.name === mod.name)!.version; if (mod.releases) { - return this.downloadModFromReleases(mod.releases, - modVersions[modNames.indexOf(mod.name)], username, token); + return this.downloadModFromReleases(mod.releases, modVersion, username, token); } else if (mod.latest_release) { - return this.downloadModFromReleases([mod.latest_release], - modVersions[modNames.indexOf(mod.name)], username, token); + return this.downloadModFromReleases([mod.latest_release], modVersion, username, token); } throw new Error(`Mod ${mod.name} has no releases or latest_release`); })); } - /** + * Downloads all provided mods to the mod dir. * - * downloads mods to the mod dir, will not download mods for which are already in the mod directory - * - * @param modMap a map of what mods to download and what version to download - * @param username factorio username used for auth to download mods - * @param token factorio token used for auth to download mods - * @param factorioVersion factorio version to filter for (mods are not guaranteed to work between Middle versions) + * Handles chunking to prevent errors due to URL length. + * Will re-download if mod is already present, if this is undesired use filterInstalled first. * + * @param mods - An array of mods to download. + * @param username - Factorio username used for auth to download mods. + * @param token - Factorio token used for auth to download mods. + * @param factorioVersion - Factorio version to filter for (mods are not guaranteed to work between Middle versions) */ - async downloadMods(modMap: Map, username: string, token: string, factorioVersion: string = "1.1") { + async downloadMods( + mods: ModNameVersionPair[], username: string, token: string, factorioVersion: ApiVersion = "1.1" + ) { const chunkSize = 500; - let futures: Promise[] = []; - modMap = new Map(modMap); - - // Truncate factorioVersion to major.minor (e.g., "1.1.100" -> "1.1") for API/builtin checks - const versionParts = factorioVersion.split("."); - if (versionParts.length > 2) { - factorioVersion = versionParts.slice(0, 2).join("."); - } - + const futures: Promise[] = []; const builtInMods = new Set(ModPack.getBuiltinModNames(factorioVersion)); + mods = mods.filter(mod => !(builtInMods.has(mod.name) || mod.name === "core")); - // get rid of mods already downloaded, built-in, or core - modMap.forEach((modVersion, modName, map) => { - const filename = `${modName}_${modVersion}.zip`; - // Filter existing, built-in from getBuiltinModNames, and explicitly filter "core" - if (this.files.has(filename) || builtInMods.has(modName) || modName === "core") { - map.delete(modName); - } - }); - - const modNames = Array.from(modMap.keys()); - const modVersions = Array.from(modMap.values()); - for (let i = 0; i < modMap.size; i += chunkSize) { + for (let i = 0; i < mods.length; i += chunkSize) { futures.push( - this.downloadModsChunk(modNames.slice(i, i + chunkSize), modVersions.slice(i, i + chunkSize), - username, token, factorioVersion) + this.downloadModsChunk(mods.slice(i, i + chunkSize), username, token, factorioVersion) ); } + await Promise.all(futures); } - static async downloadFile(url: string | URL, filePath: string) { - try { - const response = await fetch(url); - - if (!response.ok) { - throw new Error(`Failed to fetch ${url}: ${response.status} ${response.statusText}`); - } - - if (!response.body) { - throw new Error("Response body is missing."); - } - - const fileStream = fs.createWriteStream(filePath); - const writer = Writable.toWeb(fileStream); - await response.body.pipeTo(writer); - } catch (error) { - logger.error("Error downloading file:", error); - throw error; - } + /** + * Filter an array of mods based on if they are installed. + * + * @param mods - Array of mods to filer. + * @param installed - `true` to keep installed mods, `false` to keep uninstalled mods. + * @returns Filtered array of mods based on install state. + */ + filterInstalled(mods: ModNameVersionPair[], installed: boolean) { + const installedMods = Array.from(this.mods()); + return mods.filter(mod => ( + installed === installedMods.some(m => mod.name === m.name && mod.version.testVersion(m.version)) + )); } /** @@ -332,4 +355,19 @@ export default class ModStore extends events.EventEmitter { logger.info(`Successfully fetched ${allMods.length} mods for Factorio version ${factorioVersion}.`); return allMods; } + + /** + * Fetch the full release list for a mod including dependencies. + * + * @param modName - Name of the mod to fetch the releases of + * @returns Mod portal response + */ + static async fetchModReleases(modName: string) { + const url = `https://mods.factorio.com/api/mods/${modName}/full`; + const response = await fetch(url); + if (!response.ok) { + throw Error(`Fetch: ${url} returned ${response.status} ${response.statusText}`); + } + return await response.json() as ModsInfoResponse["results"][number]; + } } diff --git a/packages/lib/src/data/ModInfo.ts b/packages/lib/src/data/ModInfo.ts index e29db8d3..df97831a 100644 --- a/packages/lib/src/data/ModInfo.ts +++ b/packages/lib/src/data/ModInfo.ts @@ -5,9 +5,128 @@ import { Type, Static } from "@sinclair/typebox"; import * as libHash from "../hash"; import * as libSchema from "../schema"; import { findRoot } from "../zip_ops"; +import { ModRecord } from "./ModPack"; -import { integerModVersion, integerFactorioVersion, modVersionRegExp } from "./version"; +import { + ApiVersion, + ApiVersionSchema, + FullVersion, FullVersionSchema, integerFullVersion, + integerPartialVersion, + ModVersionEquality, +} from "./version"; +type ModDependencyType = "incompatible" | "optional" | "hidden" | "unordered" | "required"; + +export type ModDependencyUnsatisfiedReason = "incompatible" | "missing_dependency" | "wrong_version"; +const UnsatisfiedSeverity: Record = { + "incompatible": 1, "missing_dependency": 2, "wrong_version": 3, +}; + +const lettersRegex = /^[a-zA-Z]+$/; + +export class ModDependency { + public type: ModDependencyType; + public name: string; + public version: ModVersionEquality | undefined; + + static getTypeFromPrefix(prefix: string): ModDependencyType { + switch (prefix) { + case "!": + return "incompatible"; + case "?": + return "optional"; + case "(?)": + return "hidden"; + case "~": + return "unordered"; + case "": + return "required"; + default: + throw new Error(`Invalid dependency prefix: ${prefix}`); + } + } + + constructor ( + public specification: string, + ) { + const parts = specification.split(" "); + + // Fix mods which include spaces in their name, joins consecutive words together + let index = 0; + while (index < parts.length - 1) { + const current = parts[index]; + const next = parts[index + 1]; + if (lettersRegex.test(current) && lettersRegex.test(next)) { + parts[index] = `${current} ${next}`; + parts.splice(index + 1, 1); + } else { + index += 1; + } + } + + // Parse the dependency specification + switch (parts.length) { + case 1: + this.type = "required"; + this.name = parts[0]; + break; + case 2: + this.type = ModDependency.getTypeFromPrefix(parts[0]); + this.name = parts[1]; + break; + case 3: + this.type = "required"; + this.name = parts[0]; + this.version = ModVersionEquality.fromParts(parts[1], parts[2]); + break; + case 4: + this.type = ModDependency.getTypeFromPrefix(parts[0]); + this.name = parts[1]; + this.version = ModVersionEquality.fromParts(parts[2], parts[3]); + break; + default: + throw new Error(`Invalid dependency specification: ${specification}`); + } + } + + checkUnsatisfiedReason(mods: (ModInfo | ModRecord)[]): ModDependencyUnsatisfiedReason | undefined { + const mod = mods.find(m => m.name === this.name); + if (mod === undefined) { + return ["unordered", "required"].includes(this.type) ? "missing_dependency" : undefined; + } else if (this.type === "incompatible") { + return "incompatible"; + } else if (this.version && !this.version.testVersion(mod.version)) { + return "wrong_version"; + } + return undefined; + } + + isSatisfied(mods: (ModInfo | ModRecord)[]) { + return this.checkUnsatisfiedReason(mods) === undefined; + } + + get incompatible() { + return this.type === "incompatible"; + } + + get required() { + return this.type === "unordered" || this.type === "required"; + } + + get optional() { + return this.type === "hidden" || this.type === "optional"; + } + + static jsonSchema = Type.String(); + + static fromJSON(json: Static) { + return new this(json); + } + + toJSON() { + return this.specification; + } +} /** * Info about a mod available on the controller. @@ -35,13 +154,13 @@ export default class ModInfo { * Version of the mod. * Sourced from info.json. */ - version = ""; + version = "0.0.0" as FullVersion; /** * Integer representation of the version */ get integerVersion() { - return integerModVersion(this.version); + return integerFullVersion(this.version); } /** @@ -78,21 +197,38 @@ export default class ModInfo { * Major version of Factorio this mod supports. * Sourced from info.json. */ - factorioVersion = "0.12"; + factorioVersion = "0.12" as "0.12" | ApiVersion; /** * Integer representation of the factorioVersion * @type {number} */ get integerFactorioVersion() { - return integerFactorioVersion(this.factorioVersion); + return integerPartialVersion(this.factorioVersion); } /** - * Dependiences for this mod. + * Dependencies for this mod. * Sourced from info.json. */ - dependencies = ["base"]; + dependencies = [new ModDependency("base")]; + + /** + * Dependencies for this mod. + * As they would be represented in info.json. + */ + get dependencySpecifications() { + return this.dependencies.map(d => d.specification); + } + + checkDependencySatisfaction(mods: (ModInfo | ModRecord)[]) { + return this.dependencies + .map(d => d.checkUnsatisfiedReason(mods)) + .filter((v): v is ModDependencyUnsatisfiedReason => Boolean(v)) + .reduce((max, current) => ( + !max || UnsatisfiedSeverity[max] > UnsatisfiedSeverity[current] ? current : max + ), undefined); + } /** * Expected name of zip file containing this mod. @@ -107,7 +243,7 @@ export default class ModInfo { * @param version - Mod's version * @returns string containing {name}_{version}.zip */ - static filename(name: string, version: string) { + static filename(name: string, version: FullVersion) { return `${name}_${version}.zip`; } @@ -137,13 +273,13 @@ export default class ModInfo { // Content of info.json found in mod files static infoJsonSchema = Type.Object({ "name": Type.String(), - "version": Type.String(), + "version": FullVersionSchema, "title": Type.String(), "author": Type.String(), "contact": Type.Optional(Type.String()), "homepage": Type.Optional(Type.String()), "description": Type.Optional(Type.String()), - "factorio_version": Type.Optional(Type.String()), + "factorio_version": Type.Optional(ApiVersionSchema), "dependencies": Type.Optional(Type.Array(Type.String())), }); @@ -172,7 +308,7 @@ export default class ModInfo { if (json.homepage) { modInfo.homepage = json.homepage; } if (json.description) { modInfo.description = json.description; } if (json.factorio_version) { modInfo.factorioVersion = json.factorio_version; } - if (json.dependencies) { modInfo.dependencies = json.dependencies; } + if (json.dependencies) { modInfo.dependencies = json.dependencies.map(d => new ModDependency(d)); } // Additional data if (json.size) { modInfo.size = json.size; } @@ -195,8 +331,8 @@ export default class ModInfo { if (this.homepage) { json.homepage = this.homepage; } if (this.description) { json.description = this.description; } if (this.factorioVersion !== "0.12") { json.factorio_version = this.factorioVersion; } - if (this.dependencies.length !== 1 || this.dependencies[0] !== "base") { - json.dependencies = this.dependencies; + if (this.dependencies.length !== 1 || this.dependencies[0].name !== "base") { + json.dependencies = this.dependencySpecifications; } if (this.size) { json.size = this.size; } if (this.mtimeMs) { json.mtime_ms = this.mtimeMs; } @@ -227,10 +363,6 @@ export default class ModInfo { throw new Error("Mod's info.json is not valid"); } - if (!modVersionRegExp.test(modInfo.version)) { - throw new Error(`Mod's version (${modInfo.version}) is invalid`); - } - const stat = await fs.stat(modPath); return this.fromJSON({ ...modInfo, diff --git a/packages/lib/src/data/ModPack.ts b/packages/lib/src/data/ModPack.ts index 74030b49..df3adf7d 100644 --- a/packages/lib/src/data/ModPack.ts +++ b/packages/lib/src/data/ModPack.ts @@ -5,9 +5,15 @@ import * as libSchema from "../schema"; import * as libLuaTools from "../lua_tools"; import ExportManifest from "./ExportManifest"; -import { integerFactorioVersion } from "./version"; import type { Logger } from "../logging"; +import ModInfo, { ModDependencyUnsatisfiedReason } from "./ModInfo"; + +import { + PartialVersion, PartialVersionSchema, integerPartialVersion, + FullVersion, FullVersionSchema, normaliseFullVersion, +} from "./version"; + export const ModSettingColor = Type.Object({ r: Type.Number(), @@ -40,17 +46,21 @@ export interface ModRecord { /** if mod is to be loaded. */ enabled: boolean, /** version of the mod. */ - version: string, + version: FullVersion, /** SHA1 hash of the zip file. */ sha1?: string, /** Used inside packages\web_ui\src\components\ModPackViewPage.tsx to define an error type. */ - error?: "missing"|"bad_checksum", + error?: "missing" | "bad_checksum", + /** Used inside packages\web_ui\src\components\ModPackViewPage.tsx to define an warning type. */ + warning?: ModDependencyUnsatisfiedReason | "wrong_factorio_version", + /** Used inside packages\web_ui\src\components\ModPackViewPage.tsx when there is no error. */ + info?: ModInfo, } const ModRecordJsonSchema = Type.Object({ "name": Type.String(), "enabled": Type.Boolean(), - "version": Type.String(), + "version": FullVersionSchema, "sha1": Type.Optional(Type.String()), }); @@ -82,13 +92,13 @@ export default class ModPack { /** * Version of Factorio this mod pack is for */ - factorioVersion = "1.1.0"; + factorioVersion = "1.1.0" as PartialVersion; /** * Integer representation of the factorioVersion */ get integerFactorioVersion() { - return integerFactorioVersion(this.factorioVersion); + return integerPartialVersion(this.factorioVersion); } /** @@ -136,7 +146,7 @@ export default class ModPack { "id": Type.Optional(Type.Integer()), "name": Type.String(), "description": Type.String(), - "factorio_version": Type.String(), + "factorio_version": PartialVersionSchema, "mods": Type.Array(ModRecordJsonSchema), "settings": Type.Object({ "startup": ModSettingsJsonSchema, @@ -385,39 +395,40 @@ export default class ModPack { /** Array of default mod packs which should exist on a newly installed cluster */ static defaultModPacks = [ - ...["0.17", "0.18", "1.0", "1.1", "2.0"] + ...(["0.17", "0.18", "1.0", "1.1", "2.0"] as const) .map(version => this.fromJSON({ name: `Base Game ${version}`, description: `Factorio ${version} with no extra mods.`, factorio_version: version, } as any)), - ...["2.0"] + ...(["2.0"] as const) .map(version => this.fromJSON({ name: `Space Age ${version}`, description: `Factorio ${version} with Space Age expansion.`, factorio_version: version, mods: [ - { name: "elevated-rails", enabled: true, version }, - { name: "quality", enabled: true, version }, - { name: "space-age", enabled: true, version }, + { name: "elevated-rails", enabled: true, version: normaliseFullVersion(version) }, + { name: "quality", enabled: true, version: normaliseFullVersion(version) }, + { name: "space-age", enabled: true, version: normaliseFullVersion(version) }, ], } as any)), ]; /** - * Returns an array of ModInfo containing all mods included in factorio + * Returns an array of ModRecord containing all mods included in factorio * * @param factorioVersion - Factorio version to get the default mods for * @return Built in mods for the given version */ - static getBuiltinMods(factorioVersion: string) { + static getBuiltinMods(factorioVersion: PartialVersion) { + factorioVersion = normaliseFullVersion(factorioVersion); let defaultMods: ModRecord[] = [ // "core" not included because core cannot be disabled { name: "base", enabled: true, version: factorioVersion }, ]; - const integerVersion = integerFactorioVersion(factorioVersion); - if (integerVersion >= integerFactorioVersion("1.2")) { + const integerVersion = integerPartialVersion(factorioVersion); + if (integerVersion >= integerPartialVersion("1.2")) { defaultMods = defaultMods.concat([ { name: "elevated-rails", enabled: false, version: factorioVersion }, { name: "quality", enabled: false, version: factorioVersion }, @@ -428,7 +439,7 @@ export default class ModPack { return defaultMods; } - static getBuiltinModNames(factorioVersion: string) { + static getBuiltinModNames(factorioVersion: PartialVersion) { return this.getBuiltinMods(factorioVersion).map(builtinMod => builtinMod.name); } } diff --git a/packages/lib/src/data/index.ts b/packages/lib/src/data/index.ts index cacc1211..5adf08e6 100644 --- a/packages/lib/src/data/index.ts +++ b/packages/lib/src/data/index.ts @@ -4,7 +4,7 @@ * @author Hornwitser */ export { default as ExportManifest } from "./ExportManifest"; -export { default as ModInfo } from "./ModInfo"; +export { default as ModInfo, ModDependency } from "./ModInfo"; export { ModSettingColor } from "./ModPack"; export type { ModSetting, ModRecord } from "./ModPack"; export { default as ModPack } from "./ModPack"; diff --git a/packages/lib/src/data/messages_instance.ts b/packages/lib/src/data/messages_instance.ts index 08119562..a0a251d2 100644 --- a/packages/lib/src/data/messages_instance.ts +++ b/packages/lib/src/data/messages_instance.ts @@ -5,6 +5,7 @@ import { InstanceConfig } from "../config"; import type { IControllerUser } from "./User"; import type { MessageRequest } from "./messages_core"; import { CollectorResultSerialized } from "../prometheus"; +import { TargetVersion, TargetVersionSchema, PartialVersion, PartialVersionSchema } from "./version"; export const InstanceStatus = StringEnum([ @@ -42,7 +43,7 @@ export class InstanceDetails { public assignedHost: number | undefined, public gamePort: number | undefined, public status: InstanceStatus, - public factorioVersion: string | undefined, + public factorioVersion: TargetVersion | undefined, /** Millisecond Unix timestamp this entry was last updated at */ public updatedAtMs = 0, public excludeFromStartAll = false, @@ -57,7 +58,7 @@ export class InstanceDetails { "unknown", "unassigned", "stopped", "starting", "running", "stopping", "creating_save", "exporting_data", "deleted", ]), - "factorioVersion": Type.Optional(Type.String()), + "factorioVersion": Type.Optional(TargetVersionSchema), "updatedAtMs": Type.Optional(Type.Number()), "excludeFromStartAll": Type.Optional(Type.Boolean()), }); @@ -744,8 +745,8 @@ export class HostInstanceUpdate { constructor( public config: Static, public status: InstanceStatus, - public gamePort: undefined | number, - public factorioVersion: undefined | string, + public gamePort: number | undefined, + public factorioVersion: PartialVersion | undefined, ) { } static jsonSchema = Type.Object({ @@ -754,7 +755,7 @@ export class HostInstanceUpdate { "stopped", "starting", "running", "stopping", "creating_save", "exporting_data", ]), "gamePort": Type.Optional(Type.Number()), - "factorioVersion": Type.Optional(Type.String()), + "factorioVersion": Type.Optional(PartialVersionSchema), }); static fromJSON(json: Static) { @@ -852,7 +853,7 @@ export class InstanceStatusChangedEvent { public instanceId: number, public status: InstanceStatus, public gamePort?: number, - public factorioVersion?: string, + public factorioVersion?: TargetVersion, ) { } static jsonSchema = Type.Object({ @@ -861,7 +862,7 @@ export class InstanceStatusChangedEvent { "stopped", "starting", "running", "stopping", "creating_save", "exporting_data", ]), "gamePort": Type.Optional(Type.Integer()), - "factorioVersion": Type.Optional(Type.String()), + "factorioVersion": Type.Optional(TargetVersionSchema), }); static fromJSON(json: Static) { diff --git a/packages/lib/src/data/messages_mod.ts b/packages/lib/src/data/messages_mod.ts index b5a4d17a..dc147b3d 100644 --- a/packages/lib/src/data/messages_mod.ts +++ b/packages/lib/src/data/messages_mod.ts @@ -1,8 +1,14 @@ import { Type, Static } from "@sinclair/typebox"; -import ModInfo from "./ModInfo"; +import ModInfo, { ModDependency } from "./ModInfo"; import ModPack from "./ModPack"; import { JsonString, jsonArray } from "./composites"; +import { + FullVersion, FullVersionSchema, + ApiVersion, ApiVersionSchema, normaliseApiVersion, + ModVersionEquality, +} from "./version"; + export class ModPackGetRequest { declare ["constructor"]: typeof ModPackGetRequest; @@ -113,13 +119,13 @@ export class ModGetRequest { constructor( public name: string, - public version: string, + public version: FullVersion, public sha1?: string, ) { } static jsonSchema = Type.Object({ "name": Type.String(), - "version": Type.String(), + "version": FullVersionSchema, "sha1": Type.Optional(Type.String()), }); @@ -148,7 +154,7 @@ export class ModSearchRequest { constructor( public query: string, - public factorioVersion: string, + public factorioVersion: ApiVersion, public page: number, public pageSize?: number, public sort?: string, @@ -157,7 +163,7 @@ export class ModSearchRequest { static jsonSchema = Type.Object({ "query": Type.String(), - "factorioVersion": Type.String(), + "factorioVersion": ApiVersionSchema, "page": Type.Integer(), "pageSize": Type.Optional(Type.Integer()), "sort": Type.Optional(Type.String()), @@ -199,9 +205,9 @@ export class ModSearchRequest { // Define the structure for the latest release info from the portal export const ModPortalReleaseSchema = Type.Object({ - version: Type.String(), + version: FullVersionSchema, // Match the structure from ModStore's ModRelease/ModDetails - info_json: Type.Object({ factorio_version: Type.String() }), + info_json: Type.Object({ factorio_version: ApiVersionSchema }), released_at: Type.String(), // ISO 8601 date string download_url: Type.String(), file_name: Type.String(), @@ -229,20 +235,13 @@ export class ModPortalGetAllRequest { static dst = "controller" as const; static permission = "core.mod.search_portal" as const; - // Define allowed Factorio versions - static allowedVersions = [ - Type.Literal("0.13"), Type.Literal("0.14"), Type.Literal("0.15"), - Type.Literal("0.16"), Type.Literal("0.17"), Type.Literal("0.18"), - Type.Literal("1.0"), Type.Literal("1.1"), Type.Literal("2.0"), - ] as const; - constructor( - public factorioVersion: Static, + public factorioVersion: ApiVersion, public hide_deprecated?: boolean, ) { } static jsonSchema = Type.Object({ - "factorioVersion": Type.Union([...this.allowedVersions]), // Use Union for validation + "factorioVersion": ApiVersionSchema, "hide_deprecated": Type.Optional(Type.Boolean()), }); @@ -277,13 +276,13 @@ export class ModDownloadRequest { constructor( public name: string, - public version: string, + public version: FullVersion, public sha1?: string, ) { } static jsonSchema = Type.Object({ "name": Type.String(), - "version": Type.String(), + "version": FullVersionSchema, "sha1": Type.Optional(Type.String()), }); @@ -303,12 +302,12 @@ export class ModDeleteRequest { constructor( public name: string, - public version: string, + public version: FullVersion, ) { } static jsonSchema = Type.Object({ "name": Type.String(), - "version": Type.String(), + "version": FullVersionSchema, }); static fromJSON(json: Static) { @@ -356,6 +355,16 @@ export class ModUpdatesEvent { } } +export interface ModNameVersionPair { + name: string, + version: ModVersionEquality, +} + +export const ModNameVersionPairSchema = Type.Object({ + "name": Type.String(), + "version": ModVersionEquality.jsonSchema, +}); + /** * Request mods to be downloaded from the Factorio mod portal to the controller. * @@ -363,8 +372,7 @@ export class ModUpdatesEvent { * Requires Factorio credentials to be configured on the controller if the portal * requires authentication for downloads. * - * @param name - Name of the mod to download. - * @param version - Version of the mod to download. + * @param mods - Array of mods to be downloaded. * @param factorioVersion - Factorio version context for the download. */ export class ModPortalDownloadRequest { @@ -373,21 +381,93 @@ export class ModPortalDownloadRequest { static src = "control" as const; static dst = "controller" as const; static permission = "core.mod.download_from_portal" as const; - static Response = ModInfo; + static Response = jsonArray(ModInfo); constructor( - public name: string, - public version: string, - public factorioVersion: string, + public mods: ModNameVersionPair[], + public factorioVersion: ApiVersion, ) { } static jsonSchema = Type.Object({ - "name": Type.String(), - "version": Type.String(), - "factorioVersion": Type.String(), + "mods": Type.Array(ModNameVersionPairSchema), + "factorioVersion": ApiVersionSchema, }); static fromJSON(json: Static) { - return new this(json.name, json.version, json.factorioVersion); + return new this( + json.mods.map(mod => ({ + name: mod.name, version: ModVersionEquality.fromJSON(mod.version), + })), + json.factorioVersion, + ); } } + +/** + * Request mods dependencies to be resolved using the Factorio mod portal. + * + * @param mods - Array of dependencies to resolve. + * @param factorioVersion - Factorio version context for resolution. + */ +export class ModDependencyResolveRequest { + declare ["constructor"]: typeof ModDependencyResolveRequest; + static type = "request" as const; + static src = "control" as const; + static dst = "controller" as const; + static permission = "core.mod.search_portal" as const; + + constructor( + public mods: ModDependency[], + public factorioVersion: ApiVersion, + public checkForUpdates: boolean = false, + ) { } + + static jsonSchema = Type.Object({ + "mods": Type.Array(ModDependency.jsonSchema), + "factorioVersion": ApiVersionSchema, + "checkForUpdates": Type.Boolean(), + }); + + static fromJSON(json: Static) { + return new this(json.mods.map(spec => new ModDependency(spec)), json.factorioVersion, json.checkForUpdates); + } + + static fromModPack(modPack: ModPack, checkForUpdates?: boolean) { + const equality = checkForUpdates ? ">=" : "="; + return new this( + [...modPack.mods.values()] + .map(mod => new ModDependency(`${mod.name} ${equality} ${mod.version}`)), + normaliseApiVersion(modPack.factorioVersion), + checkForUpdates, + ); + } + + static fromModPackEnabled(modPack: ModPack, checkForUpdates?: boolean) { + const equality = checkForUpdates ? ">=" : "="; + return new this( + [...modPack.mods.values()] + .filter(mod => mod.enabled) + .map(mod => new ModDependency(`${mod.name} ${equality} ${mod.version}`)), + normaliseApiVersion(modPack.factorioVersion), + checkForUpdates, + ); + } + + static Response = class ModDependencyResolveResponse { + constructor( + public dependencies: ModInfo[], + public incompatible: string[], + public missing: string[], + ) {} + + static jsonSchema = Type.Object({ + "dependencies": Type.Array(ModInfo.jsonSchema), + "incompatible": Type.Array(Type.String()), + "missing": Type.Array(Type.String()), + }); + + static fromJSON(json: Static) { + return new this(json.dependencies.map(d => ModInfo.fromJSON(d)), json.incompatible, json.missing); + } + }; +} diff --git a/packages/lib/src/data/version.ts b/packages/lib/src/data/version.ts index bd79bece..882992b2 100644 --- a/packages/lib/src/data/version.ts +++ b/packages/lib/src/data/version.ts @@ -1,33 +1,241 @@ // Helper functions for dealing with factorio version numbers +import { Static, Type } from "@sinclair/typebox"; /** - * Integer representation of a mod version + * Integer representation of a version string. * * Suitable for sorting and comparing version numbers which may have different * number of leading zeros in them. - * @param version - string representation of version. - * @returns numeric representation. */ -export function integerModVersion(version: string) { +export type IntegerVersion = number & { [integerVersionSymbol]: void }; +declare const integerVersionSymbol: unique symbol; + +/** + * Allowed version equalities as defined within mod info json + * https://lua-api.factorio.com/latest/auxiliary/mod-structure.html#Dependency + */ +const versionEqualities = ["<", "<=", "=", ">=", ">"] as const; +export const VersionEqualitySchema = Type.Union(versionEqualities.map(v => Type.Literal(v))); +export type VersionEquality = Static; + +export function isVersionEquality(input: string): input is VersionEquality { + return versionEqualities.includes(input as any); +} + +/** + * Matches valid factorio versions accepts by the web API + * https://wiki.factorio.com/Mod_portal_API#/api/mods (see version enum) + */ +export const ApiVersions = ["0.13", "0.14", "0.15", "0.16", "0.17", "0.18", "1.0", "1.1", "2.0"] as const; +export const ApiVersionSchema = Type.Union(ApiVersions.map(v => Type.Literal(v))); +export type ApiVersion = Static; + +export function isApiVersion(input: string): input is ApiVersion { + return ApiVersions.includes(input as any); +} + +export function normaliseApiVersion(version: PartialVersion) { + const parts = version.split("."); + const apiVersion = `${parts[0]}.${parts[1]}`; + if (!isApiVersion(apiVersion)) { + throw new Error(`Factorio version not supported by mod portal: ${apiVersion}`); + } + return apiVersion; +} + +/** + * Matches valid mod versions, where all parts are specified. + */ +const fullVersionRegExp = /^\d+\.\d+\.\d+$/; +export type FullVersion = `${number}.${number}.${number}`; +export const FullVersionSchema = Type.Unsafe( + Type.String({ pattern: fullVersionRegExp.source }) +); + +export function isFullVersion(input: string): input is FullVersion { + return fullVersionRegExp.test(input); +} + +export function integerFullVersion(version: FullVersion) { const [major, minor, sub] = version.split(".").map(n => Number.parseInt(n, 10)); - return major * 0x100000000 + minor * 0x10000 + sub; // Can't use bitwise here because this is 48-bits. + // Can't use bitwise here because this is 48-bits. + return major * 0x100000000 + minor * 0x10000 + sub as IntegerVersion; +} + +export function normaliseFullVersion(version: PartialVersion) { + const parts = version.split("."); + return (parts.length === 3 ? version : `${version}.0`) as FullVersion; } /** - * Integer representation of a factorio version - * - * Suitable for sorting and comparing version numbers which may have different - * number of leading zeros in them. - * @param version - string representation of version. - * @returns numeric representation. + * Matches valid factorio versions and mod dependencies specifications where 2 or 3 parts are specified. */ -export function integerFactorioVersion(version: string) { - const [main, major, minor] = version.split(".").map(n => Number.parseInt(n, 10)); - return main * 0x100000000 + major * 0x10000 + (minor || 0); // Can't use bitwise here because this is 48-bits. +const partialVersionRegExp = /^\d+\.\d+(?:\.\d+)?$/; +export type PartialVersion = FullVersion | `${number}.${number}`; +export const PartialVersionSchema = Type.Unsafe( + Type.String({ pattern: partialVersionRegExp.source }) +); + +export function isPartialVersion(input: string): input is PartialVersion { + return partialVersionRegExp.test(input); +} + +export function integerPartialVersion(version: PartialVersion) { + const [major, minor, sub] = version.split(".").map(n => Number.parseInt(n, 10)); + // Can't use bitwise here because this is 48-bits. sub is optional and defaults to 0. + return major * 0x100000000 + minor * 0x10000 + (sub || 0) as IntegerVersion; } /** - * Matches valid mod versions. + * Matches valid factorio target versions, this is the same as partial but also accepts "latest". */ -export const modVersionRegExp = /^\d+\.\d+\.\d+$/; +export type TargetVersion = PartialVersion | "latest"; +export const TargetVersionSchema = Type.Union([ + PartialVersionSchema, + Type.Literal("latest"), +]); + +export function isTargetVersion(input: string): input is TargetVersion { + return input === "latest" || partialVersionRegExp.test(input); +} + +/** + * Represents a mod version paired with an equality which can be tested against + */ +export class ModVersionEquality { + public integerVersion: IntegerVersion; + + constructor( + public equality: VersionEquality, + public version: PartialVersion, + ) { + this.integerVersion = integerPartialVersion(version); + if (!isVersionEquality(this.equality)) { + throw new Error("Invalid equality"); + } + if (!isPartialVersion(this.version)) { + throw new Error("Invalid version"); + } + } + + testIntegerVersion(other: IntegerVersion) { + switch (this.equality) { + case "<": + return other < this.integerVersion; + case "<=": + return other <= this.integerVersion; + case "=": + return other === this.integerVersion; + case ">=": + return other >= this.integerVersion; + case ">": + return other > this.integerVersion; + default: + throw new Error("unreachable"); + } + } + + testVersion(version: PartialVersion) { + return this.testIntegerVersion(integerPartialVersion(version)); + } + + toString() { + return `${this.equality} ${this.version}`; + } + + static fromString(version: string) { + const parts = version.split(" "); + return parts.length === 1 ? this.fromParts("=", parts[0]) : this.fromParts(parts[0], parts[1]); + } + + static fromParts(equality: string, version: string) { + if (!isVersionEquality(equality)) { + throw new Error(`Unknown version equality: ${equality}`); + } + if (!isPartialVersion(version)) { + throw new Error(`Malformed version string: ${version}`); + } + return new this(equality, version); + } + + static jsonSchema = Type.String(); + + static fromJSON(json: Static) { + return this.fromString(json); + } + + toJSON() { + return this.toString(); + } +} + +/** + * Represents a range of mod versions which can be tested against + */ +export class ModVersionRange { + constructor( + public minVersion = new ModVersionEquality(">=", "0.0.0"), + public maxVersion = new ModVersionEquality("<=", "65535.65535.65535"), + ) { + if (minVersion.equality === "<" || minVersion.equality === "<=") { + throw new Error("Minimum version can not use < or <="); + } + if (maxVersion.equality === ">" || maxVersion.equality === ">=") { + throw new Error("Maximum version can not use > or >="); + } + } + + get valid() { + return this.minVersion.testIntegerVersion(this.maxVersion.integerVersion) + && this.maxVersion.testIntegerVersion(this.minVersion.integerVersion); + } + + invalidate() { + this.maxVersion = new ModVersionEquality(">", "0.0.0"); + } + + testIntegerVersion(other: IntegerVersion) { + return this.minVersion.testIntegerVersion(other) && this.maxVersion.testIntegerVersion(other); + } + + testVersion(version: PartialVersion) { + return this.testIntegerVersion(integerPartialVersion(version)); + } + + combineVersion(other: ModVersionEquality) { + switch (other.equality) { + case "<": + if (other.integerVersion <= this.maxVersion.integerVersion) { + this.maxVersion = other; + } + break; + case "<=": + if (other.integerVersion < this.maxVersion.integerVersion) { + this.maxVersion = other; + } + break; + case "=": + if (this.testIntegerVersion(other.integerVersion)) { + this.minVersion = other; + this.maxVersion = other; + } else { + // Make invalid as it could not be satisfied + this.invalidate(); + } + break; + case ">=": + if (other.integerVersion > this.minVersion.integerVersion) { + this.minVersion = other; + } + break; + case ">": + if (other.integerVersion >= this.minVersion.integerVersion) { + this.minVersion = other; + } + break; + default: + throw new Error("unreachable"); + } + } +} diff --git a/packages/lib/src/link/messages.ts b/packages/lib/src/link/messages.ts index 65505bac..6e29a75f 100644 --- a/packages/lib/src/link/messages.ts +++ b/packages/lib/src/link/messages.ts @@ -108,6 +108,7 @@ export const dataClasses: (RequestClass | EventClass) mod.ModPackUpdatesEvent, mod.ModUpdatesEvent, mod.ModPortalDownloadRequest, + mod.ModDependencyResolveRequest, role.PermissionListRequest, role.RoleListRequest, diff --git a/packages/web_ui/src/components/InstanceList.tsx b/packages/web_ui/src/components/InstanceList.tsx index 4e756b84..46b75f9d 100644 --- a/packages/web_ui/src/components/InstanceList.tsx +++ b/packages/web_ui/src/components/InstanceList.tsx @@ -37,10 +37,10 @@ export default function InstanceList(props: InstanceListProps) { } function integerFactorioVersionOrDefault(instance: lib.InstanceDetails) { - if (instance.factorioVersion === undefined) { + if (instance.factorioVersion === undefined || instance.factorioVersion === "latest") { return -1; } - return lib.integerFactorioVersion(instance.factorioVersion); + return lib.integerPartialVersion(instance.factorioVersion); } let columns: ColumnsType = [ diff --git a/packages/web_ui/src/components/ModDetails.tsx b/packages/web_ui/src/components/ModDetails.tsx index 973480fb..1403bf6c 100644 --- a/packages/web_ui/src/components/ModDetails.tsx +++ b/packages/web_ui/src/components/ModDetails.tsx @@ -1,6 +1,9 @@ import React, { Fragment } from "react"; -import { Descriptions, Grid, Typography } from "antd"; +import { Descriptions, Grid, Tooltip, Typography, Space } from "antd"; import ExclamationCircleOutlined from "@ant-design/icons/ExclamationCircleOutlined"; +import FileUnknownOutlined from "@ant-design/icons/FileUnknownOutlined"; +import FileExclamationOutlined from "@ant-design/icons/FileExclamationOutlined"; +import FileSyncOutlined from "@ant-design/icons/FileSyncOutlined"; import * as lib from "@clusterio/lib"; @@ -8,11 +11,25 @@ const { useBreakpoint } = Grid; type ModDetailsProps = { mod: T; + mods?: lib.ModRecord[]; actions?: (mod: T) => React.JSX.Element; }; + export default function ModDetails(props: ModDetailsProps) { let screens = useBreakpoint(); - const mod: Partial & Partial = props.mod; + let mod: Partial & Partial = props.mod; + + if ("info" in mod) { + // Add the mod info if present in a mod record + mod = { ...mod, ...mod.info }; + } + + const depWarnings = new Map(); + if (mod.dependencies && props.mods) { + for (const dependency of mod.dependencies) { + depWarnings.set(dependency.name, dependency.checkUnsatisfiedReason(props.mods)); + } + } return (props: {Boolean(mod.homepage) && {mod.homepage}} {mod.name} {Boolean(mod.factorioVersion) - && {mod.factorioVersion} + && + + {mod.warning === "wrong_factorio_version" && + {" "} + } + {mod.factorioVersion} + + } {Boolean(mod.filename) && {mod.filename}} {mod.size !== undefined @@ -62,8 +86,22 @@ export default function ModDetails(props: {mod.dependencies && mod.dependencies.length && {mod.dependencies - .filter(e => !e.startsWith("(?)")) - .map((e, i) => {e}
) + .filter(e => e.type !== "hidden") + .map((e, i) => + + {depWarnings.get(e.name) === "incompatible" && + {" "} + } + {depWarnings.get(e.name) === "missing_dependency" && + {" "} + } + {depWarnings.get(e.name) === "wrong_version" && + {" "} + } + {e.specification} + +
+
) }
} diff --git a/packages/web_ui/src/components/ModPackViewPage.tsx b/packages/web_ui/src/components/ModPackViewPage.tsx index fd41acb2..bc60f4c7 100644 --- a/packages/web_ui/src/components/ModPackViewPage.tsx +++ b/packages/web_ui/src/components/ModPackViewPage.tsx @@ -1,19 +1,19 @@ import React, { Fragment, memo, useCallback, useEffect, useContext, useRef, useState } from "react"; import { useNavigate, useParams } from "react-router-dom"; +import { FieldData } from "rc-field-form/lib/interface"; + import { Button, Card, ColorPicker, Checkbox, Col, ConfigProvider, Descriptions, Form, Input, Pagination, Popconfirm, Row, Table, Tag, Typography, Select, Skeleton, Space, Spin, Switch, Modal, Tooltip, - TablePaginationConfig, + TablePaginationConfig, Alert, notification, } from "antd"; -import { FieldData } from "rc-field-form/lib/interface"; + +import { + ExportOutlined, FileUnknownOutlined, FileExclamationOutlined, FileSyncOutlined, + CloseOutlined, DeleteOutlined, ToolOutlined, PlusOutlined, CloudSyncOutlined, CloudDownloadOutlined, +} from "@ant-design/icons"; import type { SorterResult, FilterValue, TableCurrentDataSource } from "antd/es/table/interface"; -import ExportOutlined from "@ant-design/icons/ExportOutlined"; -import FileUnknownOutlined from "@ant-design/icons/FileUnknownOutlined"; -import FileExclamationOutlined from "@ant-design/icons/FileExclamationOutlined"; -import CloseOutlined from "@ant-design/icons/CloseOutlined"; -import DeleteOutlined from "@ant-design/icons/DeleteOutlined"; -import PlusOutlined from "@ant-design/icons/PlusOutlined"; import * as lib from "@clusterio/lib"; import ControlContext from "./ControlContext"; @@ -28,16 +28,20 @@ import SectionHeader from "./SectionHeader"; import ModDetails from "./ModDetails"; const { logger } = lib; -const { Paragraph, Text } = Typography; +const { Text } = Typography; const strcmp = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" }).compare; - type ModChange = { - type: "name" | "description" | "factorioVersion", + type: "name" | "description" name?: never, scope?: never, value: string, + } | { + type: "factorioVersion", + name?: never, + scope?: never, + value: lib.PartialVersion, } | { type: "settings.set" | "settings.delete", name: string, @@ -47,7 +51,7 @@ type ModChange = type: "mods.set" | "mods.delete", name: string, scope?: never, - value: lib.ModRecord | lib.ModInfo, + value: lib.ModRecord, } ; @@ -71,28 +75,38 @@ function SearchModsTable(props: SearchModsTableProps) { const [modResultPageSize, setModResultPageSize] = useState(10); const [modResultCount, setModResultCount] = useState(2); const [modResultSelectedVersion, setModResultSelectedVersion] = useState>(new Map()); + const [factorioVersion, setFactorioVersion] = useState(null); - const factorioVersion = props.modPack.factorioVersion.split(".").slice(0, 2).join("."); + // Get a valid factorio version + useEffect(() => { + try { + setFactorioVersion(lib.normaliseApiVersion(props.modPack.factorioVersion)); + } catch (err) { + setFactorioVersion(null); + } + }, [props.modPack.factorioVersion]); useEffect(() => { let canceled = false; - control.send(new lib.ModSearchRequest( - searchText, - factorioVersion, - modResultPage, - modResultPageSize, - modResultSort, - modResultSortOrder, - )).then(response => { - if (canceled) { - return; - } - // In React < v18 this causes 3 renders and a partial state necessitating the use - // of || 0 on modResultSelectedVersion.get() calls. Remove when updating React. - setModResults(response.results); - setModResultSelectedVersion(new Map(response.results.map(({ name, versions }) => [name, 0]))); - setModResultCount(response.resultCount); - }); + if (factorioVersion) { + control.send(new lib.ModSearchRequest( + searchText, + factorioVersion, + modResultPage, + modResultPageSize, + modResultSort, + modResultSortOrder, + )).then(response => { + if (canceled) { + return; + } + // In React < v18 this causes 3 renders and a partial state necessitating the use + // of || 0 on modResultSelectedVersion.get() calls. Remove when updating React. + setModResults(response.results); + setModResultSelectedVersion(new Map(response.results.map(({ name, versions }) => [name, 0]))); + setModResultCount(response.resultCount); + }); + } return () => { canceled = true; @@ -239,19 +253,260 @@ function SearchModsTable(props: SearchModsTableProps) { ; } +type DownloadDependenciesProps = { + disabled?: boolean; + checkForUpdates?: boolean; + modPack: lib.ModPack; + mods: lib.ModRecord[]; + onChange: (change: ModChange) => void; + builtInModNames: string[]; +} +function DownloadDependenciesButton(props: DownloadDependenciesProps) { + const control = useContext(ControlContext); + const [modInfos] = useMods(); + + // State for the modal and dependencies + const [open, setOpen] = useState(false); + const [missing, setMissing] = useState([]); + const [builtins, setBuiltins] = useState([]); + const [incompatible, setIncompatible] = useState([]); + const [factorioVersion, setFactorioVersion] = useState(null); + + // State for all mods fetched from backend + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); + const [allMods, setAllMods] = useState([]); + const [mods, setMods] = useState([]); + + // Get a valid factorio version + useEffect(() => { + try { + setFactorioVersion(lib.normaliseApiVersion(props.modPack.factorioVersion)); + } catch (err) { + setError(new Error(`Invalid factorio version: ${props.modPack.factorioVersion}`)); + setFactorioVersion(null); + } + }, [props.modPack.factorioVersion]); + + // Fetch all mods from backend when modal opens or version changes + useEffect(() => { + if (!open || !factorioVersion) { + // Clear if closed or no version + setMods([]); + setAllMods([]); + setMissing([]); + setBuiltins([]); + setIncompatible([]); + return; + } + + setLoading(true); + setError(null); + let canceled = false; + + control.send( + lib.ModDependencyResolveRequest.fromModPackEnabled(props.modPack, props.checkForUpdates) + ).then(response => { + if (canceled) { return; } + setMods(response.dependencies.filter(dep => !modInfos.has(`${dep.name}_${dep.version}`))); + setIncompatible(response.incompatible); + setAllMods(response.dependencies); + setMissing(response.missing.filter(name => !props.builtInModNames.includes(name))); + setBuiltins(response.missing.filter(name => props.builtInModNames.includes(name))); + setLoading(false); + }).catch(err => { + if (canceled) { return; } + notifyErrorHandler("Error fetching mods dependencies")(err); + setError(err); + setMods([]); + setAllMods([]); + setMissing([]); + setBuiltins([]); + setIncompatible([]); + setLoading(false); + }); + + // eslint-disable-next-line consistent-return + return () => { + canceled = true; + }; + // Re-fetch when modal opens or factorio version changes + }, [open, factorioVersion, control]); + + // Function to handle download from Mod Portal to Controller + function handleControllerDownload() { + if (!factorioVersion || !mods) { + return; + } + + // Enable any required builtins + for (const name of builtins) { + props.onChange({ + type: "mods.set", + name: name, + value: { ...props.modPack.mods.get(name)!, enabled: true }, + }); + } + + // Disable any incompatible mods present + for (const name of incompatible) { + const modRecord = props.modPack.mods.get(name); + if (modRecord) { + props.onChange({ + type: "mods.set", + name: name, + value: { ...modRecord, enabled: false }, + }); + } + } + + // Add all dependencies to the mod pack + for (const mod of allMods) { + props.onChange({ + type: "mods.set", + name: mod.name, + value: { + name: mod.name, + version: mod.version, + sha1: mod.sha1, + enabled: true, + }, + }); + } + + // Download the missing dependencies + control.send( + new lib.ModPortalDownloadRequest( + mods.map(mod => ({ + name: mod.name, version: new lib.ModVersionEquality("=", mod.version), + })), + factorioVersion, + ) + ).then(() => { + notification.success({ + message: "Download complete", + description: `${mods.length} mods have been downloaded to the controller.`, + }); + }).catch( + notifyErrorHandler("Error starting mod download") + ); + }; + + return <> + {props.checkForUpdates && || } + { setOpen(false); }} + width={1000} + footer={[ + , + , + ]} + > + {/* Display error message if fetching failed */} + {error && } + + {/* Display warning message if dependencies could not be found */} + {!loading && missing.length > 0 && + {missing.map(name =>
  • {name}
  • )} + } + type="warning" + showIcon + style={{ marginBottom: 16 }} + />} + + {/* Display warning message if dependencies are incompatible */} + {!loading && incompatible.length > 0 && + {incompatible.map(name =>
  • {name}
  • )} + } + type="warning" + showIcon + style={{ marginBottom: 16 }} + />} + + record.name} + loading={loading} + pagination={{ + defaultPageSize: 10, + showSizeChanger: true, + pageSizeOptions: ["10", "20", "50", "100"], + showTotal: (total: number) => ( + props.checkForUpdates ? `${total} Updates` : `${total} Missing Dependencies` + ), + }} + columns={[ + { + title: "Name", + dataIndex: "name", + key: "name", + sorter: true, + }, + { + title: "Title", + dataIndex: "title", + key: "title", + sorter: true, + }, + { + title: "Author", + dataIndex: "author", + key: "author", + sorter: true, + }, + { + title: "Selected Version", + dataIndex: "version", + key: "version", + }, + ]} + /> + + ; +} + type ModsTableProps = { modPack: lib.ModPack; changes: ModChange[]; onChange: (change: ModChange) => void; onRevert: (change: ModChange) => void; - builtInMods: string[]; + builtInModNames: string[]; }; function ModsTable(props: ModsTableProps) { - const [showAddMods, setShowAddMods] = useState(false); - const [modsMap] = useMods(); + const account = useAccount(); + const [modInfos] = useMods(); - const deletedMods: Map = new Map(); - const changedMods: Map = new Map(); + const [showAddMods, setShowAddMods] = useState(false); + + const deletedMods: Map = new Map(); + const changedMods: Map = new Map(); for (let change of props.changes) { if (change.name) { if (props.modPack.mods.has(change.name)) { @@ -265,34 +520,119 @@ function ModsTable(props: ModsTableProps) { } const mods = [...props.modPack.mods.values(), ...deletedMods.values()].map( - (mod: lib.ModRecord|lib.ModInfo): lib.ModInfo|lib.ModRecord => { - if (props.builtInMods.includes(mod.name)) { + (mod: lib.ModRecord): lib.ModRecord => { + if (props.builtInModNames.includes(mod.name)) { return { ...mod, - enabled: (mod as any).enabled ?? false, + enabled: mod.enabled ?? false, }; } - const candidate = modsMap.get(`${mod.name}_${mod.version}`); - if (!candidate) { + const modInfo = modInfos.get(`${mod.name}_${mod.version}`); + if (!modInfo) { return { ...mod, - enabled: (mod as any).enabled ?? false, + enabled: mod.enabled ?? false, error: "missing", }; - } else if (mod.sha1 && candidate.sha1 !== mod.sha1) { + } else if (mod.sha1 && modInfo.sha1 !== mod.sha1) { return { ...mod, - enabled: (mod as any).enabled ?? false, + enabled: mod.enabled ?? false, error: "bad_checksum", }; } - return candidate; + return { + ...mod, + enabled: mod.enabled ?? false, + info: modInfo, + }; } ); - function actions(mod: lib.ModRecord|lib.ModInfo) { + for (const mod of mods) { + if (mod.enabled && mod.info) { + mod.warning = mod.info.checkDependencySatisfaction(mods.filter(m => m.enabled)); + try { + const packFactorioVersion = lib.normaliseApiVersion(props.modPack.factorioVersion); + const modFactorioVersion = lib.normaliseApiVersion(mod.info.factorioVersion); + if (packFactorioVersion !== modFactorioVersion) { + mod.warning = "wrong_factorio_version"; + } + } catch { + // Hit if the mod or mod pack version is not a valid api version + mod.warning = "wrong_factorio_version"; + } + } + } + + async function fixDependencyIssues(mod: lib.ModRecord) { + if (!mod.info) { + throw new Error("Mod record has no info"); // Should be unreachable + } + if (mod.info.factorioVersion === "0.12") { + throw new Error("Factorio version 0.12 not supported by mod portal"); + } + + let hasFailure = false; + const enabledMods = mods.filter(m => m.enabled); + const modInfoArray = [...modInfos.values()]; + for (const dependency of mod.info.dependencies) { + const reason = dependency.checkUnsatisfiedReason(enabledMods); + if (reason === undefined) { + continue; // No issues for this dependency + } + + if (reason === "incompatible") { + // Disable the incompatible mod + props.onChange({ + type: "mods.set", + name: dependency.name, + value: { ...props.modPack.mods.get(dependency.name)!, enabled: false }, + }); + continue; + } + + if (props.builtInModNames.includes(dependency.name)) { + // If builtin mod then enable it + props.onChange({ + type: "mods.set", + name: dependency.name, + value: { ...props.modPack.mods.get(dependency.name)!, enabled: true }, + }); + continue; + } + + // Find a suitable version to add + const candidate = modInfoArray + .filter(info => (info.name === dependency.name && info.factorioVersion === mod.info!.factorioVersion + && (!dependency.version || dependency.version.testVersion(info.version)) + )) + .reduce((max, cur) => ( + max && max.integerVersion > cur.integerVersion ? max : cur + ), undefined); + + if (candidate) { + props.onChange({ + type: "mods.set", + name: candidate.name, + value: { + name: candidate.name, + version: candidate.version, + sha1: candidate.sha1, + enabled: true, + }, + }); + } else { + hasFailure = true; + } + } + + return hasFailure; + } + + function actions(mod: lib.ModRecord) { return - {!deletedMods.has(mod.name) && !props.builtInMods.includes(mod.name) && { props.onChange({ @@ -311,6 +651,13 @@ function ModsTable(props: ModsTableProps) { }); }} >revert} + {(mod.warning && mod.warning !== "wrong_factorio_version") && { + if (await fixDependencyIssues(mod)) { + notify("Failed to automatically fix all issues", "warning"); + } + }} + >fix issues} ; } @@ -318,6 +665,38 @@ function ModsTable(props: ModsTableProps) { + + {account.hasAllPermission("core.mod.search_portal", "core.mod.download_from_portal") + && m.enabled + && (m.error || m.warning && ["wrong_version", "missing_dependency"].includes(m.warning)) + )} + />} + {account.hasAllPermission("core.mod.search_portal", "core.mod.download_from_portal") + && } } /> @@ -361,14 +740,26 @@ function ModsTable(props: ModsTableProps) { { title: "Name", key: "name", - render: (_, mod: any) => <> + render: (_, mod: lib.ModRecord) => <> {mod.error === "missing" && {" "} } - {mod.error === "bad_checksum" && + {mod.error === "bad_checksum" && {" "} } - {mod.title || mod.name} + {mod.warning === "incompatible" && + {" "} + } + {mod.warning === "missing_dependency" && + {" "} + } + {mod.warning === "wrong_version" && + {" "} + } + {mod.warning === "wrong_factorio_version" && + {" "} + } + {mod.info?.title || mod.name} , defaultSortOrder: "ascend", sorter: (a, b) => strcmp(a.name, b.name), @@ -386,7 +777,7 @@ function ModsTable(props: ModsTableProps) { }, ]} expandable={{ - expandedRowRender: mod => , + expandedRowRender: mod => m.enabled)} actions={actions} />, expandedRowClassName: () => "no-expanded-padding", }} dataSource={mods} @@ -908,9 +1299,7 @@ export default function ModPackViewPage() { changes={changes} onChange={pushChange} onRevert={revertChange} - builtInMods={ - lib.ModPack.getBuiltinMods(modifiedModPack.factorioVersion).map(builtinMod => builtinMod.name) - } + builtInModNames={lib.ModPack.getBuiltinModNames(modifiedModPack.factorioVersion)} /> ; - // Type alias for the mod structure derived from the library response type ModPortalModType = InstanceType["mods"][number]; @@ -145,7 +141,7 @@ function SearchModsButton() { const [open, setOpen] = useState(false); const [form] = Form.useForm(); const [searchText, setSearchText] = useState(""); - const [factorioVersion, setFactorioVersion] = useState("2.0"); + const [factorioVersion, setFactorioVersion] = useState("2.0"); // State for all mods fetched from backend const [allMods, setAllMods] = useState([]); @@ -162,22 +158,21 @@ function SearchModsButton() { const handleControllerDownload = ( modName: string, modTitle: string | undefined, - modVersion: string | undefined, - portalFactorioVersion: FactorioVersion, + modVersion: lib.FullVersion | undefined, + portalFactorioVersion: lib.ApiVersion, ) => { if (modVersion) { control.send( new lib.ModPortalDownloadRequest( - modName, - modVersion, + [{ name: modName, version: new lib.ModVersionEquality("=", modVersion)}], portalFactorioVersion ) ).then(() => { notification.success({ - message: "Download started", + message: "Download complete", description: `${modTitle || modName} v${ modVersion - } is being downloaded to the controller.`, + } has been downloaded to the controller.`, }); }).catch( notifyErrorHandler("Error starting mod download") @@ -270,7 +265,7 @@ function SearchModsButton() { // Update search text and factorio version from form const handleSearch = (changedValues: any, allValues: any) => { const nameValue = allValues.name; - const versionValue = allValues.factorioVersion as FactorioVersion | undefined; + const versionValue = allValues.factorioVersion as lib.ApiVersion | undefined; // Only trigger state updates if values actually changed if (nameValue !== searchText) { @@ -351,12 +346,9 @@ function SearchModsButton() { label="Factorio Version" > diff --git a/test/common.js b/test/common.js new file mode 100644 index 00000000..96397548 --- /dev/null +++ b/test/common.js @@ -0,0 +1,35 @@ +"use strict"; +const assert = require("assert").strict; + +/** + * Generate a flat array of tests from a matrix of inputs. + * + * @template {any[][]} T + * @param {T} arrays - A list of arrays representing the different values for each argument + * @returns {Array<{ [K in keyof T]: T[K][number] }>} + * An array of tuples, each containing one value from each input array. + */ +function testMatrix(...arrays) { + return arrays.reduce((acc, curr) => acc.flatMap(a => curr.map(b => [...a, b])), [[]]); +} + +/** + * Test that a class is round trip json serialisable across multiple test cases. + * + * @template {any[]} T - Constructor arguments + * @param {{new(...args: T): object}} Class - The class which has toJSON and fromJSON methods. + * @param {T[]} tests - The tests inputs to pass to the class constructor. + */ +function testRoundTripJsonSerialisable(Class, tests) { + for (const test of tests) { + const original = new Class(...test); + const serialised = JSON.stringify(original); + const reconstructed = Class.fromJSON(JSON.parse(serialised)); + assert.deepEqual(reconstructed, original, JSON.stringify(test)); + } +} + +module.exports = { + testMatrix, + testRoundTripJsonSerialisable, +}; diff --git a/test/controller/Controller.js b/test/controller/Controller.js index aa3a82d7..d9e68be2 100644 --- a/test/controller/Controller.js +++ b/test/controller/Controller.js @@ -2,9 +2,13 @@ const path = require("node:path"); const assert = require("assert").strict; const { Controller, HostInfo, InstanceInfo } = require("@clusterio/controller"); -const { ControllerConfig, Address, RequestError, InstanceConfig, SystemInfo, Role } = require("@clusterio/lib"); const { EventEmitter } = require("stream"); +const { + ControllerConfig, Address, RequestError, + InstanceConfig, SystemInfo, Role, ModPack, +} = require("@clusterio/lib"); + class MockEvent {} class MockInstanceConfig extends EventEmitter { @@ -373,6 +377,48 @@ describe("controller/src/Controller", function() { ]); }); }); + describe("ModPack", function() { + it("migrates builtin mod versions to X.Y.Z", function() { + const modPacks = Controller.migrateModPacks([{ + id: 0, + name: "name", + factorio_version: "2.0", + mods: [ + { name: "base", version: "2.0" }, + { name: "my-mod", version: "1.0.0" }, + ], + }]); + assert.deepEqual(modPacks, [{ + id: 0, + name: "name", + factorio_version: "2.0", + mods: [ + { name: "base", version: "2.0.0" }, + { name: "my-mod", version: "1.0.0" }, + ], + }]); + }); + it("does nothing for upto date data", function() { + const modPack = ModPack.fromJSON({ + id: 0, + name: "name", + factorio_version: "2.0", + mods: [ + { name: "base", version: "2.0.0" }, + { name: "my-mod", version: "1.0.0" }, + ], + }); + const jsonString = JSON.stringify(modPack); + const modPackJson = JSON.parse(jsonString); + const modPackJsonCopy = JSON.parse(jsonString); + const result = Controller.migrateModPacks([ + modPackJson, modPackJson, modPackJson, + ]); + assert.deepEqual(result, [ + modPackJsonCopy, modPackJsonCopy, modPackJsonCopy, + ]); + }); + }); describe("Role", function() { it("migrates renamed permissions", function() { // Alpha 17 const result = Controller.migrateRoles([ diff --git a/test/integration/clusterio.js b/test/integration/clusterio.js index 43fe317c..948dd556 100644 --- a/test/integration/clusterio.js +++ b/test/integration/clusterio.js @@ -951,7 +951,7 @@ describe("Integration of Clusterio", function() { await execCtl("instance start 44"); const instance = (await getInstances()).get(44); - const isV2 = lib.integerFactorioVersion(instance.factorioVersion) > lib.integerFactorioVersion("2.0.0"); + const isV2 = lib.integerPartialVersion(instance.factorioVersion) > lib.integerPartialVersion("2.0"); const exchangeString = (isV2 ? testStrings.modified_v2 : testStrings.modified).replace(/[\n\r]+/g, ""); const args = `base/freeplay --seed 1234 --map-exchange-string "${exchangeString}"`; await execCtl("instance stop 44"); diff --git a/test/integration/index.js b/test/integration/index.js index fbbc5fb1..529b0c08 100644 --- a/test/integration/index.js +++ b/test/integration/index.js @@ -117,15 +117,14 @@ class TestHostConnector extends lib.WebSocketClientConnector { } } -// Mark that this test takes a lot of time, or depeneds on a test -// that takes a lot of time. +// 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(20000); + test.timeout(30000); } async function get(urlPath) { @@ -196,7 +195,9 @@ async function execCtlProcess(...args) { return await exec(...args); } +let inExecCtl = false; async function execCtl(command) { + inExecCtl = true; process.chdir("temp/test"); try { const initArgs = await initializeCtl(command, control.plugins, true); @@ -209,9 +210,17 @@ async function execCtl(command) { 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)); } diff --git a/test/lib/ModStore.test.js b/test/lib/ModStore.test.js index 75c2d1c9..b1d733b1 100644 --- a/test/lib/ModStore.test.js +++ b/test/lib/ModStore.test.js @@ -7,7 +7,7 @@ const JSZip = require("jszip"); // Added for creating mock zips // Capture native fetch before any potential mocks are applied const nativeFetch = global.fetch; -const { ModStore, ModInfo } = require("@clusterio/lib"); // Adjust path based on compiled output +const { ModStore, ModInfo, ModVersionEquality } = require("@clusterio/lib"); // Adjust path based on compiled output const MODS_DIR = path.join("temp", "test", "mod_store", "mods"); const CACHE_FILE = path.join(MODS_DIR, "mod-info-cache.json"); @@ -148,7 +148,7 @@ describe("lib/ModStore", function () { assert.equal(modInfo.name, modName); assert.equal(modInfo.version, modVersion); // Check if the dependency was updated (indicating re-read) - assert(modInfo.dependencies.includes("? updated-dep")); + assert(modInfo.dependencySpecifications.includes("? updated-dep")); }); it("should handle corrupted zip files gracefully", async function () { @@ -271,7 +271,7 @@ describe("lib/ModStore", function () { }); // Helper to create the mock fetch function for the Factorio Mod Portal API - function createMockPortalApiFetch(modsToDownloadMap, fetchCallsArray = null) { + function createMockPortalApiFetch(modsToDownload, fetchCallsArray = null) { return async (url, options) => { const urlString = url.toString(); if (fetchCallsArray) { @@ -285,11 +285,12 @@ describe("lib/ModStore", function () { requestedModNames = (body.get("namelist") || "").split(",").filter(Boolean); } const results = requestedModNames.map(name => { - const requestedVersion = modsToDownloadMap.get(name); - if (!requestedVersion) { + const requestedMod = modsToDownload.find(mod => mod.name === name); + if (!requestedMod) { // Use assert or throw for clearer test failure messages assert.fail(`[Test Mock ERROR] Mock fetch received unexpected mod name: ${name}`); } + const requestedVersion = requestedMod.version.version; return { name: name, latest_release: { @@ -309,7 +310,7 @@ describe("lib/ModStore", function () { } it("should download and add a single mod", async function () { - const modsToDownload = new Map([["download-mod-1", "1.1.0"]]); + const modsToDownload = [{ name: "download-mod-1", version: ModVersionEquality.fromString("1.1.0") }]; // Define fetch mock specific to this test global.fetch = createMockPortalApiFetch(modsToDownload); @@ -327,10 +328,10 @@ describe("lib/ModStore", function () { }); it("should download multiple mods", async function () { - const modsToDownload = new Map([ - ["multi-mod-a", "1.0.0"], - ["multi-mod-b", "2.0.0"], - ]); + const modsToDownload = [ + { name: "multi-mod-a", version: ModVersionEquality.fromString("1.0.0") }, + { name: "multi-mod-b", version: ModVersionEquality.fromString("2.0.0") }, + ]; // Define fetch mock specific to this test global.fetch = createMockPortalApiFetch(modsToDownload); @@ -343,43 +344,34 @@ describe("lib/ModStore", function () { assert(await fs.pathExists(path.join(MODS_DIR, "multi-mod-a_1.0.0.zip"))); assert(await fs.pathExists(path.join(MODS_DIR, "multi-mod-b_2.0.0.zip"))); }); + it("should throw when no releases available", async function () { + const modsToDownload = [{ name: "download-mod-1", version: ModVersionEquality.fromString("1.1.0") }]; - it("should skip already downloaded mods", async function () { - const existingModName = "existing-mod"; - const existingModVersion = "1.0.0"; - const existingFilename = `${existingModName}_${existingModVersion}.zip`; - await createMockModZip(path.join(MODS_DIR, existingFilename), existingModName, existingModVersion); - modStore = await ModStore.fromDirectory(MODS_DIR); - assert.equal(modStore.files.size, 1); + // Define fetch mock specific to this test + global.fetch = async (url, options) => { + const urlString = url.toString(); + if (urlString.startsWith("https://mods.factorio.com/api/mods") && options?.method === "POST") { + const body = options.body; + let requestedModNames = []; + if (body instanceof URLSearchParams) { + requestedModNames = (body.get("namelist") || "").split(",").filter(Boolean); + } + const results = requestedModNames.map(name => ({ name: name })); + return { ok: true, status: 200, json: async () => ({ results: results, pagination: {} }) }; + } + // Fallback for unhandled fetches in this test + return { ok: false, status: 404, statusText: `Test Mock 404: ${urlString}` }; + }; - const modsToDownload = new Map([ - [existingModName, existingModVersion], // Already present - ["new-mod", "0.5.0"], // New mod - ]); - - let fetchCalls = []; - // Define fetch mock specific to this test (wraps original for tracking) - global.fetch = createMockPortalApiFetch(modsToDownload, fetchCalls); - - await modStore.downloadMods(modsToDownload, username, token, factorioVersion); - - assert.equal(modStore.files.size, 2, "Should have 2 mods total"); - assert(modStore.files.has(existingFilename)); - assert(modStore.files.has("new-mod_0.5.0.zip")); - assert(await fs.pathExists(path.join(MODS_DIR, "new-mod_0.5.0.zip"))); - - const postCall = fetchCalls.find(call => call.options?.method === "POST"); - assert(postCall, "POST call to API should have happened"); - const requestedMods = (postCall.options.body.get("namelist") || "").split(","); - assert.deepEqual(requestedMods, ["new-mod"], "Only new-mod should be in the namelist"); + await assert.rejects(modStore.downloadMods(modsToDownload, username, token, factorioVersion)); }); it("should skip built-in mods", async function () { - const modsToDownload = new Map([ - ["base", "1.1.0"], // Built-in - ["core", "1.1.0"], // Built-in - ["another-new-mod", "1.0.0"], // Not built-in - ]); + const modsToDownload = [ + { name: "base", version: ModVersionEquality.fromString("1.1.0") }, // Built-in + { name: "core", version: ModVersionEquality.fromString("1.1.0") }, // Built-in + { name: "another-new-mod", version: ModVersionEquality.fromString("1.0.0") }, // Not built-in + ]; let fetchCalls = []; // Define fetch mock specific to this test @@ -396,51 +388,8 @@ describe("lib/ModStore", function () { assert.deepEqual(requestedMods, ["another-new-mod"], "Only non-builtin mod should be in the namelist"); }); - it("should handle Factorio version formatting (e.g., 1.1.100 -> 1.1)", async function () { - const modsToDownload = new Map([["version-test-mod", "1.0.0"]]); - let capturedApiUrl = null; - - // Define fetch mock specific to this test - const testFetch = async (url, options) => { - const urlString = url.toString(); - if (urlString.startsWith("https://mods.factorio.com/api/mods") && options?.method === "POST") { - capturedApiUrl = urlString; // Capture URL - const body = options.body; - let requestedModNames = []; - if (body instanceof URLSearchParams) { - requestedModNames = (body.get("namelist") || "").split(",").filter(Boolean); - } - const results = requestedModNames.map(name => { - const requestedVersion = modsToDownload.get(name); - if (!requestedVersion) { - throw new Error(`[Test Mock ERROR] No version for ${name}`); - } - return { - name: name, - latest_release: { - download_url: `/download/${name}/${requestedVersion}`, - file_name: `${name}_${requestedVersion}.zip`, - info_json: { factorio_version: factorioVersion }, - version: requestedVersion, - sha1: `sha1_${name}_${requestedVersion}`, - }, - }; - }); - return { ok: true, status: 200, json: async () => ({ results: results, pagination: {} }) }; - } - return { ok: false, status: 404, statusText: `Test Mock 404: ${urlString}` }; - }; - global.fetch = testFetch; - - await modStore.downloadMods(modsToDownload, username, token, "1.1.100"); // Use full version - - assert(capturedApiUrl, "Fetch to API mods should have been called"); - const apiUrlParams = new URL(capturedApiUrl).searchParams; - assert.equal(apiUrlParams.get("version"), "1.1", "Factorio version should be truncated in API call"); - }); - it("should handle API error during mod info fetch", async function () { - const modsToDownload = new Map([["api-error-mod", "1.0.0"]]); + const modsToDownload = [{ name: "api-error-mod", version: ModVersionEquality.fromString("1.0.0") }]; // Define fetch mock specific to this test (simulates API error) global.fetch = async (url, options) => { @@ -463,11 +412,11 @@ describe("lib/ModStore", function () { }); it("should handle download error for a specific mod", async function () { - const modsToDownload = new Map([ - ["good-mod", "1.0.0"], // This one should succeed - ["bad-download-mod", "1.0.0"], // This one will fail - ["another-good-mod", "1.0.0"], // This one should also succeed - ]); + const modsToDownload = [ + { name: "good-mod", version: ModVersionEquality.fromString("1.0.0") }, // Success + { name: "bad-download-mod", version: ModVersionEquality.fromString("1.0.0") }, // Fail + { name: "another-good-mod", version: ModVersionEquality.fromString("1.0.0") }, // Success + ]; // Mock ModStore.downloadFile to fail for the specific mod const originalDSF = ModStore.downloadFile; @@ -496,10 +445,11 @@ describe("lib/ModStore", function () { requestedModNames = (body.get("namelist") || "").split(",").filter(Boolean); } const results = requestedModNames.map(name => { - const requestedVersion = modsToDownload.get(name); - if (!requestedVersion) { + const requestedMod = modsToDownload.find(mod => mod.name === name); + if (!requestedMod) { throw new Error(`[Test Mock ERROR] No version for ${name}`); } + const requestedVersion = requestedMod.version.version; return { name: name, latest_release: { @@ -526,12 +476,12 @@ describe("lib/ModStore", function () { }); it("should download mods in chunks", async function () { - const modsToDownload = new Map([ - ["chunk-mod-1", "1.0.0"], - ["chunk-mod-2", "1.0.0"], - ["chunk-mod-3", "1.0.0"], - ["chunk-mod-4", "1.0.0"], - ]); + const modsToDownload = [ + { name: "chunk-mod-1", version: ModVersionEquality.fromString("1.0.0") }, + { name: "chunk-mod-2", version: ModVersionEquality.fromString("1.0.0") }, + { name: "chunk-mod-3", version: ModVersionEquality.fromString("1.0.0") }, + { name: "chunk-mod-4", version: ModVersionEquality.fromString("1.0.0") }, + ]; let postCalls = 0; // Define fetch mock specific to this test @@ -545,10 +495,11 @@ describe("lib/ModStore", function () { requestedModNames = (body.get("namelist") || "").split(",").filter(Boolean); } const results = requestedModNames.map(name => { - const requestedVersion = modsToDownload.get(name); - if (!requestedVersion) { + const requestedMod = modsToDownload.find(mod => mod.name === name); + if (!requestedMod) { throw new Error(`[Test Mock ERROR] No version for ${name}`); } + const requestedVersion = requestedMod.version.version; return { name: name, latest_release: { @@ -570,7 +521,7 @@ describe("lib/ModStore", function () { // Assuming chunkSize is 500, 4 mods should be 1 chunk/POST call assert.equal(postCalls, 1, "Should make 1 POST call for 4 mods"); - assert.equal(modStore.files.size, modsToDownload.size, "All chunked mods should be downloaded"); + assert.equal(modStore.files.size, modsToDownload.length, "All chunked mods should be downloaded"); assert(modStore.files.has("chunk-mod-1_1.0.0.zip")); assert(modStore.files.has("chunk-mod-4_1.0.0.zip")); }); @@ -843,6 +794,40 @@ describe("lib/ModStore", function () { }); }); + describe("fetchModReleases (static)", function() { + // Keep track of the original fetch for the live test + let originalFetch; + before(function() { + originalFetch = global.fetch; + }); + after(function() { + global.fetch = originalFetch; // Restore original fetch after this suite + }); + + it("should fetch from the correct api url (mock)", async function() { + global.fetch = async function(url) { + assert.equal(url, "https://mods.factorio.com/api/mods/my-mod/full"); + return { ok: true, status: 200, json: async () => ({ name: "my-mod" }) }; + }; + + const result = await ModStore.fetchModReleases("my-mod"); + assert.equal(result.name, "my-mod"); + }); + it("should throw an error when the fetch fails (mock)", async function() { + global.fetch = async function(url) { + assert.equal(url, "https://mods.factorio.com/api/mods/my-mod/full"); + return { ok: false, status: 400, json: async () => ({ name: "my-mod" }) }; + }; + + await assert.rejects(ModStore.fetchModReleases("my-mod")); + }); + it("should fetch from the correct api url (live)", async function() { + global.fetch = nativeFetch; + const result = await ModStore.fetchModReleases("clusterio_lib"); + assert(result.releases && result.releases.length > 1, "Failed to fetch releases"); + }); + }); + // === Instance Methods === describe("Instance Methods", function () { let modStore; @@ -1004,5 +989,26 @@ describe("lib/ModStore", function () { assert.equal(eventModInfo, modInfo, "Event should pass the added ModInfo"); }); }); + + describe("filterInstalled", function() { + it("should filter out installed mods", function() { + const mods = [ + { name: testMod1Name, version: ModVersionEquality.fromString(testMod1Version) }, + { name: "not-installed", version: ModVersionEquality.fromString("1.0.0") }, + ]; + assert.deepEqual(modStore.filterInstalled(mods, false), [ + { name: "not-installed", version: ModVersionEquality.fromString("1.0.0") }, + ]); + }); + it("should filter out uninstalled mods", function() { + const mods = [ + { name: testMod1Name, version: ModVersionEquality.fromString(testMod1Version) }, + { name: "not-installed", version: ModVersionEquality.fromString("1.0.0") }, + ]; + assert.deepEqual(modStore.filterInstalled(mods, true), [ + { name: testMod1Name, version: ModVersionEquality.fromString(testMod1Version) }, + ]); + }); + }); }); }); diff --git a/test/lib/data/ModInfo.js b/test/lib/data/ModInfo.js index 9e8d6c65..1868a432 100644 --- a/test/lib/data/ModInfo.js +++ b/test/lib/data/ModInfo.js @@ -5,10 +5,226 @@ const path = require("path"); const lib = require("@clusterio/lib"); const libBuildMod = require("@clusterio/lib/build_mod"); -const { ModInfo } = lib; +const { ModInfo, ModDependency } = lib; +const { testMatrix, testRoundTripJsonSerialisable } = require("../../common"); describe("lib/data/ModInfo", function() { + describe("class ModDependency", function() { + describe(".getTypeFromPrefix()", function() { + it("should not throw for valid prefixes", function() { + assert.equal(ModDependency.getTypeFromPrefix("!"), "incompatible"); + assert.equal(ModDependency.getTypeFromPrefix("?"), "optional"); + assert.equal(ModDependency.getTypeFromPrefix("(?)"), "hidden"); + assert.equal(ModDependency.getTypeFromPrefix("~"), "unordered"); + assert.equal(ModDependency.getTypeFromPrefix(""), "required"); + }); + it("should throw for unknown prefixes", function() { + assert.throws(() => ModDependency.getTypeFromPrefix("invalid")); + }); + }); + describe("constructor", function() { + it("should accept specifications of name only", function() { + const dependency = new ModDependency("my-mod"); + assert.equal(dependency.name, "my-mod"); + assert.equal(dependency.type, "required"); + assert.equal(dependency.version, undefined); + }); + it("should accept specifications of name and prefix", function() { + const dependency = new ModDependency("? my-mod"); + assert.equal(dependency.name, "my-mod"); + assert.equal(dependency.type, "optional"); + assert.equal(dependency.version, undefined); + }); + it("should accept specifications of name and version", function() { + const dependency = new ModDependency("my-mod >= 1.2.3"); + assert.equal(dependency.name, "my-mod"); + assert.equal(dependency.type, "required"); + assert.notEqual(dependency.version, undefined); + assert.equal(dependency.version.equality, ">="); + assert.equal(dependency.version.integerVersion, lib.integerFullVersion("1.2.3")); + }); + it("should accept specifications of name, prefix and version", function() { + const dependency = new ModDependency("? my-mod >= 1.2.3"); + assert.equal(dependency.name, "my-mod"); + assert.equal(dependency.type, "optional"); + assert.notEqual(dependency.version, undefined); + assert.equal(dependency.version.equality, ">="); + assert.equal(dependency.version.integerVersion, lib.integerFullVersion("1.2.3")); + }); + it("should throw if no version equality is given", function() { + assert.throws(() => new ModDependency("my-mod 1.2.3")); + assert.throws(() => new ModDependency("? my-mod 1.2.3")); + }); + it("should throw if invalid specification given", function() { + assert.throws(() => new ModDependency("? ? my-mod = 1.2.3")); + }); + it("should throw if name contains spaces", function() { + // Apparently there are some legacy mods which include spaces + // Such as https://mods.factorio.com/mod/Explosive%20Excavation + this.skip(); + assert.throws(() => new ModDependency("my mod")); + assert.throws(() => new ModDependency("? my mod")); + assert.throws(() => new ModDependency("? my mod >= 1.2.3")); + }); + it("should not error when the name contains spaces", function() { + const dependencyReq = new ModDependency("my mod"); + assert.equal(dependencyReq.name, "my mod"); + assert.equal(dependencyReq.type, "required"); + + const dependencyOpt = new ModDependency("? my mod"); + assert.equal(dependencyOpt.name, "my mod"); + assert.equal(dependencyOpt.type, "optional"); + + const dependencyVer = new ModDependency("my mod >= 1.2.3"); + assert.equal(dependencyVer.name, "my mod"); + assert.equal(dependencyVer.type, "required"); + assert.equal(dependencyVer.version.version, "1.2.3"); + + const dependencyOptVer = new ModDependency("? my mod >= 1.2.3"); + assert.equal(dependencyOptVer.name, "my mod"); + assert.equal(dependencyOptVer.type, "optional"); + assert.equal(dependencyOptVer.version.version, "1.2.3"); + }); + it("should be round trip json serialisable", function() { + testRoundTripJsonSerialisable(ModDependency, testMatrix( + ["my-mod", "? my-mod", "! my-mod", "my-mod > 1.0.0", "~ my-mod <= 2.0.0"], // Specification + )); + }); + }); + describe("checkUnsatisfiedReason()", function() { + const mods = [ + ModInfo.fromJSON({ name: "my-mod", version: "1.0.0" }), + ]; + it("should be undefined for incompatible being missing", function() { + const dependency = new ModDependency("! not-present"); + assert.equal(dependency.checkUnsatisfiedReason(mods), undefined); + }); + it("should give reason for incompatible being present", function() { + const dependency = new ModDependency("! my-mod"); + assert.equal(dependency.checkUnsatisfiedReason(mods), "incompatible"); + }); + it("should undefined for optional / hidden being missing", function() { + const dependencyOptional = new ModDependency("? not-present"); + assert.equal(dependencyOptional.checkUnsatisfiedReason(mods), undefined); + const dependencyHidden = new ModDependency("(?) not-present"); + assert.equal(dependencyHidden.checkUnsatisfiedReason(mods), undefined); + }); + it("should undefined for optional / hidden being present", function() { + const dependencyOptional = new ModDependency("? my-mod"); + assert.equal(dependencyOptional.checkUnsatisfiedReason(mods), undefined); + const dependencyHidden = new ModDependency("(?) my-mod"); + assert.equal(dependencyHidden.checkUnsatisfiedReason(mods), undefined); + }); + it("should give reason for optional / hidden being present but wrong version", function() { + const dependencyOptional = new ModDependency("? my-mod > 2.0.0"); + assert.equal(dependencyOptional.checkUnsatisfiedReason(mods), "wrong_version"); + const dependencyHidden = new ModDependency("(?) my-mod > 2.0.0"); + assert.equal(dependencyHidden.checkUnsatisfiedReason(mods), "wrong_version"); + }); + it("should undefined for unordered / required being present", function() { + const dependencyOptional = new ModDependency("~ my-mod"); + assert.equal(dependencyOptional.checkUnsatisfiedReason(mods), undefined); + const dependencyHidden = new ModDependency("my-mod"); + assert.equal(dependencyHidden.checkUnsatisfiedReason(mods), undefined); + }); + it("should give reason for unordered / required being present but wrong version", function() { + const dependencyOptional = new ModDependency("~ my-mod > 2.0.0"); + assert.equal(dependencyOptional.checkUnsatisfiedReason(mods), "wrong_version"); + const dependencyHidden = new ModDependency("my-mod > 2.0.0"); + assert.equal(dependencyHidden.checkUnsatisfiedReason(mods), "wrong_version"); + }); + it("should give reason for unordered / required being missing", function() { + const dependencyOptional = new ModDependency("~ not-present"); + assert.equal(dependencyOptional.checkUnsatisfiedReason(mods), "missing_dependency"); + const dependencyHidden = new ModDependency("not-present"); + assert.equal(dependencyHidden.checkUnsatisfiedReason(mods), "missing_dependency"); + }); + }); + describe("isSatisfied()", function() { + const mods = [ + ModInfo.fromJSON({ name: "my-mod", version: "1.0.0" }), + ]; + it("should pass for incompatible being missing", function() { + const dependency = new ModDependency("! not-present"); + assert.equal(dependency.isSatisfied(mods), true); + }); + it("should fail for incompatible being present", function() { + const dependency = new ModDependency("! my-mod"); + assert.equal(dependency.isSatisfied(mods), false); + }); + it("should pass for optional / hidden being missing", function() { + const dependencyOptional = new ModDependency("? not-present"); + assert.equal(dependencyOptional.isSatisfied(mods), true); + const dependencyHidden = new ModDependency("(?) not-present"); + assert.equal(dependencyHidden.isSatisfied(mods), true); + }); + it("should pass for optional / hidden being present", function() { + const dependencyOptional = new ModDependency("? my-mod"); + assert.equal(dependencyOptional.isSatisfied(mods), true); + const dependencyHidden = new ModDependency("(?) my-mod"); + assert.equal(dependencyHidden.isSatisfied(mods), true); + }); + it("should fail for optional / hidden being present but wrong version", function() { + const dependencyOptional = new ModDependency("? my-mod > 2.0.0"); + assert.equal(dependencyOptional.isSatisfied(mods), false); + const dependencyHidden = new ModDependency("(?) my-mod > 2.0.0"); + assert.equal(dependencyHidden.isSatisfied(mods), false); + }); + it("should pass for unordered / required being present", function() { + const dependencyOptional = new ModDependency("~ my-mod"); + assert.equal(dependencyOptional.isSatisfied(mods), true); + const dependencyHidden = new ModDependency("my-mod"); + assert.equal(dependencyHidden.isSatisfied(mods), true); + }); + it("should fail for unordered / required being present but wrong version", function() { + const dependencyOptional = new ModDependency("~ my-mod > 2.0.0"); + assert.equal(dependencyOptional.isSatisfied(mods), false); + const dependencyHidden = new ModDependency("my-mod > 2.0.0"); + assert.equal(dependencyHidden.isSatisfied(mods), false); + }); + it("should fail for unordered / required being missing", function() { + const dependencyOptional = new ModDependency("~ not-present"); + assert.equal(dependencyOptional.isSatisfied(mods), false); + const dependencyHidden = new ModDependency("not-present"); + assert.equal(dependencyHidden.isSatisfied(mods), false); + }); + }); + describe("incompatible", function() { + it("should return true iff the type is incompatible", function() { + const dependencyIncompatible = new ModDependency("! my-mod"); + assert.equal(dependencyIncompatible.incompatible, true); + for (const test of ["my-mod", "? my-mod", "(?) my-mod", "~ my-mod"]) { + const dependency = new ModDependency(test); + assert.equal(dependency.incompatible, false, test); + } + }); + }); + describe("required", function() { + it("should return true iff the dependency is required", function() { + for (const test of ["my-mod", "~ my-mod"]) { + const dependency = new ModDependency(test); + assert.equal(dependency.required, true, test); + } + for (const test of ["! my-mod", "? my-mod", "(?) my-mod"]) { + const dependency = new ModDependency(test); + assert.equal(dependency.required, false, test); + } + }); + }); + describe("optional", function() { + it("should return true iff the dependency is optional", function() { + for (const test of ["? my-mod", "(?) my-mod"]) { + const dependency = new ModDependency(test); + assert.equal(dependency.optional, true, test); + } + for (const test of ["! my-mod", "my-mod", "~ my-mod"]) { + const dependency = new ModDependency(test); + assert.equal(dependency.optional, false, test); + } + }); + }); + }); describe("class ModInfo", function() { it("should round trip serialize", function() { const validate = lib.compile(ModInfo.jsonSchema); @@ -70,7 +286,16 @@ describe("lib/data/ModInfo", function() { factorioMods.sort((a, b) => a.integerFactorioVersion - b.integerFactorioVersion); assert.deepEqual(factorioMods.map(mod => mod.factorioVersion), sortedVersions); }); - + it("should provide the original string specifications for dependencies", function() { + const mod = ModInfo.fromJSON({ dependencies: ["UltraMod", "SuperLib >= 1.00", "! bad-mod"] }); + assert.deepEqual(mod.dependencySpecifications, ["UltraMod", "SuperLib >= 1.00", "! bad-mod"]); + }); + it("should parse the provided dependency specifications", function() { + const mod = ModInfo.fromJSON({ dependencies: ["UltraMod", "SuperLib >= 1.00", "! bad-mod"] }); + assert.deepEqual(mod.dependencies, + ["UltraMod", "SuperLib >= 1.00", "! bad-mod"].map(dep => new ModDependency(dep)) + ); + }); it("should load from a mod zip file", async function() { await libBuildMod.build({ build: true, @@ -100,5 +325,23 @@ describe("lib/data/ModInfo", function() { }) ); }); + describe("checkDependencySatisfaction()", function() { + it("should return undefined when satisfied", function() { + const mod = ModInfo.fromJSON({ dependencies: ["! a", "b = 1.0.0", "? c = 2.0.0"] }); + assert.equal(mod.checkDependencySatisfaction([{ name: "b", version: "1.0.0" }]), undefined); + }); + it("should return incompatible when there is an incompatibility", function() { + const mod = ModInfo.fromJSON({ dependencies: ["! a", "b = 1.0.0", "? c = 2.0.0"] }); + assert.equal(mod.checkDependencySatisfaction([{ name: "a", version: "1.0.0" }]), "incompatible"); + }); + it("should return missing dependency when missing but no incompatibly", function() { + const mod = ModInfo.fromJSON({ dependencies: ["! a", "b = 1.0.0", "? c = 2.0.0"] }); + assert.equal(mod.checkDependencySatisfaction([{ name: "c", version: "1.0.0" }]), "missing_dependency"); + }); + it("should return wrong version when wrong but not missing or incompatible", function() { + const mod = ModInfo.fromJSON({ dependencies: ["! a", "b = 1.0.0", "? c = 2.0.0"] }); + assert.equal(mod.checkDependencySatisfaction([{ name: "b", version: "2.0.0" }]), "wrong_version"); + }); + }); }); }); diff --git a/test/lib/data/ModPack.js b/test/lib/data/ModPack.js index b63b9794..5000845b 100644 --- a/test/lib/data/ModPack.js +++ b/test/lib/data/ModPack.js @@ -397,16 +397,16 @@ describe("lib/data/ModPack", function() { it("should work with versions before 2.0", function() { const builtinMods = ModPack.getBuiltinMods("1.1"); assert.deepEqual(builtinMods, [ - { name: "base", enabled: true, version: "1.1" }, + { name: "base", enabled: true, version: "1.1.0" }, ]); }); it("should work with versions after 2.0", function() { const builtinMods = ModPack.getBuiltinMods("2.0"); assert.deepEqual(builtinMods, [ - { name: "base", enabled: true, version: "2.0" }, - { name: "elevated-rails", enabled: false, version: "2.0" }, - { name: "quality", enabled: false, version: "2.0" }, - { name: "space-age", enabled: false, version: "2.0" }, + { name: "base", enabled: true, version: "2.0.0" }, + { name: "elevated-rails", enabled: false, version: "2.0.0" }, + { name: "quality", enabled: false, version: "2.0.0" }, + { name: "space-age", enabled: false, version: "2.0.0" }, ]); }); }); diff --git a/test/lib/data/version.test.js b/test/lib/data/version.test.js new file mode 100644 index 00000000..39cf0e03 --- /dev/null +++ b/test/lib/data/version.test.js @@ -0,0 +1,515 @@ +"use strict"; +const assert = require("assert").strict; +const lib = require("@clusterio/lib"); + +const { testMatrix, testRoundTripJsonSerialisable } = require("../../common"); + +describe("lib/data/version", function() { + describe("isVersionEquality()", function() { + it("should correctly validate input strings", function() { + const valid = ["<", "<=", "=", ">=", ">"]; + for (const test of valid) { + assert.equal(lib.isVersionEquality(test), true, test); + } + const invalid = ["==", "!=", "<>", ""]; + for (const test of invalid) { + assert.equal(lib.isVersionEquality(test), false, test); + } + }); + }); + describe("isApiVersion()", function() { + it("should correctly validate input strings", function() { + const valid = ["0.13", "0.14", "0.15", "0.16", "0.17", "0.18", "1.0", "1.1", "2.0"]; + for (const test of valid) { + assert.equal(lib.isApiVersion(test), true, test); + } + const invalid = ["0.12", "0.19", "1.2", "2.1", "1.0.0", "1.0.0.0", "0", "", "latest"]; + for (const test of invalid) { + assert.equal(lib.isApiVersion(test), false, test); + } + }); + }); + describe("normaliseApiVersion()", function() { + it("should normalise accepted versions", function() { + assert.equal(lib.normaliseApiVersion("0.13"), "0.13"); + assert.equal(lib.normaliseApiVersion("0.13.0"), "0.13"); + assert.equal(lib.normaliseApiVersion("0.13.1"), "0.13"); + assert.equal(lib.normaliseApiVersion("1.1"), "1.1"); + assert.equal(lib.normaliseApiVersion("1.1.0"), "1.1"); + assert.equal(lib.normaliseApiVersion("1.1.1"), "1.1"); + assert.equal(lib.normaliseApiVersion("2.0"), "2.0"); + assert.equal(lib.normaliseApiVersion("2.0.0"), "2.0"); + assert.equal(lib.normaliseApiVersion("2.0.1"), "2.0"); + assert.equal(lib.normaliseApiVersion("1.0.0.0"), "1.0"); + }); + it("should throw for invalid versions", function() { + const invalid = ["0.12", "0", "", "latest"]; + for (const test of invalid) { + assert.throws(() => lib.normaliseApiVersion(test), undefined, test); + } + }); + }); + describe("isFullVersion()", function() { + it("should correctly validate input strings", function() { + const valid = ["0.12.0", "0.13.1", "0.17.99", "00.000.0001", "1.1.110"]; + for (const test of valid) { + assert.equal(lib.isFullVersion(test), true, test); + } + const invalid = ["0.12", "0.19", "1.0", "1.0.0.0", "0", "", "latest"]; + for (const test of invalid) { + assert.equal(lib.isFullVersion(test), false, test); + } + }); + }); + describe("integerFullVersion()", function() { + it("should sort versions lexicographically", function() { + let unsortedVersions = ["1.0.0", "1.1.0", "0.1.0", "3.0.0", "1.2.0", "0.3.1", "0.3.3", "2.1.1", "0.0.1"]; + let sortedVersions = ["0.0.1", "0.1.0", "0.3.1", "0.3.3", "1.0.0", "1.1.0", "1.2.0", "2.1.1", "3.0.0"]; + unsortedVersions.sort((a, b) => lib.integerFullVersion(a) - lib.integerFullVersion(b)); + assert.deepEqual(unsortedVersions, sortedVersions); + }); + }); + describe("normaliseFullVersion()", function() { + it("should normalise accepted versions", function() { + assert.equal(lib.normaliseFullVersion("0.13"), "0.13.0"); + assert.equal(lib.normaliseFullVersion("0.13.0"), "0.13.0"); + assert.equal(lib.normaliseFullVersion("0.13.1"), "0.13.1"); + assert.equal(lib.normaliseFullVersion("1.1"), "1.1.0"); + assert.equal(lib.normaliseFullVersion("1.1.0"), "1.1.0"); + assert.equal(lib.normaliseFullVersion("1.1.1"), "1.1.1"); + assert.equal(lib.normaliseFullVersion("2.0"), "2.0.0"); + assert.equal(lib.normaliseFullVersion("2.0.0"), "2.0.0"); + assert.equal(lib.normaliseFullVersion("2.0.1"), "2.0.1"); + }); + }); + describe("isPartialVersion()", function() { + it("should correctly validate input strings", function() { + const valid = [ + "0.13", "0.14", "0.15", "0.16", "0.17", "0.18", "1.0", "1.1", "2.0", + "0.12.0", "0.13.1", "0.17.99", "00.000.0001", "1.1.110", + ]; + for (const test of valid) { + assert.equal(lib.isPartialVersion(test), true, test); + } + const invalid = ["1.0.0.0", "0", "", "latest"]; + for (const test of invalid) { + assert.equal(lib.isPartialVersion(test), false, test); + } + }); + }); + describe("integerPartialVersion()", function() { + it("should sort versions lexicographically", function() { + let unsortedVersions = ["1.0", "1.1", "0.1", "3.0", "1.2", "0.3", "2.1"]; + let sortedVersions = ["0.1", "0.3", "1.0", "1.1", "1.2", "2.1", "3.0"]; + unsortedVersions.sort((a, b) => lib.integerPartialVersion(a) - lib.integerPartialVersion(b)); + assert.deepEqual(unsortedVersions, sortedVersions); + }); + }); + describe("isTargetVersion()", function() { + it("should correctly validate input strings", function() { + const valid = [ + "0.13", "0.14", "0.15", "0.16", "0.17", "0.18", "1.0", "1.1", "2.0", + "0.12.0", "0.13.1", "0.17.99", "00.000.0001", "1.1.110", "latest", + ]; + for (const test of valid) { + assert.equal(lib.isTargetVersion(test), true, test); + } + const invalid = ["1.0.0.0", "0", ""]; + for (const test of invalid) { + assert.equal(lib.isTargetVersion(test), false, test); + } + }); + }); + describe("class ModVersionEquality", function() { + const tests = testMatrix( + ["<", "<=", "=", ">=", ">"], + ["0.18", "1.0", "1.0.0", "1.1.1"], + ); + describe("constructor", function() { + it("should be round trip json serialisable", function() { + testRoundTripJsonSerialisable(lib.ModVersionEquality, tests); + }); + it("should be constructible", function() { + for (const [eq, ver] of tests) { + const test = `${eq} ${ver}`; + const version = new lib.ModVersionEquality(eq, ver); + assert.equal(version.equality, eq, test); + assert.equal(version.version, ver, test); + assert.equal(version.integerVersion, lib.integerPartialVersion(ver), test); + } + }); + it("should throw when given an invalid equality", function() { + assert.throws(() => new lib.ModVersionEquality("==", "1.0.0")); + }); + it("should throw when given an invalid version", function() { + assert.throws(() => new lib.ModVersionEquality("=", "1.0.0.0")); + }); + }); + describe("testIntegerVersion()", function() { + it("should test for less than", function() { + const version = new lib.ModVersionEquality("<", "0.0.100"); + assert.equal(version.testIntegerVersion(90), true); + assert.equal(version.testIntegerVersion(100), false); + assert.equal(version.testIntegerVersion(110), false); + }); + it("should test for less than equal", function() { + const version = new lib.ModVersionEquality("<=", "0.0.100"); + assert.equal(version.testIntegerVersion(90), true); + assert.equal(version.testIntegerVersion(100), true); + assert.equal(version.testIntegerVersion(110), false); + }); + it("should test for equal", function() { + const version = new lib.ModVersionEquality("=", "0.0.100"); + assert.equal(version.testIntegerVersion(90), false); + assert.equal(version.testIntegerVersion(100), true); + assert.equal(version.testIntegerVersion(110), false); + }); + it("should test for greater than equal", function() { + const version = new lib.ModVersionEquality(">=", "0.0.100"); + assert.equal(version.testIntegerVersion(90), false); + assert.equal(version.testIntegerVersion(100), true); + assert.equal(version.testIntegerVersion(110), true); + }); + it("should test for greater than", function() { + const version = new lib.ModVersionEquality(">", "0.0.100"); + assert.equal(version.testIntegerVersion(90), false); + assert.equal(version.testIntegerVersion(100), false); + assert.equal(version.testIntegerVersion(110), true); + }); + it("should throw if the equality is invalid (unreachable)", function() { + const version = new lib.ModVersionEquality(">", "0.0.100"); + version.equality = "=="; + assert.throws(() => version.testIntegerVersion(100)); + }); + }); + describe("testVersion()", function() { + it("should test for less than", function() { + const version = new lib.ModVersionEquality("<", "1.1.1"); + assert.equal(version.testVersion("1.0"), true); + assert.equal(version.testVersion("1.1"), true); + assert.equal(version.testVersion("1.1.1"), false); + assert.equal(version.testVersion("1.1.2"), false); + assert.equal(version.testVersion("1.2"), false); + }); + it("should test for less than equal", function() { + const version = new lib.ModVersionEquality("<=", "1.1.1"); + assert.equal(version.testVersion("1.0"), true); + assert.equal(version.testVersion("1.1"), true); + assert.equal(version.testVersion("1.1.1"), true); + assert.equal(version.testVersion("1.1.2"), false); + assert.equal(version.testVersion("1.2"), false); + }); + it("should test for equal", function() { + const version = new lib.ModVersionEquality("=", "1.1.1"); + assert.equal(version.testVersion("1.0"), false); + assert.equal(version.testVersion("1.1"), false); + assert.equal(version.testVersion("1.1.1"), true); + assert.equal(version.testVersion("1.1.2"), false); + assert.equal(version.testVersion("1.2"), false); + }); + it("should test for greater than equal", function() { + const version = new lib.ModVersionEquality(">=", "1.1.1"); + assert.equal(version.testVersion("1.0"), false); + assert.equal(version.testVersion("1.1"), false); + assert.equal(version.testVersion("1.1.1"), true); + assert.equal(version.testVersion("1.1.2"), true); + assert.equal(version.testVersion("1.2"), true); + }); + it("should test for greater than", function() { + const version = new lib.ModVersionEquality(">", "1.1.1"); + assert.equal(version.testVersion("1.0"), false); + assert.equal(version.testVersion("1.1"), false); + assert.equal(version.testVersion("1.1.1"), false); + assert.equal(version.testVersion("1.1.2"), true); + assert.equal(version.testVersion("1.2"), true); + }); + it("should throw if the equality is invalid (unreachable)", function() { + const version = new lib.ModVersionEquality(">", "1.1.1"); + version.equality = "=="; + assert.throws(() => version.testInteger("1.0.0")); + }); + }); + describe("toString()", function() { + it("should be convertible to string", function() { + for (const [eq, ver] of tests) { + const expected = `${eq} ${ver}`; + const version = new lib.ModVersionEquality(eq, ver); + assert.equal(version.toString(), expected); + } + }); + }); + describe("fromString()", function() { + it("should be constructable from string", function() { + for (const [eq, ver] of tests) { + const test = `${eq} ${ver}`; + const version = lib.ModVersionEquality.fromString(test); + assert.equal(version.equality, eq, test); + assert.equal(version.version, ver, test); + assert.equal(version.integerVersion, lib.integerPartialVersion(ver), test); + } + }); + it("should error for invalid equalities", function() { + const equalities = ["==", "!=", "<>", ""]; + for (const eq of equalities) { + const test = `${eq} 1.0.0`; + assert.throws(() => lib.ModVersionEquality.fromString(test), undefined, test); + } + }); + it("should error for invalid versions", function() { + const versions = ["1.0.0.0", "0", "", "latest"]; + for (const ver of versions) { + const test = `= ${ver}`; + assert.throws(() => lib.ModVersionEquality.fromString(test), undefined, test); + } + }); + }); + describe("fromParts()", function() { + it("should be constructable from parts", function() { + for (const [eq, ver] of tests) { + const test = `${eq} ${ver}`; + const version = lib.ModVersionEquality.fromParts(eq, ver); + assert.equal(version.equality, eq, test); + assert.equal(version.version, ver, test); + assert.equal(version.integerVersion, lib.integerPartialVersion(ver), test); + } + }); + it("should error for invalid equalities", function() { + const equalities = ["==", "!=", "<>", ""]; + for (const eq of equalities) { + assert.throws(() => lib.ModVersionEquality.fromParts(eq, "1.0.0"), undefined, eq); + } + }); + it("should error for invalid versions", function() { + const versions = ["1.0.0.0", "0", "", "latest"]; + for (const ver of versions) { + assert.throws(() => lib.ModVersionEquality.fromParts("=", ver), undefined, ver); + } + }); + }); + }); + describe("class ModVersionRange", function() { + describe("constructor", function() { + it("should be constructable", function() { + const range = new lib.ModVersionRange( + new lib.ModVersionEquality(">=", "1.0.0"), + new lib.ModVersionEquality("<", "2.0.0"), + ); + assert.equal(range.minVersion.equality, ">="); + assert.equal(range.minVersion.version, "1.0.0"); + assert.equal(range.maxVersion.equality, "<"); + assert.equal(range.maxVersion.version, "2.0.0"); + }); + it("should be default constructable", function() { + const range = new lib.ModVersionRange(); + assert.equal(range.minVersion.equality, ">="); + assert.equal(range.minVersion.version, "0.0.0"); + assert.equal(range.maxVersion.equality, "<="); + assert.equal(range.maxVersion.version, "65535.65535.65535"); + }); + it("should throw if invalid equality is given for min version", function() { + assert.throws(() => new lib.ModVersionRange( + new lib.ModVersionEquality("<", "1.0.0"), + new lib.ModVersionEquality("<", "2.0.0"), + )); + assert.throws(() => new lib.ModVersionRange( + new lib.ModVersionEquality("<=", "1.0.0"), + new lib.ModVersionEquality("<", "2.0.0"), + )); + }); + it("should throw if invalid equality is given for max version", function() { + assert.throws(() => new lib.ModVersionRange( + new lib.ModVersionEquality(">=", "1.0.0"), + new lib.ModVersionEquality(">", "2.0.0"), + )); + assert.throws(() => new lib.ModVersionRange( + new lib.ModVersionEquality(">=", "1.0.0"), + new lib.ModVersionEquality(">=", "2.0.0"), + )); + }); + }); + describe("valid", function() { + it("should be true for valid ranges", function() { + const range = new lib.ModVersionRange( + new lib.ModVersionEquality(">=", "1.0.0"), + new lib.ModVersionEquality("<", "2.0.0"), + ); + assert.equal(range.valid, true); + + const defaultRange = new lib.ModVersionRange(); + assert.equal(defaultRange.valid, true); + }); + it("should be false for invalid ranges", function() { + const range = new lib.ModVersionRange( + new lib.ModVersionEquality(">=", "2.0.0"), + new lib.ModVersionEquality("<", "1.0.0"), + ); + assert.equal(range.valid, false); + + const rangeImpossible = new lib.ModVersionRange( + new lib.ModVersionEquality("=", "1.0.0"), + new lib.ModVersionEquality("=", "2.0.0"), + ); + assert.equal(rangeImpossible.valid, false); + }); + }); + describe("invalidate()", function() { + it("should result in the range becoming invalid", function() { + const range = new lib.ModVersionRange( + new lib.ModVersionEquality(">=", "1.0.0"), + new lib.ModVersionEquality("<", "2.0.0"), + ); + range.invalidate(); + assert.equal(range.valid, false); + + const defaultRange = new lib.ModVersionRange(); + defaultRange.invalidate(); + assert.equal(defaultRange.valid, false); + }); + }); + describe("testIntegerVersion()", function() { + it("should return true when in range", function() { + const range = new lib.ModVersionRange( + new lib.ModVersionEquality(">=", "0.0.100"), + new lib.ModVersionEquality("<", "0.0.200"), + ); + assert.equal(range.testIntegerVersion(100), true); + assert.equal(range.testIntegerVersion(199), true); + }); + it("should return false when out of range", function() { + const range = new lib.ModVersionRange( + new lib.ModVersionEquality(">=", "0.0.100"), + new lib.ModVersionEquality("<", "0.0.200"), + ); + assert.equal(range.testIntegerVersion(99), false); + assert.equal(range.testIntegerVersion(200), false); + }); + it("should handle exact matches", function() { + const range = new lib.ModVersionRange( + new lib.ModVersionEquality("=", "0.0.100"), + new lib.ModVersionEquality("=", "0.0.100"), + ); + assert.equal(range.testIntegerVersion(99), false); + assert.equal(range.testIntegerVersion(100), true); + assert.equal(range.testIntegerVersion(101), false); + }); + }); + describe("testVersion()", function() { + it("should return true when in range", function() { + const range = new lib.ModVersionRange( + new lib.ModVersionEquality(">=", "1.0.0"), + new lib.ModVersionEquality("<", "2.0.0"), + ); + assert.equal(range.testVersion("1.0.0"), true); + assert.equal(range.testVersion("1.9.9"), true); + }); + it("should return false when out of range", function() { + const range = new lib.ModVersionRange( + new lib.ModVersionEquality(">=", "1.0.0"), + new lib.ModVersionEquality("<", "2.0.0"), + ); + assert.equal(range.testVersion("0.9.9"), false); + assert.equal(range.testVersion("2.0.0"), false); + }); + it("should handle exact matches", function() { + const range = new lib.ModVersionRange( + new lib.ModVersionEquality("=", "1.0.0"), + new lib.ModVersionEquality("=", "1.0.0"), + ); + assert.equal(range.testVersion("0.9.9"), false); + assert.equal(range.testVersion("1.0.0"), true); + assert.equal(range.testVersion("1.0.1"), false); + }); + }); + describe("combineVersion()", function() { + it("should handle merge with less than", function() { + const range = new lib.ModVersionRange( + new lib.ModVersionEquality(">=", "1.0.0"), + new lib.ModVersionEquality("<", "2.0.0"), + ); + + assert.equal(range.testVersion("1.1"), true); + assert.equal(range.testVersion("1.5"), true); + range.combineVersion(new lib.ModVersionEquality("<", "1.2")); + assert.equal(range.testVersion("1.1"), true); + assert.equal(range.testVersion("1.5"), false); + + assert.equal(range.valid, true); + range.combineVersion(new lib.ModVersionEquality("<", "0.5")); + assert.equal(range.valid, false); + }); + it("should handle merge with less than equal", function() { + const range = new lib.ModVersionRange( + new lib.ModVersionEquality(">=", "1.0.0"), + new lib.ModVersionEquality("<", "2.0.0"), + ); + + assert.equal(range.testVersion("1.2"), true); + assert.equal(range.testVersion("1.5"), true); + range.combineVersion(new lib.ModVersionEquality("<=", "1.2")); + assert.equal(range.testVersion("1.2"), true); + assert.equal(range.testVersion("1.5"), false); + + assert.equal(range.valid, true); + range.combineVersion(new lib.ModVersionEquality("<=", "0.5")); + assert.equal(range.valid, false); + }); + it("should handle merge with equal", function() { + const range = new lib.ModVersionRange( + new lib.ModVersionEquality(">=", "1.0.0"), + new lib.ModVersionEquality("<", "2.0.0"), + ); + + assert.equal(range.testVersion("1.2"), true); + assert.equal(range.testVersion("1.5"), true); + range.combineVersion(new lib.ModVersionEquality("=", "1.2")); + assert.equal(range.testVersion("1.2"), true); + assert.equal(range.testVersion("1.5"), false); + + assert.equal(range.valid, true); + range.combineVersion(new lib.ModVersionEquality("=", "0.5")); + assert.equal(range.valid, false); + }); + it("should handle merge with greater than equal", function() { + const range = new lib.ModVersionRange( + new lib.ModVersionEquality(">=", "1.0.0"), + new lib.ModVersionEquality("<", "2.0.0"), + ); + + assert.equal(range.testVersion("1.1"), true); + assert.equal(range.testVersion("1.5"), true); + range.combineVersion(new lib.ModVersionEquality(">=", "1.2")); + assert.equal(range.testVersion("1.1"), false); + assert.equal(range.testVersion("1.5"), true); + + assert.equal(range.valid, true); + range.combineVersion(new lib.ModVersionEquality(">=", "2.5")); + assert.equal(range.valid, false); + }); + it("should handle merge with greater than", function() { + const range = new lib.ModVersionRange( + new lib.ModVersionEquality(">=", "1.0.0"), + new lib.ModVersionEquality("<", "2.0.0"), + ); + + assert.equal(range.testVersion("1.2"), true); + assert.equal(range.testVersion("1.5"), true); + range.combineVersion(new lib.ModVersionEquality(">", "1.2")); + assert.equal(range.testVersion("1.2"), false); + assert.equal(range.testVersion("1.5"), true); + + assert.equal(range.valid, true); + range.combineVersion(new lib.ModVersionEquality(">", "2.5")); + assert.equal(range.valid, false); + }); + it("should throw if the equality is invalid (unreachable)", function() { + const range = new lib.ModVersionRange( + new lib.ModVersionEquality(">=", "1.0.0"), + new lib.ModVersionEquality("<", "2.0.0"), + ); + const version = new lib.ModVersionEquality(">", "1.2"); + version.equality = "=="; + assert.throws(() => range.combineVersion(version)); + }); + }); + }); +}); diff --git a/test/messages/mod.test.js b/test/messages/mod.test.js new file mode 100644 index 00000000..42b0d787 --- /dev/null +++ b/test/messages/mod.test.js @@ -0,0 +1,462 @@ +"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, ControllerUser } = 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 = new ControllerUser(controller.userManager, undefined, "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" }), + ], + ], + [[], ["foo-i"], ["foo-i", "bar-i"]], // incompatible + [[], ["foo-m"], ["foo-m", "bar-m"]], // missing + ); + it("should be round trip json serialisable", function() { + testRoundTripJsonSerialisable(ModDependencyResolveRequest.Response, tests); + }); + it("should be construable", function() { + for (const [dependencies, incompatible, missing] of tests) { + const test = JSON.stringify([dependencies, missing, incompatible]); + const request = new ModDependencyResolveRequest.Response(dependencies, incompatible, missing); + assert.deepEqual(request.dependencies, dependencies, test); + assert.deepEqual(request.incompatible, incompatible, test); + assert.deepEqual(request.missing, missing, 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.missing, ["base"]); + assert.deepEqual(result.incompatible, []); + + const expectedIds = [ + "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", + ]; + + const depIds = new Set(result.dependencies.map(mod => mod.id)); + assert.equal(depIds.size, expectedIds.length); + for (const modId of expectedIds) { + assert(depIds.has(modId), `Missing ${modId}`); + } + } + }); + 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.missing, ["base", "bar"]); + assert.deepEqual(result.incompatible, []); + + 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.missing, ["base", "foo"]); + assert.deepEqual(result.incompatible, []); + + 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.missing, ["base"]); + assert.deepEqual(result.incompatible, ["foo-dep"]); + + const expectedIds = ["root_1.0.0", "foo_1.0.0", "foo-dep_1.0.0"]; + + const depIds = new Set(result.dependencies.map(mod => mod.id)); + assert.equal(depIds.size, expectedIds.length); + for (const modId of expectedIds) { + assert(depIds.has(modId), `Missing ${modId}`); + } + } + }); + 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.missing, ["base"]); + assert.deepEqual(result.incompatible, ["dep"]); + + const expectedIds = ["root_1.0.0", "foo_1.0.0", "bar_1.0.0"]; + + const depIds = new Set(result.dependencies.map(mod => mod.id)); + assert.equal(depIds.size, expectedIds.length); + for (const modId of expectedIds) { + assert(depIds.has(modId), `Missing ${modId}`); + } + } + }); + 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.missing, []); + assert.deepEqual(result.incompatible, ["base"]); + + const expectedIds = ["root_1.0.0", "foo_1.0.0"]; + + const depIds = new Set(result.dependencies.map(mod => mod.id)); + assert.equal(depIds.size, expectedIds.length); + for (const modId of expectedIds) { + assert(depIds.has(modId), `Missing ${modId}`); + } + } + }); + 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.missing, ["base"]); + assert.deepEqual(result.incompatible, []); + + const expectedIds = ["root_1.0.0", "foo_1.0.0", "baz_1.0.0"]; + + const depIds = new Set(result.dependencies.map(mod => mod.id)); + assert.equal(depIds.size, expectedIds.length); + for (const modId of expectedIds) { + assert(depIds.has(modId), `Missing ${modId}`); + } + } + }); + 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.missing, ["base"]); + assert.deepEqual(result.incompatible, []); + + const expectedIds = ["root_1.0.0", "foo_1.0.0"]; + + const depIds = new Set(result.dependencies.map(mod => mod.id)); + assert.equal(depIds.size, expectedIds.length); + for (const modId of expectedIds) { + assert(depIds.has(modId), `Missing ${modId}`); + } + } + }); + 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.missing, ["base"]); + assert.deepEqual(result.incompatible, []); + + const expectedIds = ["root_1.0.0", "foo_2.0.0"]; + + const depIds = new Set(result.dependencies.map(mod => mod.id)); + assert.equal(depIds.size, expectedIds.length); + for (const modId of expectedIds) { + assert(depIds.has(modId), `Missing ${modId}`); + } + } + }); + 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.missing, ["base"]); + assert.deepEqual(result.incompatible, []); + + const expectedIds = ["root_1.0.0", "foo_2.0.0", "bar_1.5.0"]; + + const depIds = new Set(result.dependencies.map(mod => mod.id)); + assert.equal(depIds.size, expectedIds.length); + for (const modId of expectedIds) { + assert(depIds.has(modId), `Missing ${modId}`); + } + } + }); + 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.missing, ["base"]); + assert.deepEqual(result.incompatible, ["space-age"]); + + const expectedNames = [ // 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", + ]; + + const depNames = new Set(result.dependencies.map(mod => mod.name)); + assert.equal(depNames.size, expectedNames.length); + for (const modName of expectedNames) { + assert(depNames.has(modName), `Missing ${modName}`); + } + }); + }); + }); +});