From 9da48d5387b9b80e27ba840c7c467f51e837c837 Mon Sep 17 00:00:00 2001 From: bbassie <17990055+bbassie@users.noreply.github.com> Date: Fri, 26 Jun 2026 14:03:53 +0000 Subject: [PATCH 1/4] Add release channels as instance version targets An instance's factorio.version can now target a release channel such as "stable" or "experimental" (any key from factorio.com/api/latest-releases) in addition to "latest" and specific X.Y / X.Y.Z versions. The controller proxies the latest-releases API through a ValueCache and persists the last successful response to latest-releases.json, serving that copy when the API is unreachable. On instance start the host resolves a channel to the concrete version it points to and feeds it into the existing checkForUpdates/findVersion path, so a channel auto-installs and runs like a pinned version. The version selector in the web UI lists the available channels alongside "latest". Also clarifies the host log/error when a newer version cannot be downloaded automatically because the host uses a direct (single-version) Factorio install rather than a versioned directory. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/controller/src/BaseConnection.ts | 5 ++ packages/controller/src/Controller.ts | 22 +++++++ packages/host/src/Instance.ts | 15 +++++ packages/host/src/server.ts | 34 +++++++++- packages/lib/src/config/definitions.ts | 6 +- packages/lib/src/data/messages_controller.ts | 24 ++++++- packages/lib/src/data/version.ts | 18 +++++- packages/lib/src/external/LatestReleases.ts | 62 +++++++++++++++++++ packages/lib/src/external/index.ts | 1 + packages/lib/src/link/messages.ts | 1 + packages/lib/src/permissions.ts | 6 ++ .../web_ui/src/components/InputVersion.tsx | 28 ++++++++- packages/web_ui/src/util/websocket.ts | 7 +++ test/host/server.js | 8 ++- test/lib/config/validators.test.js | 2 +- 15 files changed, 226 insertions(+), 13 deletions(-) create mode 100644 packages/lib/src/external/LatestReleases.ts 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/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 63297106..22ffdcd1 100644 --- a/packages/lib/src/data/version.ts +++ b/packages/lib/src/data/version.ts @@ -88,16 +88,30 @@ export function integerPartialVersion(version: PartialVersion) { } /** - * 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/InputVersion.tsx b/packages/web_ui/src/components/InputVersion.tsx index bcb7beeb..1648e546 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(); @@ -66,9 +82,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 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/ ); }); From f793ec33b317e8bc65923e260acab668d55f84df Mon Sep 17 00:00:00 2001 From: bbassie <17990055+bbassie@users.noreply.github.com> Date: Fri, 26 Jun 2026 15:10:06 +0000 Subject: [PATCH 2/4] Add tests for release channel resolution and caching Cover fetchLatestReleases and resolveReleaseChannel, the controller's latest-releases cache with its on-disk offline fallback, the LatestReleasesRequest round-trip, and FactorioServer.setTargetVersion. Co-Authored-By: Claude Opus 4.8 (1M context) --- test/host/server.js | 10 +++ test/lib/external/LatestReleases.test.js | 88 ++++++++++++++++++++++++ test/messages/controller.test.js | 48 +++++++++++++ 3 files changed, 146 insertions(+) create mode 100644 test/lib/external/LatestReleases.test.js diff --git a/test/host/server.js b/test/host/server.js index 42ecbb1c..adbec5e1 100644 --- a/test/host/server.js +++ b/test/host/server.js @@ -233,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(); 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 }); + } + }); }); }); From dfe132699e7d06304c530d45e904e955e18ea983 Mon Sep 17 00:00:00 2001 From: bbassie <17990055+bbassie@users.noreply.github.com> Date: Fri, 26 Jun 2026 15:38:29 +0000 Subject: [PATCH 3/4] Add integration test for starting an instance with a release channel Configures an instance with the "experimental" channel and starts it, exercising channel resolution end to end across the controller and host. The resolved version may or may not be installed depending on the host's Factorio install, so the test asserts the instance reaches a terminal state rather than a specific outcome. Co-Authored-By: Claude Opus 4.8 (1M context) --- test/integration/clusterio.js | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) 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 { From a56fcbd785b43e8093ccdc78b1096d74c8a687ed Mon Sep 17 00:00:00 2001 From: Bastiaan <17990055+bbassie@users.noreply.github.com> Date: Fri, 26 Jun 2026 18:21:52 +0200 Subject: [PATCH 4/4] Fix antd deprecation and TreeSelect duplicate-value warnings (#940) Rename the deprecated Modal `destroyOnClose` prop to `destroyOnHidden` (antd 5.25+) across the web UI. Skip a version-tree child equal to its major.minor group key in InputVersion, which triggered "Same value exist in the tree" warnings when the version list contained bare major.minor entries (the ApiVersions fallback shown before the version fetch resolves). Co-authored-by: Claude Opus 4.8 (1M context) --- packages/web_ui/src/components/AssignInstanceModal.tsx | 2 +- packages/web_ui/src/components/CreateSaveModal.tsx | 2 +- packages/web_ui/src/components/InputVersion.tsx | 5 ++++- packages/web_ui/src/components/InstancesPage.tsx | 2 +- packages/web_ui/src/components/LoadScenarioModal.tsx | 2 +- packages/web_ui/src/components/ModPackViewPage.tsx | 2 +- packages/web_ui/src/components/NpmButton.tsx | 2 +- packages/web_ui/src/components/RolesPage.tsx | 2 +- packages/web_ui/src/components/SavesList.tsx | 2 +- packages/web_ui/src/components/UsersPage.tsx | 4 ++-- 10 files changed, 14 insertions(+), 11 deletions(-) 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 1648e546..e42bf1f4 100644 --- a/packages/web_ui/src/components/InputVersion.tsx +++ b/packages/web_ui/src/components/InputVersion.tsx @@ -74,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, diff --git a/packages/web_ui/src/components/InstancesPage.tsx b/packages/web_ui/src/components/InstancesPage.tsx index cfa24922..b62f6970 100644 --- a/packages/web_ui/src/components/InstancesPage.tsx +++ b/packages/web_ui/src/components/InstancesPage.tsx @@ -53,7 +53,7 @@ function CreateInstanceButton(props: { instances: ReturnType { 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 d2640d18..b6327a72 100644 --- a/packages/web_ui/src/components/ModPackViewPage.tsx +++ b/packages/web_ui/src/components/ModPackViewPage.tsx @@ -1052,7 +1052,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 >