diff --git a/packages/controller/src/BaseConnection.ts b/packages/controller/src/BaseConnection.ts index 5c1a2177..ba6ba39c 100644 --- a/packages/controller/src/BaseConnection.ts +++ b/packages/controller/src/BaseConnection.ts @@ -33,6 +33,7 @@ export default class BaseConnection extends lib.Link { this.handle(lib.ModPackGetDefaultRequest, this.handleModPackGetDefaultRequest.bind(this)); this.handle(lib.ModDownloadRequest, this.handleModDownloadRequest.bind(this)); this.handle(lib.FactorioVersionsRequest, this.handleFactorioVersionsRequest.bind(this)); + this.handle(lib.LatestReleasesRequest, this.handleLatestReleasesRequest.bind(this)); this.handle(lib.SubscriptionRequest, this.handleSubscriptionRequest.bind(this)); this.connector.on("close", () => { @@ -113,4 +114,8 @@ export default class BaseConnection extends lib.Link { async handleFactorioVersionsRequest(request: lib.FactorioVersionsRequest) { return await this._controller.factorioVersions.get(request.maxAgeMs); } + + async handleLatestReleasesRequest(request: lib.LatestReleasesRequest) { + return await this._controller.latestReleases.get(request.maxAgeMs); + } } diff --git a/packages/controller/src/Controller.ts b/packages/controller/src/Controller.ts index a62cb353..6dfbe643 100644 --- a/packages/controller/src/Controller.ts +++ b/packages/controller/src/Controller.ts @@ -121,6 +121,28 @@ export default class Controller { } }); + // Cache for the latest stable/experimental factorio releases. Persisted to + // disk so that release channel targets can still be resolved when + // factorio.com is unavailable. + latestReleases = new lib.ValueCache(async () => { + const cachePath = path.join(this.config.get("controller.database_directory"), "latest-releases.json"); + try { + const releases = await lib.fetchLatestReleases(); + await lib.safeOutputFile(cachePath, JSON.stringify(releases, null, "\t")); + return releases; + } catch (err: any) { + logger.warn(`Failed to retrieve Factorio releases ${err.message}`); + try { + return JSON.parse(await fs.readFile(cachePath, "utf8")) as lib.LatestReleases; + } catch (readErr: any) { + if (readErr.code !== "ENOENT") { + logger.warn(`Failed to read cached Factorio releases ${readErr.message}`); + } + return {}; + } + } + }); + static async bootstrap(config: lib.ControllerConfig) { let databaseDirectory = config.get("controller.database_directory"); await fs.mkdir(databaseDirectory, { recursive: true }); diff --git a/packages/host/src/Instance.ts b/packages/host/src/Instance.ts index 073abad1..ac72da58 100644 --- a/packages/host/src/Instance.ts +++ b/packages/host/src/Instance.ts @@ -642,6 +642,21 @@ end`.replace(/\r?\n/g, " "); try { const factorioVersions = await this.sendTo("controller", new lib.FactorioVersionsRequest()); await this._loadStats(); + // Resolve a release channel target (e.g. "stable") to a concrete + // version using the controller's cached latest-releases data, so the + // existing download/lookup logic can fetch and run it. + const targetVersion = this.config.get("factorio.version") as string; + if (lib.isReleaseChannel(targetVersion)) { + const releases = await this.sendTo("controller", new lib.LatestReleasesRequest()); + const resolved = lib.resolveReleaseChannel(releases, targetVersion); + if (!resolved) { + throw new Error( + `Cannot resolve Factorio release channel '${targetVersion}': ` + + "latest-releases unavailable and no cached copy on the controller." + ); + } + this.server.setTargetVersion(resolved); + } await this.server.checkForUpdates(factorioVersions); await this.server.init(); } catch (err) { diff --git a/packages/host/src/server.ts b/packages/host/src/server.ts index c4ecfa05..32d65b52 100644 --- a/packages/host/src/server.ts +++ b/packages/host/src/server.ts @@ -190,7 +190,11 @@ async function findVersion(factorioDir: string, targetVersion: lib.TargetVersion if (targetVersion === "latest" || directVersion === targetVersion || directVersion.startsWith(targetVersion)) { return [path.join(factorioDir, "data"), directVersion]; } - throw new Error(`Unable to find Factorio version ${targetVersion}`); + throw new Error( + `Unable to find Factorio version ${targetVersion}: ${factorioDir} is a direct (single-version) ` + + `install of ${directVersion}. Use a versioned layout, where ${factorioDir} contains a subdirectory ` + + "per version, to run other versions or download them automatically." + ); } // Otherwise assume it is a directory of multiple versions @@ -952,6 +956,18 @@ export class FactorioServer extends events.EventEmitter { } } + /** + * Override the target version of Factorio to run + * + * Used to substitute a concrete version for a release channel target + * (e.g. "stable" or "experimental") before {@link checkForUpdates} and + * {@link init} run. Must be called before the server is initialized. + */ + setTargetVersion(version: lib.TargetVersion) { + this._check(["new"]); + this._targetVersion = version; + } + /** * Initialize class instance * @@ -987,8 +1003,20 @@ export class FactorioServer extends events.EventEmitter { if (!installedVersions.versions.has(latestVersion.version)) { const v = latestVersion.version; // Can't download if not linux or this is a direct install - if (installedVersions.direct || process.platform !== "linux") { - this._logger.info(`A newer version of factorio is available (${v}) but must be manually downloaded`); + if (installedVersions.direct) { + this._logger.info( + `A newer version of factorio is available (${v}) but cannot be downloaded automatically ` + + `because ${this._factorioDir} is a direct (single-version) Factorio install. Use a versioned ` + + `layout, where ${this._factorioDir} contains a subdirectory per version, to enable automatic ` + + "downloads." + ); + return; + } + if (process.platform !== "linux") { + this._logger.info( + `A newer version of factorio is available (${v}) but must be manually downloaded ` + + "(automatic downloads are only supported on Linux)." + ); return; } diff --git a/packages/lib/src/config/definitions.ts b/packages/lib/src/config/definitions.ts index b693de9c..ae2efbdb 100644 --- a/packages/lib/src/config/definitions.ts +++ b/packages/lib/src/config/definitions.ts @@ -508,14 +508,16 @@ export class InstanceConfig extends classes.Config { initialValue: false, }, "factorio.version": { - description: "Version of the game to run, use latest to run the latest installed version", + description: + "Version of the game to run. Use 'latest' for the latest installed version, a release " + + "channel such as 'stable' or 'experimental', or a specific X.Y or X.Y.Z version.", restartRequired: true, type: "string", initialValue: "latest", inputComponent: "target_version", validator: function(value) { if (!isTargetVersion(value)) { - throw new Error("Value must be be 'latest', or match X.Y, or match X.Y.Z"); + throw new Error("Value must be 'latest', a release channel, or match X.Y or X.Y.Z"); } }, }, diff --git a/packages/lib/src/data/ModInfo.ts b/packages/lib/src/data/ModInfo.ts index 7f65b50c..dc164d65 100644 --- a/packages/lib/src/data/ModInfo.ts +++ b/packages/lib/src/data/ModInfo.ts @@ -278,7 +278,12 @@ export default class ModInfo { static validate = libSchema.compile(this.jsonSchema as any); - static fromJSON(json: Static) { + // Accepts the lenient info.json version fields (normalised below) plus the stored + // extras; the strict jsonSchema describes the serialised form produced by toJSON. + static fromJSON(json: + Static + & Pick, "size" | "mtime_ms" | "sha1" | "updated_at_ms" | "is_deleted"> + ) { const modInfo = new this(); // info.json fields diff --git a/packages/lib/src/data/messages_controller.ts b/packages/lib/src/data/messages_controller.ts index 69ffb3a0..9075cfb9 100644 --- a/packages/lib/src/data/messages_controller.ts +++ b/packages/lib/src/data/messages_controller.ts @@ -2,7 +2,7 @@ import { Type, Static } from "@sinclair/typebox"; import { JsonString, StringEnum, StringKey, plainJson } from "./composites"; import { levels } from "../logging"; import { ControllerConfig, HostConfig } from "../config"; -import { ExternalFactorioVersionSchema } from "../external"; +import { ExternalFactorioVersionSchema, LatestReleasesSchema } from "../external"; export class ControllerStopRequest { declare ["constructor"]: typeof ControllerStopRequest; @@ -442,3 +442,25 @@ export class FactorioVersionsRequest { static Response = plainJson(Type.Array(ExternalFactorioVersionSchema)); } + +export class LatestReleasesRequest { + declare ["constructor"]: typeof LatestReleasesRequest; + static type = "request" as const; + static src = ["control", "instance"] as const; + static dst = "controller" as const; + static permission = "core.external.get_latest_releases" as const; + + constructor( + public maxAgeMs: number = 5 * 60 * 1000, // Default 5 minutes + ) {} + + static jsonSchema = Type.Object({ + "maxAgeMs": Type.Number(), + }); + + static fromJSON(json: Static) { + return new this(json.maxAgeMs); + } + + static Response = plainJson(LatestReleasesSchema); +} diff --git a/packages/lib/src/data/version.ts b/packages/lib/src/data/version.ts index dc6c925f..9b04f8b2 100644 --- a/packages/lib/src/data/version.ts +++ b/packages/lib/src/data/version.ts @@ -120,16 +120,30 @@ export function normaliseMajorMinorVersion(version: PartialVersion): MajorMinorV } /** - * Matches valid factorio target versions, this is the same as partial but also accepts "latest". + * Matches a release channel name from the latest-releases API, such as + * "stable" or "experimental". Excludes "latest", which is handled separately. + * The set of channels is dynamic, so any lowercase identifier is accepted here + * and resolved against the live API when the instance starts. + */ +const releaseChannelRegExp = /^[a-z][a-z0-9-]*$/; + +export function isReleaseChannel(input: string): boolean { + return input !== "latest" && releaseChannelRegExp.test(input); +} + +/** + * Matches valid factorio target versions: a partial version, "latest", or a + * release channel name (e.g. "stable" or "experimental"). */ export type TargetVersion = PartialVersion | "latest"; export const TargetVersionSchema = Type.Union([ PartialVersionSchema, Type.Literal("latest"), + Type.Unsafe(Type.String({ pattern: releaseChannelRegExp.source })), ]); export function isTargetVersion(input: string): input is TargetVersion { - return input === "latest" || partialVersionRegExp.test(input); + return input === "latest" || isReleaseChannel(input) || partialVersionRegExp.test(input); } /** diff --git a/packages/lib/src/external/LatestReleases.ts b/packages/lib/src/external/LatestReleases.ts new file mode 100644 index 00000000..91a0f322 --- /dev/null +++ b/packages/lib/src/external/LatestReleases.ts @@ -0,0 +1,62 @@ +import { Static, Type } from "@sinclair/typebox"; +import { FullVersion, FullVersionSchema } from "../data/version"; + +const LATEST_RELEASES_URL = "https://factorio.com/api/latest-releases"; + +/** + * The set of builds (e.g. alpha, demo, headless, expansion) and their version + * for a single release channel. + */ +export const LatestReleaseBuildsSchema = Type.Record(Type.String(), FullVersionSchema); + +/** + * The latest released version of each build, keyed by release channel name + * (e.g. "stable" and "experimental"). + */ +export const LatestReleasesSchema = Type.Record(Type.String(), LatestReleaseBuildsSchema); + +export type LatestReleases = Static + +/** + * Fetch the latest stable and experimental factorio releases + * + * @returns The latest version of each build keyed by release channel + * @throws Network errors and non-ok status responses + */ +export async function fetchLatestReleases(): Promise { + let res; + + try { + res = await fetch(LATEST_RELEASES_URL); + } catch (err) { + throw new Error(`Failed to fetch Factorio releases from ${LATEST_RELEASES_URL}: ${err}`); + } + + if (!res.ok) { + throw new Error( + `Failed to fetch Factorio releases from ${LATEST_RELEASES_URL}: HTTP ${res.status} ${res.statusText}` + ); + } + + return await res.json() as LatestReleases; +} + +/** + * Resolve a release channel to a concrete factorio version + * + * @param releases Releases data as returned by {@link fetchLatestReleases} + * @param channel Release channel name, e.g. "stable" or "experimental" + * @param build Build to read the version of, defaults to the headless build + * @returns The version for the channel, or undefined if it cannot be resolved + */ +export function resolveReleaseChannel( + releases: LatestReleases, + channel: string, + build = "headless", +): FullVersion | undefined { + const builds = releases[channel]; + if (!builds) { + return undefined; + } + return (builds[build] ?? Object.values(builds)[0]) as FullVersion | undefined; +} diff --git a/packages/lib/src/external/index.ts b/packages/lib/src/external/index.ts index 92cc1f2e..0bd258c4 100644 --- a/packages/lib/src/external/index.ts +++ b/packages/lib/src/external/index.ts @@ -3,3 +3,4 @@ * @module lib/external */ export * from "./FactorioVersions"; +export * from "./LatestReleases"; diff --git a/packages/lib/src/link/messages.ts b/packages/lib/src/link/messages.ts index 419385ad..d5aaa4d3 100644 --- a/packages/lib/src/link/messages.ts +++ b/packages/lib/src/link/messages.ts @@ -37,6 +37,7 @@ export const dataClasses: (RequestClass | EventClass) controller.DebugDumpWsRequest, controller.DebugWsMessageEvent, controller.FactorioVersionsRequest, + controller.LatestReleasesRequest, subscriptions.SubscriptionRequest, diff --git a/packages/lib/src/permissions.ts b/packages/lib/src/permissions.ts index a93fd0c6..ce1bb44c 100644 --- a/packages/lib/src/permissions.ts +++ b/packages/lib/src/permissions.ts @@ -524,3 +524,9 @@ definePermission({ title: "Get factorio versions", description: "Get list of all factorio versions from Wube", }); + +definePermission({ + name: "core.external.get_latest_releases", + title: "Get factorio releases", + description: "Get the latest stable and experimental factorio releases from Wube", +}); diff --git a/packages/web_ui/src/components/AssignInstanceModal.tsx b/packages/web_ui/src/components/AssignInstanceModal.tsx index 6cb19383..8c1ba5aa 100644 --- a/packages/web_ui/src/components/AssignInstanceModal.tsx +++ b/packages/web_ui/src/components/AssignInstanceModal.tsx @@ -67,7 +67,7 @@ export default function AssignInstanceModal(props: AssignInstanceModalProps) { confirmLoading={applying} onOk={handleAssign} onCancel={handleCancel} - destroyOnClose + destroyOnHidden > Select a Host to assign this instance to. Assignment diff --git a/packages/web_ui/src/components/CreateSaveModal.tsx b/packages/web_ui/src/components/CreateSaveModal.tsx index 67dd7b61..8025af17 100644 --- a/packages/web_ui/src/components/CreateSaveModal.tsx +++ b/packages/web_ui/src/components/CreateSaveModal.tsx @@ -106,7 +106,7 @@ export default function CreateSaveModal(props: CreateSaveModalProps) { confirmLoading={creatingSave} onOk={createSave} onCancel={handleCancel} - destroyOnClose + destroyOnHidden >
diff --git a/packages/web_ui/src/components/InputVersion.tsx b/packages/web_ui/src/components/InputVersion.tsx index bcb7beeb..e42bf1f4 100644 --- a/packages/web_ui/src/components/InputVersion.tsx +++ b/packages/web_ui/src/components/InputVersion.tsx @@ -23,6 +23,7 @@ function InputVersion< ) { const fieldDefinition = props.fieldDefinition || defaultFieldDefinition; const [versions, setVersions] = useState(lib.ApiVersions); + const [channels, setChannels] = useState<{ name: string, version: string }[]>([]); const [customVersion, setCustomVersion] = useState(""); const [open, setOpen] = useState(false); const inputRef = useRef(null); @@ -39,6 +40,21 @@ function InputVersion< })(); }, [control]); + // Release channels (e.g. stable, experimental) only apply to target versions + useEffect(() => { + (async () => { + if (props.version.includeLatest && account.hasPermission("core.external.get_latest_releases")) { + const res = await control.latestReleases.get(5 * 60 * 1000); + // Read the headless build's version directly rather than via a lib + // helper: lib is a federation-shared module here and unused exports + // can be tree-shaken out of it. + setChannels(Object.entries(res).map(([name, builds]) => ( + { name, version: builds.headless ?? Object.values(builds)[0] ?? "" } + ))); + } + })(); + }, [control]); + // Split the versions into groups based on major minor const groups = new Map(); @@ -58,7 +74,10 @@ function InputVersion< title: majorMinor, value: majorMinor, key: majorMinor, - children: patchVersions.map((v) => ({ + // Skip a patch equal to the group key (a bare major.minor version, + // e.g. the ApiVersions fallback): the selectable group node already + // represents it, and a duplicate value warns in the TreeSelect. + children: patchVersions.filter((v) => v !== majorMinor).map((v) => ({ title: v, value: v, key: v, @@ -66,9 +85,17 @@ function InputVersion< } )); - // Add "latest" option for TargetVersion + // Add "latest" and release channel options for TargetVersion if (props.version.includeLatest) { - tree.unshift({ title: "latest", value: "latest", key: "latest", children: [] }); + tree.unshift( + { title: "latest", value: "latest", key: "latest", children: [] }, + ...channels.map(({ name, version }) => ({ + title: version ? `${name} (${version})` : name, + value: name, + key: name, + children: [], + })), + ); } return { createInstance().catch(notifyErrorHandler("Error creating instance")); }} onCancel={() => { setOpen(false); }} - destroyOnClose + destroyOnHidden > diff --git a/packages/web_ui/src/components/LoadScenarioModal.tsx b/packages/web_ui/src/components/LoadScenarioModal.tsx index 15a03ce6..b83edb6c 100644 --- a/packages/web_ui/src/components/LoadScenarioModal.tsx +++ b/packages/web_ui/src/components/LoadScenarioModal.tsx @@ -103,7 +103,7 @@ export default function LoadScenarioModal(props: LoadScenarioModalProps) { confirmLoading={loadingScenario} onOk={loadScenario} onCancel={handleCancel} - destroyOnClose + destroyOnHidden > diff --git a/packages/web_ui/src/components/ModPackViewPage.tsx b/packages/web_ui/src/components/ModPackViewPage.tsx index 4a823081..598447ff 100644 --- a/packages/web_ui/src/components/ModPackViewPage.tsx +++ b/packages/web_ui/src/components/ModPackViewPage.tsx @@ -1053,7 +1053,7 @@ function ExportButton(props: { modPack: lib.ModPack }) { open={open} onOk={close} onCancel={close} - destroyOnClose + destroyOnHidden footer={ diff --git a/packages/web_ui/src/components/NpmButton.tsx b/packages/web_ui/src/components/NpmButton.tsx index aa01c7b4..1a5a83e2 100644 --- a/packages/web_ui/src/components/NpmButton.tsx +++ b/packages/web_ui/src/components/NpmButton.tsx @@ -170,7 +170,7 @@ export function NpmButton(props: { okButtonProps={{disabled: formAction === undefined}} onOk={() => { onOk().catch(notifyErrorHandler(`Error running ${formAction}`)); }} onCancel={() => { setOpen(false); }} - destroyOnClose + destroyOnHidden > diff --git a/packages/web_ui/src/components/RolesPage.tsx b/packages/web_ui/src/components/RolesPage.tsx index a44bd2e1..1d01f847 100644 --- a/packages/web_ui/src/components/RolesPage.tsx +++ b/packages/web_ui/src/components/RolesPage.tsx @@ -47,7 +47,7 @@ function CreateRoleButton() { open={open} onOk={() => { createRole().catch(notifyErrorHandler("Error creating role")); }} onCancel={() => { setOpen(false); }} - destroyOnClose + destroyOnHidden > diff --git a/packages/web_ui/src/components/SavesList.tsx b/packages/web_ui/src/components/SavesList.tsx index 9089335a..d4a84465 100644 --- a/packages/web_ui/src/components/SavesList.tsx +++ b/packages/web_ui/src/components/SavesList.tsx @@ -119,7 +119,7 @@ function TransferModal(props: ModalProps) { open={open} onOk={() => form.submit()} onCancel={() => setOpen(false)} - destroyOnClose + destroyOnHidden > { createUser().catch(notifyErrorHandler("Error creating user")); }} onCancel={() => { setOpen(false); }} - destroyOnClose + destroyOnHidden > @@ -345,7 +345,7 @@ function BulkUserActionButton() { okButtonProps={{ disabled: formAction === undefined }} onOk={() => { onOk().catch(notifyErrorHandler(`Error running ${formAction}`)); }} onCancel={() => { setOpen(false); }} - destroyOnClose + destroyOnHidden > diff --git a/packages/web_ui/src/util/websocket.ts b/packages/web_ui/src/util/websocket.ts index 05747892..44069956 100644 --- a/packages/web_ui/src/util/websocket.ts +++ b/packages/web_ui/src/util/websocket.ts @@ -72,6 +72,9 @@ export class Control extends lib.Link { /** Cache for factorio versions to avoid repeat calls to the controller */ factorioVersions = new lib.ValueCache(this.requestFactorioVersions.bind(this)); + /** Cache for the latest factorio release channels to avoid repeat calls to the controller */ + latestReleases = new lib.ValueCache(this.requestLatestReleases.bind(this)); + declare connector: ControlConnector; constructor( @@ -112,6 +115,10 @@ export class Control extends lib.Link { return this.send(new lib.FactorioVersionsRequest()); } + requestLatestReleases() { + return this.send(new lib.LatestReleasesRequest()); + } + async handleAccountUpdateEvent(event: lib.AccountUpdateEvent) { this.accountRoles = event.roles??null; this.emitAccountUpdate(); diff --git a/test/host/server.js b/test/host/server.js index ea19b133..adbec5e1 100644 --- a/test/host/server.js +++ b/test/host/server.js @@ -62,7 +62,11 @@ describe("host/server", function() { let installDir = path.join("test", "file", "factorio", "0.1.1"); await assert.rejects( hostServer._findVersion(installDir, "0.1.2"), - new Error("Unable to find Factorio version 0.1.2") + new Error( + `Unable to find Factorio version 0.1.2: ${installDir} is a direct (single-version) ` + + `install of 0.1.1. Use a versioned layout, where ${installDir} contains a subdirectory ` + + "per version, to run other versions or download them automatically." + ) ); }); }); @@ -229,6 +233,16 @@ describe("host/server", function() { }); }); + describe(".setTargetVersion()", function() { + it("should override the target version before init", function() { + const fresh = new hostServer.FactorioServer( + path.join("test", "file", "factorio"), writePath, {} + ); + fresh.setTargetVersion("0.1.5"); + assert.equal(fresh._targetVersion, "0.1.5"); + }); + }); + describe(".init()", function() { it("should not throw on first call", async function() { await server.init(); @@ -433,7 +447,7 @@ describe("host/server", function() { assert.equal(fetchCalledWith, null); assert.ok(logLine !== null); - assert.ok(logLine.endsWith("but must be manually downloaded")); + assert.ok(logLine.endsWith("(automatic downloads are only supported on Linux).")); }); it("should do attempt to download on linux", async function() { let logLine = null; diff --git a/test/integration/clusterio.js b/test/integration/clusterio.js index e44f673d..f1aa80c5 100644 --- a/test/integration/clusterio.js +++ b/test/integration/clusterio.js @@ -704,6 +704,28 @@ describe("Integration of Clusterio", function() { await execCtl("instance config set 44 factorio.version latest"); } }); + it("should resolve a release channel target when starting", async function() { + slowTest(this); + try { + // "experimental" is resolved to a concrete version through the + // controller's latest-releases data before the server starts. + // Whether that version is installed (or downloadable) depends on + // the host's Factorio install - CI uses a fixed direct install - + // so the start may succeed or fail, but the channel must resolve + // and the instance must reach a terminal state rather than hang. + await execCtl("instance config set 44 factorio.version experimental"); + await execCtl(`instance ${cmd} test`).catch(() => {}); + const status = (await getInstances()).get(44).status; + assert( + ["running", "stopped"].includes(status), + `instance stuck in unexpected state '${status}'` + ); + + } finally { + await execCtl("instance stop 44").catch(() => {}); + await execCtl("instance config set 44 factorio.version latest"); + } + }); it("should not leave the instance in the stopping state if it fails", async function() { slowTest(this); try { diff --git a/test/lib/config/validators.test.js b/test/lib/config/validators.test.js index 949f3d0f..d77a5b50 100644 --- a/test/lib/config/validators.test.js +++ b/test/lib/config/validators.test.js @@ -203,7 +203,7 @@ describe("lib/config/definitions/validators", function() { // invalid case assert.throws( () => config.set("factorio.version", "invalidValue"), - /Value must be be 'latest', or match X.Y, or match X.Y.Z/ + /Value must be 'latest', a release channel, or match X.Y or X.Y.Z/ ); }); diff --git a/test/lib/external/LatestReleases.test.js b/test/lib/external/LatestReleases.test.js new file mode 100644 index 00000000..01d1d0df --- /dev/null +++ b/test/lib/external/LatestReleases.test.js @@ -0,0 +1,88 @@ +const assert = require("assert").strict; + +const { fetchLatestReleases, resolveReleaseChannel } = require("@clusterio/lib"); +const { slowTest } = require("../../integration"); + +describe("LatestReleases", function() { + describe("fetchLatestReleases", function() { + const originalFetch = global.fetch; + + beforeEach(() => { + global.fetch = undefined; + }); + + afterEach(() => { + global.fetch = originalFetch; + }); + + const sample = { + experimental: { alpha: "2.1.8", demo: "2.0.77", expansion: "2.1.8", headless: "2.1.8" }, + stable: { alpha: "2.0.77", demo: "2.0.77", expansion: "2.0.77", headless: "2.0.77" }, + }; + + function mockFetch(json) { + global.fetch = async () => ({ + ok: true, + status: 200, + statusText: "OK", + json: async () => json, + }); + } + + it("fetches and returns the releases json", async function() { + mockFetch(sample); + + const releases = await fetchLatestReleases(); + assert.deepEqual(releases, sample); + }); + it("throws on HTTP error status", async function() { + global.fetch = async () => ({ + ok: false, + status: 500, + statusText: "Server Error", + json: async () => ({}), + }); + + await assert.rejects(fetchLatestReleases(), /HTTP 500 Server Error/); + }); + it("throws on network error", async function() { + global.fetch = async () => { + throw new Error("Network Error"); + }; + + await assert.rejects(fetchLatestReleases(), /Network Error/); + }); + it("fetches from the live api", async function() { + slowTest(this); + global.fetch = originalFetch; + + const releases = await fetchLatestReleases(); + assert.ok(releases.stable, "expected a stable channel"); + assert.ok(releases.experimental, "expected an experimental channel"); + assert.equal(typeof releases.stable.headless, "string"); + }); + }); + + describe("resolveReleaseChannel", function() { + const releases = { + experimental: { alpha: "2.1.8", demo: "2.0.77", headless: "2.1.8" }, + stable: { alpha: "2.0.77", headless: "2.0.77" }, + // A channel without a headless build, to exercise the fallback. + oddball: { alpha: "1.2.3" }, + }; + + it("returns the headless build version by default", function() { + assert.equal(resolveReleaseChannel(releases, "stable"), "2.0.77"); + assert.equal(resolveReleaseChannel(releases, "experimental"), "2.1.8"); + }); + it("returns the requested build version", function() { + assert.equal(resolveReleaseChannel(releases, "experimental", "demo"), "2.0.77"); + }); + it("falls back to the first build when headless is missing", function() { + assert.equal(resolveReleaseChannel(releases, "oddball"), "1.2.3"); + }); + it("returns undefined for an unknown channel", function() { + assert.equal(resolveReleaseChannel(releases, "nightly"), undefined); + }); + }); +}); diff --git a/test/messages/controller.test.js b/test/messages/controller.test.js index 3042f82e..de74d61c 100644 --- a/test/messages/controller.test.js +++ b/test/messages/controller.test.js @@ -1,5 +1,8 @@ "use strict"; const assert = require("assert").strict; +const fs = require("node:fs/promises"); +const os = require("node:os"); +const path = require("node:path"); const lib = require("@clusterio/lib"); const { Controller, ControlConnection } = require("@clusterio/controller"); @@ -416,5 +419,50 @@ describe("messages/controller", function() { assert.deepEqual(versions3, versions); assert.equal(callCount, 2); }); + it("round trips LatestReleasesRequest", function() { + testRoundTripJsonSerialisable(lib.LatestReleasesRequest, [[], [0], [60000]]); + }); + it("caches the latest releases and persists them for offline use", async function() { + const releases = { + stable: { headless: "2.0.77" }, + experimental: { headless: "2.1.8" }, + }; + const databaseDirectory = await fs.mkdtemp(path.join(os.tmpdir(), "clusterio-test-")); + controller.config.set("controller.database_directory", databaseDirectory); + const cachePath = path.join(databaseDirectory, "latest-releases.json"); + const originalFetch = global.fetch; + try { + let callCount = 0; + global.fetch = async () => { + callCount += 1; + return { ok: true, status: 200, statusText: "OK", json: async () => releases }; + }; + + // First call fetches from the api and persists the result to disk + const fetched = await controlConnection.handleLatestReleasesRequest( + new lib.LatestReleasesRequest(0) + ); + assert.deepEqual(fetched, releases); + assert.equal(callCount, 1); + assert.deepEqual(JSON.parse(await fs.readFile(cachePath, "utf8")), releases); + + // When the api is unavailable the persisted copy is served instead + global.fetch = async () => { throw new Error("Network Error"); }; + const cached = await controlConnection.handleLatestReleasesRequest( + new lib.LatestReleasesRequest(0) + ); + assert.deepEqual(cached, releases); + + // With no persisted copy and an unavailable api an empty object is returned + await fs.rm(cachePath); + const empty = await controlConnection.handleLatestReleasesRequest( + new lib.LatestReleasesRequest(0) + ); + assert.deepEqual(empty, {}); + } finally { + global.fetch = originalFetch; + await fs.rm(databaseDirectory, { recursive: true, force: true }); + } + }); }); });