diff --git a/packages/controller/src/BaseConnection.ts b/packages/controller/src/BaseConnection.ts index 8efbf14c..07b753f8 100644 --- a/packages/controller/src/BaseConnection.ts +++ b/packages/controller/src/BaseConnection.ts @@ -32,6 +32,7 @@ export default class BaseConnection extends lib.Link { this.handle(lib.ModPackGetRequest, this.handleModPackGetRequest.bind(this)); 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.SubscriptionRequest, this.handleSubscriptionRequest.bind(this)); this.connector.on("close", () => { @@ -106,4 +107,8 @@ export default class BaseConnection extends lib.Link { async handleSubscriptionRequest(request: lib.SubscriptionRequest, src: lib.Address, dst: lib.Address) { return await this._controller.subscriptions.handleRequest(this, request, src, dst); } + + async handleFactorioVersionsRequest(request: lib.FactorioVersionsRequest) { + return await this._controller.factorioVersions.get(request.maxAgeMs); + } } diff --git a/packages/controller/src/ControlConnection.ts b/packages/controller/src/ControlConnection.ts index 673a9277..d1ac875c 100644 --- a/packages/controller/src/ControlConnection.ts +++ b/packages/controller/src/ControlConnection.ts @@ -127,7 +127,6 @@ 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) { @@ -1240,10 +1239,6 @@ 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 a5671795..6e1f8a78 100644 --- a/packages/controller/src/Controller.ts +++ b/packages/controller/src/Controller.ts @@ -112,7 +112,14 @@ export default class Controller { // Cache for mod portal requests modPortalCache = new Map(); // Cache for factorio versions - factorioVersions = new lib.ValueCache(lib.fetchFactorioVersions); + factorioVersions = new lib.ValueCache(async () => { + try { + return await lib.fetchFactorioVersions(); + } catch (err: any) { + logger.warn(`Failed to retrieve Factorio versions ${err.message}`); + return []; + } + }); static async bootstrap(config: lib.ControllerConfig) { let databaseDirectory = config.get("controller.database_directory"); diff --git a/packages/controller/src/HostConnection.ts b/packages/controller/src/HostConnection.ts index 66601e20..e7132925 100644 --- a/packages/controller/src/HostConnection.ts +++ b/packages/controller/src/HostConnection.ts @@ -81,7 +81,6 @@ export default class HostConnection extends BaseConnection { this.handle(lib.InstanceSaveDetailsUpdatesEvent, this.handleInstanceSaveDetailsUpdatesEvent.bind(this)); this.handle(lib.LogMessageEvent, this.handleLogMessageEvent.bind(this)); this.handle(lib.InstancePlayerUpdateEvent, this.handleInstancePlayerUpdateEvent.bind(this)); - this.handle(lib.FactorioVersionsRequest, this.handleFactorioVersionsRequest.bind(this)); this.snoopEvent(lib.InstanceAdminlistUpdateEvent, this.snoopInstanceAdminlistUpdateEvent.bind(this)); this.snoopEvent(lib.InstanceBanlistUpdateEvent, this.snoopInstanceBanlistUpdateEvent.bind(this)); this.snoopEvent(lib.InstanceWhitelistUpdateEvent, this.snoopInstanceWhitelistUpdateEvent.bind(this)); @@ -359,10 +358,6 @@ export default class HostConnection extends BaseConnection { }); } - async handleFactorioVersionsRequest(request: lib.FactorioVersionsRequest) { - return await this._controller.factorioVersions.get(request.maxAgeMs); - } - async snoopInstanceAdminlistUpdateEvent(event: lib.InstanceAdminlistUpdateEvent, src: lib.Address) { if (src.type !== lib.Address.instance) { return; diff --git a/packages/host/src/Host.ts b/packages/host/src/Host.ts index 7e778c32..c3f2466e 100644 --- a/packages/host/src/Host.ts +++ b/packages/host/src/Host.ts @@ -74,6 +74,10 @@ async function discoverInstances(instancesDir: string) { return instances; } +// Requests that wake up instances are responsible for making sure the +// instance status is correctly updated even if they fail with an error. This +// means they should typically use try catch on anything that can throw +// and call notifyExit upon any errors. const instanceStartingMessages = new Set([ lib.InstanceStartRequest.name, lib.InstanceLoadScenarioRequest.name, diff --git a/packages/host/src/Instance.ts b/packages/host/src/Instance.ts index 9b9509ca..fa228ee8 100644 --- a/packages/host/src/Instance.ts +++ b/packages/host/src/Instance.ts @@ -637,9 +637,10 @@ end`.replace(/\r?\n/g, " "); } async init(pluginInfos: lib.PluginNodeEnvInfo[]) { + // After changing the status this function is responsible for calling notifyExit if it throws. this.notifyStatus("starting"); - const factorioVersions = await this.sendTo("controller", new lib.FactorioVersionsRequest()); try { + const factorioVersions = await this.sendTo("controller", new lib.FactorioVersionsRequest()); await this._loadStats(); await this.server.checkForUpdates(factorioVersions); await this.server.init(); diff --git a/packages/host/src/server.ts b/packages/host/src/server.ts index 86cfdfaf..227d2319 100644 --- a/packages/host/src/server.ts +++ b/packages/host/src/server.ts @@ -978,6 +978,10 @@ export class FactorioServer extends events.EventEmitter { .sort((v1, v2) => versionOrder(v1.version, v2.version)) [0]; + if (!latestVersion) { + return; + } + const installedVersions = await listFactorioVersions(this._factorioDir); if (!installedVersions.versions.has(latestVersion.version)) { const v = latestVersion.version; diff --git a/packages/lib/src/datastore.ts b/packages/lib/src/datastore.ts index 0dae1a57..48b9d15b 100644 --- a/packages/lib/src/datastore.ts +++ b/packages/lib/src/datastore.ts @@ -290,18 +290,50 @@ export class KeyValueDatastore< export class SubscribableDatastore< V extends SubscribableValue, > extends Datastore { + /** Last time an update occurred */ + lastUpdatedAtMs: number = 0; + + private static maxUpdatedAtMs(values: Iterable) { + let max = 0; + for (const value of values) { + // Prevent one NaN, undefined, +Inf or similar from breaking the datastore. + if (Number.isFinite(value.updatedAtMs)) { + max = Math.max(max, value.updatedAtMs); + } + } + return max; + } + + private incrementLastUpdatedMs() { + this.lastUpdatedAtMs = Math.max(Date.now(), this.lastUpdatedAtMs + 1); + } + + constructor( + provider: DatastoreProvider = new MemoryDatastoreProvider(), + data = new Map(), + ) { + super(provider, data); + this.lastUpdatedAtMs = SubscribableDatastore.maxUpdatedAtMs(this.data.values()); + } + + async load() { + await super.load(); + this.lastUpdatedAtMs = SubscribableDatastore.maxUpdatedAtMs(this.data.values()); + } + // Set the value in the datastore, be careful of race conditions if you await any functions before calling set set(value: V) { + this.incrementLastUpdatedMs(); this.data.set(value.id, value); - value.updatedAtMs = Date.now(); + value.updatedAtMs = this.lastUpdatedAtMs; this.touch([value]); } // Set many values at once from an array of key value pairs setMany(values: V[]) { - const nowMs = Date.now(); + this.incrementLastUpdatedMs(); for (const value of values) { - value.updatedAtMs = nowMs; + value.updatedAtMs = this.lastUpdatedAtMs; this.data.set(value.id, value); } this.touch(values); @@ -309,18 +341,19 @@ export class SubscribableDatastore< // Delete a value in the datastore delete(value: V) { + this.incrementLastUpdatedMs(); this.data.delete(value.id); - value.updatedAtMs = Date.now(); + value.updatedAtMs = this.lastUpdatedAtMs; value.isDeleted = true; this.touch([value]); } // Delete many values at once from an array of values deleteMany(values: V[]) { - const nowMs = Date.now(); + this.incrementLastUpdatedMs(); for (const value of values) { value.isDeleted = true; - value.updatedAtMs = nowMs; + value.updatedAtMs = this.lastUpdatedAtMs; this.data.delete(value.id); } this.touch(values); diff --git a/packages/lib/src/external/FactorioVersions.ts b/packages/lib/src/external/FactorioVersions.ts index 45125433..86360b39 100644 --- a/packages/lib/src/external/FactorioVersions.ts +++ b/packages/lib/src/external/FactorioVersions.ts @@ -1,7 +1,7 @@ import { Static, Type } from "@sinclair/typebox"; -import { FullVersionSchema, integerFullVersion, isFullVersion } from "../data"; +import { FullVersionSchema, integerFullVersion, isFullVersion } from "../data/version"; -const ARCHIVE_URL = "https://factorio.com/download/archive"; +const ARCHIVE_URL = "https://factorio.com/download/archive/"; /** * Represents the parsed result of a factorio version diff --git a/packages/lib/src/prometheus.ts b/packages/lib/src/prometheus.ts index 2a55ddd7..84999093 100644 --- a/packages/lib/src/prometheus.ts +++ b/packages/lib/src/prometheus.ts @@ -272,18 +272,31 @@ function labelsToKey(labels: LabelValues, metricLabels: string[]) { function keyToLabels(key: string) { let labels = new Map(); - if (key !== "") { - for (let pair of key.split(",")) { - let [name, value] = pair.split("=", 2); - if (!name || value === undefined) { - continue; + let pos = 0; + let matchLabel = /([^=]+)="(([^\\"]|\\["n\\])+)"/y; + while (pos < key.length) { + if (pos !== 0) { + /* istanbul ignore if */ + if (key[pos] !== ",") { + // Should be unreachable + throw Error(`Expected , at pos ${pos} in "${key}"`); } + pos += 1; + } + matchLabel.lastIndex = pos; + const match = matchLabel.exec(key); + /* istanbul ignore else */ + if (match) { + let [, name, value] = match; labels.set(name, value - .slice(1, -1) .replace(/\\"/g, "\"") .replace(/\\n/g, "\n") .replace(/\\\\/g, "\\") ); + pos += match[0].length; + } else { + // Should be unreachable + throw new Error(`Failed to parse label at pos ${pos} in "${key}"`); } } diff --git a/packages/web_ui/src/components/NpmButton.tsx b/packages/web_ui/src/components/NpmButton.tsx index 8696cac1..aa01c7b4 100644 --- a/packages/web_ui/src/components/NpmButton.tsx +++ b/packages/web_ui/src/components/NpmButton.tsx @@ -82,7 +82,11 @@ function NpmPluginInstall({ setApplyAction, form, target }: NpmActionProps) { ; } -export function NpmButton(props: { target: lib.AddressShorthand, canRestart?: boolean, disabled?: boolean }) { +export function NpmButton(props: { + target: "controller" | { hostId: number }, + canRestart?: boolean, + disabled?: boolean +}) { const control = useContext(ControlContext); const account = useAccount(); const [open, setOpen] = useState(false); @@ -93,6 +97,7 @@ export function NpmButton(props: { target: lib.AddressShorthand, canRestart?: bo remoteUpdates: true, pluginUpdates: true, pluginInstall: true, }); + const hostId = props.target === "controller" ? undefined : props.target.hostId; useEffect(() => { if (props.target === "controller") { if (!account.hasPermission("core.controller.get_config")) { @@ -121,7 +126,7 @@ export function NpmButton(props: { target: lib.AddressShorthand, canRestart?: bo }); }).catch(notifyErrorHandler("Failed to fetch host config for remote updates")); } - }, [props.target]); + }, [hostId]); function onValuesChange({ action } : { action?: string }) { if (action) { diff --git a/test/controller/Controller.js b/test/controller/Controller.js index ae45a11b..7b0d4127 100644 --- a/test/controller/Controller.js +++ b/test/controller/Controller.js @@ -252,6 +252,28 @@ describe("controller/src/Controller", function() { assert.equal(controller.config.restartRequired, true); }); }); + + describe(".factorioVersions", function() { + const _fetch = global.fetch; + const fetchCalls = []; + before(function() { + global.fetch = async (url) => { + fetchCalls.push(url); + return { ok: false, status: 503, statusText: "Service Unavailable" }; + }; + }); + it("should handle fetch failure", async function() { + await assert.deepEqual( + await controller.factorioVersions.get(0), + [], + ); + assert(fetchCalls.length === 1, "expected one fetch call"); + assert(fetchCalls[0].indexOf("/download") !== -1, "fetch call to be to a URL with /download in it"); + }); + after(function() { + global.fetch = _fetch; + }); + }); }); describe("finalise", function() { describe(".finaliseHosts()", function() { diff --git a/test/controller/User.test.js b/test/controller/User.test.js index 3e5ea4ab..cf605280 100644 --- a/test/controller/User.test.js +++ b/test/controller/User.test.js @@ -37,6 +37,12 @@ describe("controller/User", function () { user.saveRecord(); assert(user.updatedAtMs > prev); }); + it("should always increment updatedAtMs even if called in quick succession", function () { + user.saveRecord(); + let prev = user.updatedAtMs; + user.saveRecord(); + assert(user.updatedAtMs > prev, "updatedAtMs did not increase"); + }); }); describe(".set()", function () { diff --git a/test/host/server.js b/test/host/server.js index 28d21054..13de4e75 100644 --- a/test/host/server.js +++ b/test/host/server.js @@ -342,264 +342,124 @@ describe("host/server", function() { }); }); - describe("full version", function() { - it("should do nothing when there is no newer version", async function() { - server._factorioDir = path.join("test", "file", "factorio"); - server._targetVersion = "0.1.1"; - await server.checkForUpdates([{ - stable: true, - version: "0.1.1", - headlessUrl: "test1", - }, { - stable: false, - version: "0.1.0", - headlessUrl: "test2", - }]); + for (const [suite, target] of [ + ["full version", "0.1.1"], + ["partial version", "0.1"], + ["latest version", "latest"], + ]) { + /* eslint-disable no-loop-func */ + describe(suite, function() { + it("should do nothing when there are no versions", async function() { + server._factorioDir = path.join("test", "file", "factorio"); + server._targetVersion = target; + await server.checkForUpdates([]); - assert.equal(fetchCalledWith, null); + assert.equal(fetchCalledWith, null); + }); + if (target !== "latest") { + it("should do nothing when no version matches", async function() { + server._factorioDir = path.join("test", "file", "factorio"); + server._targetVersion = target; + await server.checkForUpdates([{ + stable: true, + version: "0.2.1", + headlessUrl: "test1", + }, { + stable: false, + version: "0.2.0", + headlessUrl: "test2", + }]); + + assert.equal(fetchCalledWith, null); + }); + } + it("should do nothing when there is no newer version", async function() { + server._factorioDir = path.join("test", "file", "factorio"); + server._targetVersion = target; + await server.checkForUpdates([{ + stable: true, + version: "0.1.1", + headlessUrl: "test1", + }, { + stable: false, + version: "0.1.0", + headlessUrl: "test2", + }]); + + assert.equal(fetchCalledWith, null); + }); + it("should do nothing for direct installs", async function() { + server._factorioDir = path.join("test", "file", "factorio", "0.1.1"); + server._targetVersion = target === "0.1.1" ? "0.1.5" : target; + await server.checkForUpdates([{ + stable: true, + version: "0.1.5", + headlessUrl: "test1", + }, { + stable: true, + version: "0.1.1", + headlessUrl: "test1", + }, { + stable: false, + version: "0.1.0", + headlessUrl: "test2", + }]); + + assert.equal(fetchCalledWith, null); + }); + it("should do nothing when on windows", async function() { + let logLine = null; + server._logger = { info: line => { logLine = line; } }; + Object.defineProperty(process, "platform", { value: "win32" }); + + server._factorioDir = path.join("test", "file", "factorio"); + server._targetVersion = target === "0.1.1" ? "0.1.5" : target; + await server.checkForUpdates([{ + stable: true, + version: "0.1.5", + headlessUrl: "test1", + }, { + stable: true, + version: "0.1.1", + headlessUrl: "test2", + }, { + stable: false, + version: "0.1.0", + headlessUrl: "test3", + }]); + + assert.equal(fetchCalledWith, null); + assert.ok(logLine !== null); + assert.ok(logLine.endsWith("but must be manually downloaded")); + }); + it("should do attempt to download on linux", async function() { + let logLine = null; + server._logger = { info: line => { logLine = line; } }; + Object.defineProperty(process, "platform", { value: "linux" }); + + server._factorioDir = path.join("test", "file", "factorio"); + server._targetVersion = target === "0.1.1" ? "0.1.5" : target; + await assert.rejects(server.checkForUpdates([{ + stable: true, + version: "0.1.5", + headlessUrl: "test1", + }, { + stable: true, + version: "0.1.1", + headlessUrl: "test2", + }, { + stable: false, + version: "0.1.0", + headlessUrl: "test3", + }]), new Error("Failed to fetch test1: -1 Fetch called")); + + assert.equal(fetchCalledWith, "test1"); + assert.ok(logLine !== null); + assert.ok(logLine.endsWith("starting download...")); + }); }); - it("should do nothing for direct installs", async function() { - server._factorioDir = path.join("test", "file", "factorio", "0.1.1"); - server._targetVersion = "0.1.5"; - await server.checkForUpdates([{ - stable: true, - version: "0.1.5", - headlessUrl: "test1", - }, { - stable: true, - version: "0.1.1", - headlessUrl: "test1", - }, { - stable: false, - version: "0.1.0", - headlessUrl: "test2", - }]); - - assert.equal(fetchCalledWith, null); - }); - it("should do nothing when on windows", async function() { - let logLine = null; - server._logger = { info: line => { logLine = line; } }; - Object.defineProperty(process, "platform", { value: "win32" }); - - server._factorioDir = path.join("test", "file", "factorio"); - server._targetVersion = "0.1.5"; - await server.checkForUpdates([{ - stable: true, - version: "0.1.5", - headlessUrl: "test1", - }, { - stable: true, - version: "0.1.1", - headlessUrl: "test2", - }, { - stable: false, - version: "0.1.0", - headlessUrl: "test3", - }]); - - assert.equal(fetchCalledWith, null); - assert.ok(logLine !== null); - assert.ok(logLine.endsWith("but must be manually downloaded")); - }); - it("should do attempt to download on linux", async function() { - let logLine = null; - server._logger = { info: line => { logLine = line; } }; - Object.defineProperty(process, "platform", { value: "linux" }); - - server._factorioDir = path.join("test", "file", "factorio"); - server._targetVersion = "0.1.5"; - await assert.rejects(server.checkForUpdates([{ - stable: true, - version: "0.1.5", - headlessUrl: "test1", - }, { - stable: true, - version: "0.1.1", - headlessUrl: "test2", - }, { - stable: false, - version: "0.1.0", - headlessUrl: "test3", - }]), new Error("Failed to fetch test1: -1 Fetch called")); - - assert.equal(fetchCalledWith, "test1"); - assert.ok(logLine !== null); - assert.ok(logLine.endsWith("starting download...")); - }); - }); - describe("partial version", function() { - it("should do nothing when there is no newer version", async function() { - server._factorioDir = path.join("test", "file", "factorio"); - server._targetVersion = "0.1"; - await server.checkForUpdates([{ - stable: true, - version: "0.1.1", - headlessUrl: "test1", - }, { - stable: false, - version: "0.1.0", - headlessUrl: "test2", - }]); - - assert.equal(fetchCalledWith, null); - }); - it("should do nothing for direct installs", async function() { - server._factorioDir = path.join("test", "file", "factorio", "0.1.1"); - server._targetVersion = "0.1"; - await server.checkForUpdates([{ - stable: true, - version: "0.1.5", - headlessUrl: "test1", - }, { - stable: true, - version: "0.1.1", - headlessUrl: "test1", - }, { - stable: false, - version: "0.1.0", - headlessUrl: "test2", - }]); - - assert.equal(fetchCalledWith, null); - }); - it("should do nothing when on windows", async function() { - let logLine = null; - server._logger = { info: line => { logLine = line; } }; - Object.defineProperty(process, "platform", { value: "win32" }); - - server._factorioDir = path.join("test", "file", "factorio"); - server._targetVersion = "0.1"; - await server.checkForUpdates([{ - stable: true, - version: "0.1.5", - headlessUrl: "test1", - }, { - stable: true, - version: "0.1.1", - headlessUrl: "test2", - }, { - stable: false, - version: "0.1.0", - headlessUrl: "test3", - }]); - - assert.equal(fetchCalledWith, null); - assert.ok(logLine !== null); - assert.ok(logLine.endsWith("but must be manually downloaded")); - }); - it("should do attempt to download on linux", async function() { - let logLine = null; - server._logger = { info: line => { logLine = line; } }; - Object.defineProperty(process, "platform", { value: "linux" }); - - server._factorioDir = path.join("test", "file", "factorio"); - server._targetVersion = "0.1"; - await assert.rejects(server.checkForUpdates([{ - stable: true, - version: "0.1.5", - headlessUrl: "test1", - }, { - stable: true, - version: "0.1.1", - headlessUrl: "test2", - }, { - stable: false, - version: "0.1.0", - headlessUrl: "test3", - }]), new Error("Failed to fetch test1: -1 Fetch called")); - - assert.equal(fetchCalledWith, "test1"); - assert.ok(logLine !== null); - assert.ok(logLine.endsWith("starting download...")); - }); - }); - describe("latest version", function() { - it("should do nothing when there is no newer version", async function() { - server._factorioDir = path.join("test", "file", "factorio"); - server._targetVersion = "latest"; - await server.checkForUpdates([{ - stable: true, - version: "0.1.1", - headlessUrl: "test1", - }, { - stable: false, - version: "0.1.0", - headlessUrl: "test2", - }]); - - assert.equal(fetchCalledWith, null); - }); - it("should do nothing for direct installs", async function() { - server._factorioDir = path.join("test", "file", "factorio", "0.1.1"); - server._targetVersion = "latest"; - await server.checkForUpdates([{ - stable: true, - version: "0.1.5", - headlessUrl: "test1", - }, { - stable: true, - version: "0.1.1", - headlessUrl: "test1", - }, { - stable: false, - version: "0.1.0", - headlessUrl: "test2", - }]); - - assert.equal(fetchCalledWith, null); - }); - it("should do nothing when on windows", async function() { - let logLine = null; - server._logger = { info: line => { logLine = line; } }; - Object.defineProperty(process, "platform", { value: "win32" }); - - server._factorioDir = path.join("test", "file", "factorio"); - server._targetVersion = "latest"; - await server.checkForUpdates([{ - stable: true, - version: "0.1.5", - headlessUrl: "test1", - }, { - stable: true, - version: "0.1.1", - headlessUrl: "test2", - }, { - stable: false, - version: "0.1.0", - headlessUrl: "test3", - }]); - - assert.equal(fetchCalledWith, null); - assert.ok(logLine !== null); - assert.ok(logLine.endsWith("but must be manually downloaded")); - }); - it("should do attempt to download on linux", async function() { - let logLine = null; - server._logger = { info: line => { logLine = line; } }; - Object.defineProperty(process, "platform", { value: "linux" }); - - server._factorioDir = path.join("test", "file", "factorio"); - server._targetVersion = "latest"; - await assert.rejects(server.checkForUpdates([{ - stable: true, - version: "0.1.5", - headlessUrl: "test1", - }, { - stable: true, - version: "0.1.1", - headlessUrl: "test2", - }, { - stable: false, - version: "0.1.0", - headlessUrl: "test3", - }]), new Error("Failed to fetch test1: -1 Fetch called")); - - assert.equal(fetchCalledWith, "test1"); - assert.ok(logLine !== null); - assert.ok(logLine.endsWith("starting download...")); - }); - }); + /* eslint-enable no-loop-func */ + } it("should download a version correctly (live api)", async function() { slowTest(this); if (_platform !== "linux") { diff --git a/test/lib/datastore.js b/test/lib/datastore.js index 93b53277..ba2cf116 100644 --- a/test/lib/datastore.js +++ b/test/lib/datastore.js @@ -5,13 +5,6 @@ const lib = require("@clusterio/lib"); const fs = require("fs-extra"); const path = require("path"); -async function incrementDateMs() { - const prev = Date.now(); - do { - await lib.wait(1); - } while (prev >= Date.now()); -} - class MockDatastoreProvider extends lib.DatastoreProvider { constructor() { super(); @@ -571,12 +564,31 @@ describe("lib/datastore", function() { eventsRaised = []; datastoreProvider = new MockDatastoreProvider(); datastoreProvider.value = new Map([ - ["foo", {id: "foo"}], ["bar", {id: "bar"}], ["baz", {id: "baz"}], + ["foo", {id: "foo", updatedAtMs: 10}], ["bar", {id: "bar"}], ["baz", {id: "baz"}], ]); datastore = new lib.SubscribableDatastore(datastoreProvider, datastoreProvider.value); datastore.on("update", updates => eventsRaised.push(updates)); }); + function forceFuture() { + const future = {id: "future"}; + for (let i = 1; i < 100; i++) { + datastore.set(future); + } + return future.updatedAtMs; + } + + describe("lastUpdateAtMs", function() { + it("is set to the highest value after constructing a new instance", function() { + assert(datastore.lastUpdatedAtMs === 10, `${datastore.lastUpdatedAtMs} !== 10`); + }); + it("is set to the highest value after loading from a provider", async function() { + const future = Date.now() + 2000; + datastore.provider.value = ["future", {id: "future", updatedAtMs: future}]; + await datastore.load(); + assert(datastore.lastUpdatedAtMs === future, `${datastore.lastUpdatedAtMs} !== ${future}`); + }); + }); describe("set", function() { it("can set a value", function() { const value = { id: "test" }; @@ -598,12 +610,21 @@ describe("lib/datastore", function() { const value = { id: "test" }; datastore.set(value); assert.notEqual(value.updatedAtMs, undefined); - assert(value.updatedAtMs > 0, "updatedAtMs greater than zero"); + assert(value.updatedAtMs > 0, "updatedAtMs not greater than zero"); + }); + it("increments the updatedAtMs property", async function() { + const value = { id: "test" }; + datastore.set(value); const prev = value.updatedAtMs; - await incrementDateMs(); datastore.set(value); assert(value.updatedAtMs > prev, "updatedAtMs must increase"); }); + it("increments the updatedAtMs property past existing values", async function() { + const future = forceFuture(); + const value = { id: "test" }; + datastore.set(value); + assert(value.updatedAtMs > future, "updatedAtMs must increase"); + }); }); describe("setMany", function() { @@ -632,11 +653,22 @@ describe("lib/datastore", function() { assert.notEqual(updates[1].updatedAtMs, undefined); assert(updates[0].updatedAtMs > 0, "updatedAtMs greater than zero"); assert(updates[1].updatedAtMs > 0, "updatedAtMs greater than zero"); - const prev = [updates[0].updatedAtMs, updates[1].updatedAtMs]; - await incrementDateMs(); + }); + it("increments the updatedAtMs property", async function() { + const value = { id: "test1" }; + datastore.set(value); + const prev = value.updatedAtMs; + const updates = [{ id: "test1" }, { id: "test2" }]; datastore.setMany(updates); - assert(updates[0].updatedAtMs > prev[0], "updatedAtMs must increase"); - assert(updates[1].updatedAtMs > prev[1], "updatedAtMs must increase"); + assert(updates[0].updatedAtMs > prev, "updatedAtMs must increase"); + assert(updates[1].updatedAtMs > prev, "updatedAtMs must increase"); + }); + it("increments the updatedAtMs property past existing values", async function() { + const future = forceFuture(); + const updates = [{ id: "test1" }, { id: "test2" }]; + datastore.setMany(updates); + assert(updates[0].updatedAtMs > future, "updatedAtMs must increase"); + assert(updates[1].updatedAtMs > future, "updatedAtMs must increase"); }); }); @@ -667,7 +699,20 @@ describe("lib/datastore", function() { const value = { id: "test" }; datastore.delete(value); assert.notEqual(value.updatedAtMs, undefined); - assert(value.updatedAtMs > 0, "updatedAtMs greater than zero"); + assert(value.updatedAtMs > 0, "updatedAtMs not greater than zero"); + }); + it("increments the updatedAtMs property", function() { + const value = { id: "test" }; + datastore.set(value); + const prev = value.updatedAtMs; + datastore.delete(value); + assert(value.updatedAtMs > prev, "updatedAtMs must increase"); + }); + it("increments the updatedAtMs property past existing values", function() { + const future = forceFuture(); + const value = { id: "test" }; + datastore.delete(value); + assert(value.updatedAtMs > future, "updatedAtMs must increase"); }); it("sets the isDeleted property", function() { const value = { id: "test" }; @@ -704,6 +749,22 @@ describe("lib/datastore", function() { assert(values[0].updatedAtMs > 0, "updatedAtMs greater than zero"); assert(values[1].updatedAtMs > 0, "updatedAtMs greater than zero"); }); + it("increments the updatedAtMs property", function() { + const value = { id: "foo" }; + datastore.set(value); + const prev = value.updatedAtMs; + const values = [{id: "foo", updatedAtMs: prev}, {id: "bar", updatedAtMs: prev}]; + datastore.deleteMany(values); + assert(values[0].updatedAtMs > prev, "updatedAtMs must increase"); + assert(values[1].updatedAtMs > prev, "updatedAtMs must increase"); + }); + it("increments the updatedAtMs property past existing values", function() { + const future = forceFuture(); + const values = [{id: "foo"}, {id: "bar"}]; + datastore.deleteMany(values); + assert(values[0].updatedAtMs > future, "updatedAtMs must increase"); + assert(values[1].updatedAtMs > future, "updatedAtMs must increase"); + }); it("sets the isDeleted property", function() { const values = [{id: "foo"}, {id: "bar"}]; datastore.deleteMany(values); diff --git a/test/lib/external/FactorioVersions.test.js b/test/lib/external/FactorioVersions.test.js index 9058e6e0..c2e4f49a 100644 --- a/test/lib/external/FactorioVersions.test.js +++ b/test/lib/external/FactorioVersions.test.js @@ -121,6 +121,12 @@ describe("FactorioVersions", function() { assert.equal(versions.filter(v => v.stable).length, 48); assert.equal(versions.filter(v => !v.stable).length, 57); }); + it("parses versions from empty html file", async function() { + mockFetch(""); + + const versions = await fetchFactorioVersions(); + assert.equal(versions.length, 0); + }); it("parses versions from live api", async function() { slowTest(this); global.fetch = originalFetch; diff --git a/test/lib/prometheus.js b/test/lib/prometheus.js index b0f24ca3..8ef24147 100644 --- a/test/lib/prometheus.js +++ b/test/lib/prometheus.js @@ -651,8 +651,20 @@ describe("lib/prometheus", function() { let gauge = new prometheus.Gauge( "test", "Help", { register: false, labels: ["a", "b"] } ); - gauge.labels('x\\\\\n\"\"\n\\', "2"); - gauge.removeAll({ a: 'x\\\\\n\"\"\n\\' }); + + let labelValue = ""; + // Include every ASCII codepoint + for (let i = 0; i < 128; i++) { + labelValue += String.fromCodePoint(i); + } + // Include Unicode codepoints from the basic and supplementary planes. + labelValue += "©💣"; + + gauge.labels(labelValue, "2"); + gauge.removeAll({ a: labelValue }); + assert(gauge._values.size === 0, "Matching labels remained"); + gauge.labels("1", labelValue); + gauge.removeAll({ a: "1" }); assert(gauge._values.size === 0, "Matching labels remained"); }); });