Merge remote-tracking branch 'upstream/master' into feature/lenient-mod-version-parsing

# Conflicts:
#	packages/lib/src/data/version.ts
This commit is contained in:
bbassie committed 2026-06-29 22:57:48 +00:00
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);
}
}
+22
View File
@@ -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 });
+15
View File
@@ -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) {
+31 -3
View File
@@ -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;
}
+4 -2
View File
@@ -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");
}
},
},
+6 -1
View File
@@ -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
+23 -1
View File
@@ -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);
}
+16 -2
View File
@@ -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
View File
@@ -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;
}
+1
View File
@@ -3,3 +3,4 @@
* @module lib/external
*/
export * from "./FactorioVersions";
export * from "./LatestReleases";
+1
View File
@@ -37,6 +37,7 @@ export const dataClasses: (RequestClass<unknown, unknown> | EventClass<unknown>)
controller.DebugDumpWsRequest,
controller.DebugWsMessageEvent,
controller.FactorioVersionsRequest,
controller.LatestReleasesRequest,
subscriptions.SubscriptionRequest,
+6
View File
@@ -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>
+1 -1
View File
@@ -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">
+1 -1
View File
@@ -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">
+1 -1
View File
@@ -119,7 +119,7 @@ function TransferModal(props: ModalProps) {
open={open}
onOk={() => form.submit()}
onCancel={() => setOpen(false)}
destroyOnClose
destroyOnHidden
>
<Form
form={form}
+2 -2
View File
@@ -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">
+7
View File
@@ -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
View File
@@ -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;
+22
View File
@@ -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 {
+1 -1
View File
@@ -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
View File
@@ -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);
});
});
});
+48
View File
@@ -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 });
}
});
});
});