mirror of
https://github.com/clusterio/clusterio.git
synced 2026-11-12 23:04:52 +09:00
Merge remote-tracking branch 'upstream/master' into feature/lenient-mod-version-parsing
# Conflicts: # packages/lib/src/data/version.ts
This commit is contained in:
commit
e27ad5fdf9
28 files changed
+414
-25
No files matched your search
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<FactorioServerEvents> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<FactorioServerEvents> {
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -508,14 +508,16 @@ export class InstanceConfig extends classes.Config<InstanceConfigFields> {
|
||||
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");
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
@@ -278,7 +278,12 @@ export default class ModInfo {
|
||||
|
||||
static validate = libSchema.compile(this.jsonSchema as any);
|
||||
|
||||
static fromJSON(json: Static<typeof ModInfo.jsonSchema>) {
|
||||
// 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<typeof ModInfo.infoJsonSchema>
|
||||
& Pick<Static<typeof ModInfo.jsonSchema>, "size" | "mtime_ms" | "sha1" | "updated_at_ms" | "is_deleted">
|
||||
) {
|
||||
const modInfo = new this();
|
||||
|
||||
// info.json fields
|
||||
|
||||
@@ -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<typeof this.jsonSchema>) {
|
||||
return new this(json.maxAgeMs);
|
||||
}
|
||||
|
||||
static Response = plainJson(LatestReleasesSchema);
|
||||
}
|
||||
@@ -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<TargetVersion>(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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+62
@@ -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<typeof LatestReleasesSchema>
|
||||
|
||||
/**
|
||||
* 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<LatestReleases> {
|
||||
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;
|
||||
}
|
||||
Vendored
+1
@@ -3,3 +3,4 @@
|
||||
* @module lib/external
|
||||
*/
|
||||
export * from "./FactorioVersions";
|
||||
export * from "./LatestReleases";
|
||||
@@ -37,6 +37,7 @@ export const dataClasses: (RequestClass<unknown, unknown> | EventClass<unknown>)
|
||||
controller.DebugDumpWsRequest,
|
||||
controller.DebugWsMessageEvent,
|
||||
controller.FactorioVersionsRequest,
|
||||
controller.LatestReleasesRequest,
|
||||
|
||||
subscriptions.SubscriptionRequest,
|
||||
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
@@ -67,7 +67,7 @@ export default function AssignInstanceModal(props: AssignInstanceModalProps) {
|
||||
confirmLoading={applying}
|
||||
onOk={handleAssign}
|
||||
onCancel={handleCancel}
|
||||
destroyOnClose
|
||||
destroyOnHidden
|
||||
>
|
||||
<Paragraph style={{ maxWidth: "30em" }}>
|
||||
Select a Host to assign this instance to. Assignment
|
||||
|
||||
@@ -106,7 +106,7 @@ export default function CreateSaveModal(props: CreateSaveModalProps) {
|
||||
confirmLoading={creatingSave}
|
||||
onOk={createSave}
|
||||
onCancel={handleCancel}
|
||||
destroyOnClose
|
||||
destroyOnHidden
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item name="saveName" label="Save Name">
|
||||
|
||||
@@ -23,6 +23,7 @@ function InputVersion<
|
||||
) {
|
||||
const fieldDefinition = props.fieldDefinition || defaultFieldDefinition;
|
||||
const [versions, setVersions] = useState<readonly string[]>(lib.ApiVersions);
|
||||
const [channels, setChannels] = useState<{ name: string, version: string }[]>([]);
|
||||
const [customVersion, setCustomVersion] = useState("");
|
||||
const [open, setOpen] = useState(false);
|
||||
const inputRef = useRef<InputRef>(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<string, string[]>();
|
||||
|
||||
@@ -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 <TreeSelect
|
||||
|
||||
@@ -53,7 +53,7 @@ function CreateInstanceButton(props: { instances: ReturnType<typeof useInstances
|
||||
open={open}
|
||||
onOk={() => { createInstance().catch(notifyErrorHandler("Error creating instance")); }}
|
||||
onCancel={() => { setOpen(false); }}
|
||||
destroyOnClose
|
||||
destroyOnHidden
|
||||
>
|
||||
<Form form={form}>
|
||||
<Form.Item name="instanceName" label="Name">
|
||||
|
||||
@@ -103,7 +103,7 @@ export default function LoadScenarioModal(props: LoadScenarioModalProps) {
|
||||
confirmLoading={loadingScenario}
|
||||
onOk={loadScenario}
|
||||
onCancel={handleCancel}
|
||||
destroyOnClose
|
||||
destroyOnHidden
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item name="scenario" label="Scenario">
|
||||
|
||||
@@ -1053,7 +1053,7 @@ function ExportButton(props: { modPack: lib.ModPack }) {
|
||||
open={open}
|
||||
onOk={close}
|
||||
onCancel={close}
|
||||
destroyOnClose
|
||||
destroyOnHidden
|
||||
footer={<Space>
|
||||
<CopyButton content={exportString} />
|
||||
<Button onClick={close}>Close</Button>
|
||||
|
||||
@@ -170,7 +170,7 @@ export function NpmButton(props: {
|
||||
okButtonProps={{disabled: formAction === undefined}}
|
||||
onOk={() => { onOk().catch(notifyErrorHandler(`Error running ${formAction}`)); }}
|
||||
onCancel={() => { setOpen(false); }}
|
||||
destroyOnClose
|
||||
destroyOnHidden
|
||||
>
|
||||
<Form form={form} onValuesChange={onValuesChange} clearOnDestroy>
|
||||
<Form.Item label="Action" name="action">
|
||||
|
||||
@@ -47,7 +47,7 @@ function CreateRoleButton() {
|
||||
open={open}
|
||||
onOk={() => { createRole().catch(notifyErrorHandler("Error creating role")); }}
|
||||
onCancel={() => { setOpen(false); }}
|
||||
destroyOnClose
|
||||
destroyOnHidden
|
||||
>
|
||||
<Form form={form}>
|
||||
<Form.Item name="roleName" label="Name">
|
||||
|
||||
@@ -119,7 +119,7 @@ function TransferModal(props: ModalProps) {
|
||||
open={open}
|
||||
onOk={() => form.submit()}
|
||||
onCancel={() => setOpen(false)}
|
||||
destroyOnClose
|
||||
destroyOnHidden
|
||||
>
|
||||
<Form
|
||||
form={form}
|
||||
|
||||
@@ -50,7 +50,7 @@ function CreateUserButton() {
|
||||
open={open}
|
||||
onOk={() => { createUser().catch(notifyErrorHandler("Error creating user")); }}
|
||||
onCancel={() => { setOpen(false); }}
|
||||
destroyOnClose
|
||||
destroyOnHidden
|
||||
>
|
||||
<Form form={form}>
|
||||
<Form.Item name="userName" label="Name">
|
||||
@@ -345,7 +345,7 @@ function BulkUserActionButton() {
|
||||
okButtonProps={{ disabled: formAction === undefined }}
|
||||
onOk={() => { onOk().catch(notifyErrorHandler(`Error running ${formAction}`)); }}
|
||||
onCancel={() => { setOpen(false); }}
|
||||
destroyOnClose
|
||||
destroyOnHidden
|
||||
>
|
||||
<Form form={form} onValuesChange={onValuesChange} clearOnDestroy>
|
||||
<Form.Item label="Action" name="action">
|
||||
|
||||
@@ -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();
|
||||
|
||||
+16
-2
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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/
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
+88
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user