diff --git a/.gitignore b/.gitignore index eb984bd0..293e0bcb 100644 --- a/.gitignore +++ b/.gitignore @@ -30,8 +30,12 @@ dist/ node_modules Factorio* +!*Factorio*.ts +!*Factorio*.tsx +!*Factorio*.js /factorio !/test/file/factorio +!/test/file/html/*.html !/test/lib/factorio !/packages/lib/src/factorio /instances diff --git a/packages/controller/src/ControlConnection.ts b/packages/controller/src/ControlConnection.ts index 332a19ed..905ef956 100644 --- a/packages/controller/src/ControlConnection.ts +++ b/packages/controller/src/ControlConnection.ts @@ -126,6 +126,7 @@ export default class ControlConnection extends BaseConnection { this.handle(lib.PluginUpdateRequest, this.handlePluginUpdateRequest.bind(this)); this.handle(lib.PluginInstallRequest, this.handlePluginInstallRequest.bind(this)); this.handle(lib.DebugDumpWsRequest, this.handleDebugDumpWsRequest.bind(this)); + this.handle(lib.FactorioVersionsRequest, this.handleFactorioVersionsRequest.bind(this)); } validateIngress(message: lib.MessageRequest | lib.MessageEvent) { @@ -1222,6 +1223,10 @@ export default class ControlConnection extends BaseConnection { this.connector._socket!.clusterio_ignore_dump = true; this._controller.debugEvents.on("message", this.ws_dumper); } + + async handleFactorioVersionsRequest(request: lib.FactorioVersionsRequest) { + return await this._controller.factorioVersions.get(request.maxAgeMs); + } } module.exports = ControlConnection; diff --git a/packages/controller/src/Controller.ts b/packages/controller/src/Controller.ts index a22181b1..7c366a73 100644 --- a/packages/controller/src/Controller.ts +++ b/packages/controller/src/Controller.ts @@ -105,6 +105,8 @@ export default class Controller { // Cache for mod portal requests modPortalCache = new Map(); + // Cache for factorio versions + factorioVersions = new lib.ValueCache(lib.fetchFactorioVersions); static async bootstrap(config: lib.ControllerConfig) { let databaseDirectory = config.get("controller.database_directory"); diff --git a/packages/host/src/server.ts b/packages/host/src/server.ts index 4ffda317..dc45d101 100644 --- a/packages/host/src/server.ts +++ b/packages/host/src/server.ts @@ -79,7 +79,7 @@ function versionOrder(a: string, b: string) { * @returns Array with path to data dir and version found. * @internal */ -async function findVersion(factorioDir: string, targetVersion: lib.TargetVersion) { +async function findVersion(factorioDir: string, targetVersion: lib.TargetVersion): Promise<[string, lib.FullVersion]> { // There are two supported setups: having the factorio dir be the actual // install directory, and having the factorio dir be a folder containing @@ -87,8 +87,10 @@ async function findVersion(factorioDir: string, targetVersion: lib.TargetVersion let simpleVersion = await getVersion(path.join(factorioDir, "data", "changelog.txt")); if (simpleVersion !== null) { - if (simpleVersion === targetVersion || targetVersion === "latest") { - return [path.join(factorioDir, "data"), simpleVersion]; + if (simpleVersion === targetVersion || simpleVersion.startsWith(targetVersion) || targetVersion === "latest") { + if (lib.isFullVersion(simpleVersion)) { + return [path.join(factorioDir, "data"), simpleVersion]; + } } throw new Error( @@ -96,14 +98,14 @@ async function findVersion(factorioDir: string, targetVersion: lib.TargetVersion ); } - let versions = new Map(); + let versions = new Map(); for (let entry of await fs.readdir(factorioDir, { withFileTypes: true })) { if (!entry.isDirectory()) { continue; } let version = await getVersion(path.join(factorioDir, entry.name, "data", "changelog.txt")); - if (version === null) { + if (version === null || !lib.isFullVersion(version)) { continue; } @@ -118,9 +120,17 @@ async function findVersion(factorioDir: string, targetVersion: lib.TargetVersion throw new Error(`Unable to find any Factorio install in ${factorioDir}`); } + if (lib.isPartialVersion(targetVersion)) { + const sorted = [...versions.keys()].sort(versionOrder); + const latest = sorted.find(version => version.startsWith(targetVersion)); + if (latest) { + return [path.join(factorioDir, versions.get(latest)!, "data"), latest]; + } + } + if (targetVersion === "latest") { - let latest = [...versions.keys()].sort(versionOrder)[0]; - return [path.join(factorioDir, versions.get(latest), "data"), latest]; + const latest = [...versions.keys()].sort(versionOrder)[0]; + return [path.join(factorioDir, versions.get(latest)!, "data"), latest]; } throw new Error(`Unable to find Factorio version ${targetVersion}`); diff --git a/packages/lib/browser.ts b/packages/lib/browser.ts index 31fd837f..58c0debf 100644 --- a/packages/lib/browser.ts +++ b/packages/lib/browser.ts @@ -15,3 +15,4 @@ export * from "./src/subscriptions"; export { default as ExponentialBackoff } from "./src/ExponentialBackoff"; export { default as RateLimiter } from "./src/RateLimiter"; +export { default as ValueCache } from "./src/ValueCache"; diff --git a/packages/lib/index.ts b/packages/lib/index.ts index 36e0d492..4f92d7c4 100644 --- a/packages/lib/index.ts +++ b/packages/lib/index.ts @@ -9,6 +9,7 @@ export * from "./src/config"; export * from "./src/data"; export * from "./src/database"; export * from "./src/errors"; +export * from "./src/external"; export * from "./src/factorio"; export * from "./src/file_ops"; export * from "./src/hash"; @@ -35,3 +36,4 @@ export * from "./src/rce_ops"; export { default as ExponentialBackoff } from "./src/ExponentialBackoff"; export { default as ModStore } from "./src/ModStore"; export { default as RateLimiter } from "./src/RateLimiter"; +export { default as ValueCache } from "./src/ValueCache"; diff --git a/packages/lib/src/ValueCache.ts b/packages/lib/src/ValueCache.ts new file mode 100644 index 00000000..5b8947de --- /dev/null +++ b/packages/lib/src/ValueCache.ts @@ -0,0 +1,64 @@ + +/** + * Represents an async function which can fetch a value for a ValueCache + */ +export interface ValueCacheFetch { + (): Promise; +} + +/** + * In-memory single value cache + * + * Stores the last time a value was fetched and measures staleness + * Concurrent calls will share the same in-flight fetch + */ +export default class ValueCache { + private ongoingFetch: Promise | null = null; + private timestamp: number | null = null; + private value: T | null = null; // Only valid when timestamp is not null + + /** + * Create a new in memory single value cache + * + * @param fetch A function that fetches and returns a fresh value + */ + constructor( + private readonly fetch: ValueCacheFetch, + ) {} + + /** + * Get the cached value, will fetch a fresh value if stale or missing + * + * @param maxAgeMs Maximum allowed cache age in milliseconds. + * Defaults to 0, meaning it will always fetch a new value. + * @throws TypeError if maxAgeMs is invalid + * @throws Propagates errors from underlying fetch method + */ + get(maxAgeMs: number = 0): Promise { + if (maxAgeMs < 0) { + throw new TypeError("maxAgeMs must be a non-negative number"); + } + + const now = Date.now(); + + if (this.timestamp && now - this.timestamp < maxAgeMs) { + return Promise.resolve(this.value as T); + } + + if (this.ongoingFetch) { + return this.ongoingFetch; + } + + this.ongoingFetch = this.fetch() + .then(value => { + this.timestamp = Date.now(); + this.value = value; + return value; + }) + .finally(() => { + this.ongoingFetch = null; + }); + + return this.ongoingFetch; + } +} diff --git a/packages/lib/src/config/definitions.ts b/packages/lib/src/config/definitions.ts index b948a04c..18d30609 100644 --- a/packages/lib/src/config/definitions.ts +++ b/packages/lib/src/config/definitions.ts @@ -479,6 +479,7 @@ export class InstanceConfig extends classes.Config { restartRequired: true, type: "string", initialValue: "latest", + inputComponent: "target_version", }, "factorio.executable_path": { description: diff --git a/packages/lib/src/data/messages_controller.ts b/packages/lib/src/data/messages_controller.ts index dfcc368c..aa4232a5 100644 --- a/packages/lib/src/data/messages_controller.ts +++ b/packages/lib/src/data/messages_controller.ts @@ -2,6 +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"; export class ControllerStopRequest { declare ["constructor"]: typeof ControllerStopRequest; @@ -391,3 +392,25 @@ export class DebugWsMessageEvent { return new this(json.direction, json.content); } } + +export class FactorioVersionsRequest { + declare ["constructor"]: typeof FactorioVersionsRequest; + static type = "request" as const; + static src = "control" as const; + static dst = "controller" as const; + static permission = "core.external.get_factorio_versions" 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(Type.Array(ExternalFactorioVersionSchema)); +} diff --git a/packages/lib/src/external/FactorioVersions.ts b/packages/lib/src/external/FactorioVersions.ts new file mode 100644 index 00000000..b9041fc0 --- /dev/null +++ b/packages/lib/src/external/FactorioVersions.ts @@ -0,0 +1,69 @@ +import { Static, Type } from "@sinclair/typebox"; +import { FullVersionSchema, integerFullVersion, isFullVersion } from "../data"; + +const ARCHIVE_URL = "https://factorio.com/download/archive"; + +/** + * Represents the parsed result of a factorio version + */ +export const ExternalFactorioVersionSchema = Type.Object({ + stable: Type.Boolean(), + version: FullVersionSchema, + headlessUrl: Type.String(), +}); + +export type ExternalFactorioVersion = Static + +/** + * Fetch all factorio versions that support headless + * + * @returns A list of all factorio versions and their download URL + * @throws Network errors and non-ok status responses + */ +export async function fetchFactorioVersions(): Promise { + let res; + + try { + res = await fetch(ARCHIVE_URL); + } catch (err) { + throw new Error(`Failed to fetch Factorio versions from ${ARCHIVE_URL}: ${err}`); + } + + if (!res.ok) { + throw new Error(`Failed to fetch Factorio versions from ${ARCHIVE_URL}: HTTP ${res.status} ${res.statusText}`); + } + + const html = await res.text(); + return parseFactorioVersions(html); +} + +/** + * Parse all versions from the factorio archive webpage + * + * @param html The HTML content of the archive page + * @returns A list of all factorio versions and their download URL + */ +function parseFactorioVersions(html: string): ExternalFactorioVersion[] { + const versions = new Map(); + + // Match 2.0.72 + // eslint-disable-next-line max-len + const anchorRegex = /]*class="[^"]*version-button-(?stable|experimental)[^"]*"[^>]*href="[^"]*\/download\/archive\/(?\d+\.\d+.\d+)"[^>]*>/g; + + let match: RegExpExecArray | null; + const minimumMultiplayerVersion = integerFullVersion("0.12.35"); + while ((match = anchorRegex.exec(html)) !== null) { + const kind = match.groups?.kind; + const version = match.groups?.version; + + if (kind && version && isFullVersion(version) && integerFullVersion(version) >= minimumMultiplayerVersion) { + versions.set(version, { + version: version, + stable: kind === "stable", + headlessUrl: `www.factorio.com/get-download/${version}/headless/linux64`, + }); + } + } + + return [...versions.values()]; +} diff --git a/packages/lib/src/external/index.ts b/packages/lib/src/external/index.ts new file mode 100644 index 00000000..92cc1f2e --- /dev/null +++ b/packages/lib/src/external/index.ts @@ -0,0 +1,5 @@ +/** + * Library for interfacing with external apis + * @module lib/external + */ +export * from "./FactorioVersions"; diff --git a/packages/lib/src/link/messages.ts b/packages/lib/src/link/messages.ts index 6e29a75f..299cf3ae 100644 --- a/packages/lib/src/link/messages.ts +++ b/packages/lib/src/link/messages.ts @@ -35,6 +35,7 @@ export const dataClasses: (RequestClass | EventClass) controller.SystemInfoUpdateEvent, controller.DebugDumpWsRequest, controller.DebugWsMessageEvent, + controller.FactorioVersionsRequest, subscriptions.SubscriptionRequest, diff --git a/packages/lib/src/permissions.ts b/packages/lib/src/permissions.ts index fb7395b9..0507a2a4 100644 --- a/packages/lib/src/permissions.ts +++ b/packages/lib/src/permissions.ts @@ -568,3 +568,9 @@ definePermission({ title: "Dump WebSocket", description: "Dump all WebSocket communicatation from the controller.", }); + +definePermission({ + name: "core.external.get_factorio_versions", + title: "Get factorio versions", + description: "Get list of all factorio versions from Wube", +}); diff --git a/packages/lib/tsconfig.browser.json b/packages/lib/tsconfig.browser.json index 059b1ce6..05554ac8 100644 --- a/packages/lib/tsconfig.browser.json +++ b/packages/lib/tsconfig.browser.json @@ -6,6 +6,7 @@ "./src/api.ts", "./src/config/*.ts", "./src/data/*.ts", + "./src/external/*.ts", "./src/datastore.ts", "./src/errors.ts", "./src/factorio/exchange_string.ts", @@ -25,6 +26,7 @@ "./src/ExponentialBackoff.ts", "./src/RateLimiter.ts", "./src/TypedEventEmitter.ts", + "./src/ValueCache.ts", ], "compilerOptions": { "rootDir": ".", diff --git a/packages/web_ui/src/bootstrap.tsx b/packages/web_ui/src/bootstrap.tsx index 36c018f4..3b64bbd5 100644 --- a/packages/web_ui/src/bootstrap.tsx +++ b/packages/web_ui/src/bootstrap.tsx @@ -9,6 +9,8 @@ import App from "./components/App"; import BaseWebPlugin, { InputComponent } from "./BaseWebPlugin"; import InputRole from "./components/InputRole"; import InputModPack from "./components/InputModPack"; +import InputTargetVersion from "./components/InputTargetVersion"; +import InputPartialVersion from "./components/InputPartialVersion"; import { Control, ControlConnector } from "./util/websocket"; const { ConsoleTransport, WebConsoleFormat, logger } = lib; @@ -109,6 +111,8 @@ async function loadPlugins(pluginInfos: lib.PluginWebpackEnvInfo[], control: Con function inputComponentsFromPlugins(plugins: Map) { const inputComponents: Record = { + "partial_version": InputPartialVersion, + "target_version": InputTargetVersion, "mod_pack": InputModPack, "role": InputRole, }; diff --git a/packages/web_ui/src/components/InputPartialVersion.tsx b/packages/web_ui/src/components/InputPartialVersion.tsx new file mode 100644 index 00000000..250b5de6 --- /dev/null +++ b/packages/web_ui/src/components/InputPartialVersion.tsx @@ -0,0 +1,68 @@ +import React, { useContext, useEffect, useState } from "react"; +import { TreeSelect } from "antd"; + +import * as lib from "@clusterio/lib"; +import { InputComponentProps } from "../BaseWebPlugin"; +import { useAccount } from "../model/account"; +import ControlContext from "./ControlContext"; + +const defaultFieldDefinition: lib.FieldDefinition = { type: "string", optional: false }; + +export default function InputPartialVersion ( + props: Omit & { + fieldDefinition?: lib.FieldDefinition; + className?: string; + } +) { + const fieldDefinition = props.fieldDefinition || defaultFieldDefinition; + const [versions, setVersions] = useState(lib.ApiVersions); + const control = useContext(ControlContext); + const account = useAccount(); + + useEffect(() => { + (async () => { + if (account.hasPermission("core.external.get_factorio_versions")) { + const res = await control.factorioVersions.get(5 * 60 * 1000); + setVersions(res.map(v => v.version)); + } + })(); + }, [control]); + + // Split the versions into groups based on major minor + const groups = new Map(); + + for (const version of versions) { + const [major, minor] = version.split("."); + const key = `${major}.${minor}` as lib.PartialVersion; + + if (!groups.has(key)) { + groups.set(key, []); + } + groups.get(key)!.push(version); + } + + // Construct the tree to be disabled + const tree = [...groups.entries()].map(([majorMinor, patchVersions]) => ( + { + title: majorMinor, + value: majorMinor, + key: majorMinor, + children: patchVersions.map((v) => ({ + title: v, + value: v, + key: v, + })), + } + )); + + return props.onChange(value ?? null)} + value={props.value} + treeData={tree} + allowClear={fieldDefinition.optional} + className={props.className} + disabled={props.disabled} + />; +} diff --git a/packages/web_ui/src/components/InputTargetVersion.tsx b/packages/web_ui/src/components/InputTargetVersion.tsx new file mode 100644 index 00000000..3e52d33d --- /dev/null +++ b/packages/web_ui/src/components/InputTargetVersion.tsx @@ -0,0 +1,71 @@ +import React, { useContext, useEffect, useState } from "react"; +import { TreeSelect } from "antd"; + +import * as lib from "@clusterio/lib"; +import { InputComponentProps } from "../BaseWebPlugin"; +import { useAccount } from "../model/account"; +import ControlContext from "./ControlContext"; + +const defaultFieldDefinition: lib.FieldDefinition = { type: "string", optional: false }; + +export default function InputTargetVersion ( + props: Omit & { + fieldDefinition?: lib.FieldDefinition; + className?: string; + } +) { + const fieldDefinition = props.fieldDefinition || defaultFieldDefinition; + const [versions, setVersions] = useState(lib.ApiVersions); + const control = useContext(ControlContext); + const account = useAccount(); + + useEffect(() => { + (async () => { + if (account.hasPermission("core.external.get_factorio_versions")) { + const res = await control.factorioVersions.get(5 * 60 * 1000); + setVersions(res.map(v => v.version)); + } + })(); + }, [control]); + + // Split the versions into groups based on major minor + const groups = new Map(); + + for (const version of versions) { + const [major, minor] = version.split("."); + const key = `${major}.${minor}` as lib.PartialVersion; + + if (!groups.has(key)) { + groups.set(key, []); + } + groups.get(key)!.push(version); + } + + // Construct the tree to be disabled + const tree = [...groups.entries()].map(([majorMinor, patchVersions]) => ( + { + title: majorMinor, + value: majorMinor, + key: majorMinor, + children: patchVersions.map((v) => ({ + title: v, + value: v, + key: v, + })), + } + )); + + // Add "latest" option for TargetVersion + tree.unshift({ title: "latest", value: "latest", key: "latest", children: [] }); + + return props.onChange(value ?? null)} + value={props.value} + treeData={tree} + allowClear={fieldDefinition.optional} + className={props.className} + disabled={props.disabled} + />; +} diff --git a/packages/web_ui/src/components/ModPackViewPage.tsx b/packages/web_ui/src/components/ModPackViewPage.tsx index 4a185a1c..fbf75bdf 100644 --- a/packages/web_ui/src/components/ModPackViewPage.tsx +++ b/packages/web_ui/src/components/ModPackViewPage.tsx @@ -26,6 +26,7 @@ import PageHeader from "./PageHeader"; import PageLayout from "./PageLayout"; import SectionHeader from "./SectionHeader"; import ModDetails from "./ModDetails"; +import InputPartialVersion from "./InputPartialVersion"; const { logger } = lib; const { Text } = Typography; @@ -1288,7 +1289,9 @@ export default function ModPackViewPage() { message: "Must be an a.b or a.b.c version number.", }]} > - form.setFieldValue("factorioVersion", value)} className={hasChange(changes, { type: "factorioVersion" }) ? "changed" : ""} /> diff --git a/packages/web_ui/src/components/ModsPage.tsx b/packages/web_ui/src/components/ModsPage.tsx index 7ab5d5d9..0c526b9d 100644 --- a/packages/web_ui/src/components/ModsPage.tsx +++ b/packages/web_ui/src/components/ModsPage.tsx @@ -21,6 +21,7 @@ import SectionHeader from "./SectionHeader"; import ModDetails from "./ModDetails"; import { Dropzone } from "./Dropzone"; import UploadButton from "./UploadButton"; +import InputPartialVersion from "./InputPartialVersion"; const strcmp = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" }).compare; @@ -128,7 +129,10 @@ function CreateModPackButton() { message: "Must be an a.b or a.b.c version number.", }]} > - + form.setFieldValue("factorioVersion", value)} + /> diff --git a/packages/web_ui/src/util/websocket.ts b/packages/web_ui/src/util/websocket.ts index 62363569..dbd02325 100644 --- a/packages/web_ui/src/util/websocket.ts +++ b/packages/web_ui/src/util/websocket.ts @@ -69,6 +69,9 @@ export class Control extends lib.Link { users = new lib.EventSubscriber(lib.UserUpdatesEvent, this); roles = new lib.EventSubscriber(lib.RoleUpdatesEvent, this); + /** Cache for factorio versions to avoid repeat calls to the controller */ + factorioVersions = new lib.ValueCache(this.requestFactorioVersions.bind(this)); + declare connector: ControlConnector; constructor( @@ -105,6 +108,10 @@ export class Control extends lib.Link { this.handle(lib.DebugWsMessageEvent, this.handleDebugWsMessageEvent.bind(this)); } + requestFactorioVersions() { + return this.send(new lib.FactorioVersionsRequest()); + } + async handleAccountUpdateEvent(event: lib.AccountUpdateEvent) { this.accountRoles = event.roles??null; this.emitAccountUpdate(); diff --git a/test/file/html/factorio-download-archive.html b/test/file/html/factorio-download-archive.html new file mode 100644 index 00000000..3f5520ee --- /dev/null +++ b/test/file/html/factorio-download-archive.html @@ -0,0 +1,1583 @@ + + + + Download old versions | Factorio + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ + + +
+ +
+
+
+ +
+
+ + + + + + + + +
+
+ +
+
+
+ + + + +
+ + + + + + +
+
+

Old version archive

+ + See latest releases + +
+ +
+

+ On this page you can download old versions of Factorio. They will not be as polished and up-to-date as the latest release! +

+
+ Please choose a version. +
+
+ + + + +
+
+ + + 2.0.73 + + + + 2.0.72 + + + + 2.0.71 + + + + 2.0.70 + + + + 2.0.69 + + + + 2.0.68 + + + + 2.0.67 + + + + 2.0.66 + + + + 2.0.65 + + + + 2.0.64 + + + + 2.0.63 + + + + 2.0.62 + + + + 2.0.61 + + + + 2.0.60 + + + + 2.0.59 + + + + 2.0.58 + + + + 2.0.57 + + + + 2.0.56 + + + + 2.0.55 + + + + 2.0.54 + + + + 2.0.53 + + + + 2.0.52 + + + + 2.0.51 + + + + 2.0.50 + + + + 2.0.49 + + + + 2.0.48 + + + + 2.0.47 + + + + 2.0.46 + + + + 2.0.45 + + + + 2.0.44 + + + + 2.0.43 + + + + 2.0.42 + + + + 2.0.41 + + + + 2.0.40 + + + + 2.0.39 + + + + 2.0.38 + + + + 2.0.37 + + + + 2.0.36 + + + + 2.0.35 + + + + 2.0.34 + + + + 2.0.33 + + + + 2.0.32 + + + + 2.0.31 + + + + 2.0.30 + + + + 2.0.29 + + + + 2.0.28 + + + + 2.0.27 + + + + 2.0.26 + + + + 2.0.25 + + + + 2.0.24 + + + + 2.0.23 + + + + 2.0.22 + + + + 2.0.21 + + + + 2.0.20 + + + + 2.0.19 + + + + 2.0.18 + + + + 2.0.17 + + + + 2.0.16 + + + + 2.0.15 + + + + 2.0.14 + + + + 2.0.13 + + + + 2.0.12 + + + + 2.0.11 + + + + 2.0.10 + + + + 2.0.9 + + + + 2.0.8 + + + + 2.0.7 + + + + 1.1.110 + + + + 1.1.109 + + + + 1.1.108 + + + + 1.1.107 + + + + 1.1.106 + + + + 1.1.105 + + + + 1.1.104 + + + + 1.1.103 + + + + 1.1.102 + + + + 1.1.101 + + + + 1.1.100 + + + + 1.1.99 + + + + 1.1.98 + + + + 1.1.97 + + + + 1.1.96 + + + + 1.1.95 + + + + 1.1.94 + + + + 1.1.93 + + + + 1.1.92 + + + + 1.1.91 + + + + 1.1.90 + + + + 1.1.89 + + + + 1.1.88 + + + + 1.1.87 + + + + 1.1.86 + + + + 1.1.69 + + + + 1.1.60 + + + + 1.1.59 + + + + 1.1.58 + + + + 1.1.57 + + + + 1.0.0 + + + + 0.17.79 + + + + 0.16.51 + + + + 0.15.40 + + + + 0.15.36 + + + + 0.14.23 + + + + 0.13.20 + + + + 0.12.35 + + + + 0.11.22 + + + + 0.10.12 + + + + 0.9.8 + + + + 0.8.8 + + + + 0.7.5 + + + + 0.6.4 + + +
+
+ + +
+ + + +
+
+ + + + + + + + + + diff --git a/test/lib/ValueCache.test.js b/test/lib/ValueCache.test.js new file mode 100644 index 00000000..b75094c3 --- /dev/null +++ b/test/lib/ValueCache.test.js @@ -0,0 +1,127 @@ +const assert = require("assert").strict; +const { ValueCache, wait } = require("@clusterio/lib"); + +describe("ValueCache", function () { + describe("constructor", function () { + it("constructs with a fetch function", function() { + const fetcher = async () => 42; + const cache = new ValueCache(fetcher); + assert.ok(cache); + }); + }); + + describe("get", function() { + it("throws TypeError for negative maxAgeMs", async function() { + const fetcher = async () => 42; + const cache = new ValueCache(fetcher); + + assert.throws(() => cache.get(-1), err => { + assert.equal(err instanceof TypeError, true); + assert.match(String(err.message), /maxAgeMs/); + return true; + }); + }); + it("uses a default of 0 for maxAgeMs", async function() { + let callCount = 0; + const fetcher = async () => { + callCount += 1; + return callCount; + }; + + const cache = new ValueCache(fetcher); + + const v1 = await cache.get(); + const v2 = await cache.get(); + + assert.equal(v1, 1); + assert.equal(v2, 2); + assert.equal(callCount, 2); + }); + it("returns cached value when within maxAgeMs", async function() { + let callCount = 0; + const fetcher = async () => { + callCount += 1; + return callCount; + }; + + const cache = new ValueCache(fetcher); + + const v1 = await cache.get(1000); + const v2 = await cache.get(1000); + + assert.equal(v1, 1); + assert.equal(v2, 1); + assert.equal(callCount, 1); + }); + it("refreshes the cache when older than maxAgeMs", async function() { + let callCount = 0; + const fetcher = async () => { + callCount += 1; + return callCount; + }; + + const cache = new ValueCache(fetcher); + + const v1 = await cache.get(50); + const v2 = await cache.get(1000); // Use cached value + await wait(100); + const v3 = await cache.get(50); // Stale after 100ms wait + const v4 = await cache.get(1000); // Use cached value + + assert.equal(v1, 1); + assert.equal(v2, 1); + assert.equal(v3, 2); + assert.equal(v4, 2); + assert.equal(callCount, 2); + }); + it("shares in-flight fetch between concurrent callers", async function() { + let callCount = 0; + let resolveFetcher; + const fetcher = () => ( + new Promise(resolve => { + callCount += 1; + resolveFetcher = resolve; + }) + ); + + const cache = new ValueCache(fetcher); + + const p1 = cache.get(1000); + const p2 = cache.get(1000); + + assert.equal(callCount, 1); + + resolveFetcher(42); + const [v1, v2] = await Promise.all([p1, p2]); + + assert.equal(v1, 42); + assert.equal(v2, 42); + assert.equal(callCount, 1); + }); + it("propagates errors from the fetcher and clears ongoing fetch", async function() { + let callCount = 0; + const error = new Error("fetch failed"); + const fetcher = async () => { + callCount += 1; + throw error; + }; + + const cache = new ValueCache(fetcher); + + await assert.rejects(cache.get(), err => { + assert.equal(err, error); + return true; + }); + + assert.equal(callCount, 1); + + // After a failure, a new attempt to fetch should be made + await assert.rejects(cache.get(), err => { + assert.equal(err, error); + return true; + }); + + assert.equal(callCount, 2); + }); + }); +}); diff --git a/test/lib/external/FactorioVersions.test.js b/test/lib/external/FactorioVersions.test.js new file mode 100644 index 00000000..32b86dad --- /dev/null +++ b/test/lib/external/FactorioVersions.test.js @@ -0,0 +1,134 @@ +const assert = require("assert").strict; +const { readFile } = require("fs-extra"); + +const { fetchFactorioVersions } = require("@clusterio/lib"); +const { slowTest } = require("../../integration"); + +describe("FactorioVersions", function() { + describe("fetchFactorioVersions", function() { + const originalFetch = global.fetch; + + beforeEach(() => { + global.fetch = undefined; + }); + + afterEach(() => { + global.fetch = originalFetch; + }); + + function mockFetch(html) { + const mockResponse = { + ok: true, + status: 200, + statusText: "OK", + text: async () => html, + }; + + global.fetch = async () => mockResponse; + } + + it("fetches and parsed versions with stable / experimental flags", async function() { + mockFetch(` + + + + 2.0.73 + + + + + 2.0.72 + + + + `); + + const versions = await fetchFactorioVersions(); + assert.deepEqual(versions, [ + { + stable: false, + version: "2.0.73", + headlessUrl: "www.factorio.com/get-download/2.0.73/headless/linux64", + }, + { + stable: true, + version: "2.0.72", + headlessUrl: "www.factorio.com/get-download/2.0.72/headless/linux64", + }, + ]); + }); + it("deduplicates versions by last occurrence", async function() { + mockFetch(` + + + + 2.0.73 + + + + + 2.0.73 + + + + `); + + const versions = await fetchFactorioVersions(); + assert.deepEqual(versions, [ + { + stable: true, + version: "2.0.73", + headlessUrl: "www.factorio.com/get-download/2.0.73/headless/linux64", + }, + ]); + }); + it("thorws on HTTP error status", async function() { + const mockResponse = { + ok: false, + status: 500, + statusText: "Server Error", + text: async () => "", + }; + + global.fetch = async () => mockResponse; + + await assert.rejects(fetchFactorioVersions(), /HTTP 500 Server Error/); + }); + it("thorws on network error status", async function() { + global.fetch = async () => { + throw new Error("Network Error"); + }; + + await assert.rejects(fetchFactorioVersions(), /Network Error/); + }); + it("parses versions from saved html file", async function() { + mockFetch(await readFile("test/file/html/factorio-download-archive.html")); + + const versions = await fetchFactorioVersions(); + assert.equal(versions.length, 105); + assert.equal(versions.filter(v => v.stable).length, 48); + assert.equal(versions.filter(v => !v.stable).length, 57); + }); + it("parses versions from live api", async function() { + slowTest(this); + global.fetch = originalFetch; + + const versions = await fetchFactorioVersions(); + assert.ok(versions.length >= 105); + assert.ok(versions.filter(v => v.stable).length >= 48); + assert.ok(versions.filter(v => !v.stable).length >= 57); + }); + }); +}); diff --git a/test/messages/controller.test.js b/test/messages/controller.test.js index 1e345475..7b735e9b 100644 --- a/test/messages/controller.test.js +++ b/test/messages/controller.test.js @@ -34,4 +34,44 @@ describe("messages/controller", function() { ); }); }); + + describe("External", function() { + it("runs", async function() { + let callCount = 0; + const versions = [ + { version: "1.2.3", stable: true, headlessUrl: "path/to/download" }, + { version: "1.2.4", stable: false, headlessUrl: "path/to/download" }, + ]; + + // Mock the cache to avoid needless fetches + controller.factorioVersions = new lib.ValueCache(async () => { + callCount += 1; + return versions; + }); + + // First call should result in a fetch + const versions1 = await controlConnection.handleFactorioVersionsRequest( + new lib.FactorioVersionsRequest() + ); + + assert.deepEqual(versions1, versions); + assert.equal(callCount, 1); + + // Second call should not fetch due to default maxAgeMs being greater than 0 + const versions2 = await controlConnection.handleFactorioVersionsRequest( + new lib.FactorioVersionsRequest() + ); + + assert.deepEqual(versions2, versions); + assert.equal(callCount, 1); + + // Setting maxAgeMs to 0 should result in a new fetch + const versions3 = await controlConnection.handleFactorioVersionsRequest( + new lib.FactorioVersionsRequest(0) + ); + + assert.deepEqual(versions3, versions); + assert.equal(callCount, 2); + }); + }); });