Compare commits

...
Author SHA1 Message Date
ILLISISandClaude Fable 5.2 b781731ecc Sync the page shown on each quick bar row
player_sync restored the content of the quick bar slots but not which
page each row shows, which every save keeps per player on its own. After
switching servers the rows could show other pages, and with them another
set of slots.

Fixes #1201

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JyNnVzRW4jPWaoWTxCyhM7
2026-11-13 22:14:46 +11:00
Daniel c1826aea98 Merge pull request #1152 from bbassie/feature/live-role-updates
Live update roles on the user and role view pages
2026-10-30 22:22:23 +01:00
Cooldude2606andClaude Opus 5.5 9b654b1ad8 Start the controller and host CLIs faster (#1159)
* Answer the disconnect prepare in the close old connections test

The test's first connector had no Link, so the controller's disconnect
prepare went unanswered and it waited the full 10 s timeout.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Start the controller and host CLIs faster

Like ctl, the controller and host now compile schema validators on first
use instead of at import, which cost about 120 ms per invocation. The
Controller and Host classes are only imported by the commands that
start them, so config, bootstrap and the other commands no longer load
express, ws and the rest of the servers. clusteriocontroller config set
goes from 413 ms to 231 ms and clusteriohost config set from 412 ms to
225 ms.

The integration tests run the bins, like npx does, instead of the
package directories.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 20:43:33 +00:00
Bastiaan e7c21fb907 Merge pull request #1150 from bbassie/fix/node-engine-range
Require Node.js 22.18 or 24.2 for import.meta.main
2026-10-30 20:41:37 +00:00
Cooldude2606 cc8888fc69 Merge pull request #1160 from bbassie/ci/split-tests
Split each CI test job into two halves
2026-10-30 20:40:52 +00:00
bbassieandClaude Opus 5.5 b9d7033724 Test old Node.js versions only with the latest Factorio
Splitting the test jobs doubled them to 14, over GitHub's concurrent
job limit together with the other jobs. Every Factorio version now runs
on the latest Node.js only, and the other Node.js versions only with
the latest Factorio, which is 10 test jobs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 20:31:57 +00:00
bbassieandClaude Opus 5.5 75cdfbad8d Split the test jobs in two halves
The clusterioctl, plugin system and instance integration tests take
about half of the test time, so they run on one runner and the rest of
the tests on another, each half with its own controller and host.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 20:31:32 +00:00
Cooldude2606andClaude Opus 5.5 3707fc35fc Drop unused dependencies from web_ui and tests (#1163)
* Take FieldData from antd instead of rc-field-form

web_ui depended on rc-field-form 1.x for a single type import, while
antd 5 uses rc-field-form 2.x. Derive the type from antd's FormProps
and drop the dependency.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Move typescript to web_ui devDependencies

web_ui only needs typescript to build its own types. The runtime
dependency is a leftover from webpack's ts-loader, Vite doesn't use it,
and it pulls in a 30 MB native compiler for anyone installing web_ui.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Use native FormData in controller route tests

Node's FormData and Blob do what the form-data package did here, and
fetch sets the multipart Content-Type header itself.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Remove leftover typedoc.json files

TypeDoc was dropped in #1147, but these configs still ship in the
published packages.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 20:29:12 +00:00
BastiaanandClaude Opus 5.5 0f7c8be060 Use only the jimp modules the host export needs (#1162)
jimp pulls in every image format and plugin, about 60 packages and
26 MB that only export.ts uses. Build the Jimp class from @jimp/core
with the PNG format and the crop and resize plugins instead. The
generated spritesheet is byte-identical to the one from jimp.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 20:24:07 +00:00
Cooldude2606 53a4fce934 Merge pull request #1151 from bbassie/fix/stop-while-starting
Fix stopping an instance while it is starting
2026-10-30 20:04:22 +00:00
Bastiaan ed2057e4a2 Merge pull request #1149 from bbassie/feature/compat-ignore-control
Ignore control in the version compat message check
2026-10-30 19:57:48 +00:00
bbassieandClaude Opus 5.5 8738c74d7f Remove leftover typedoc.json files
TypeDoc was dropped in #1147, but these configs still ship in the
published packages.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 18:44:54 +00:00
bbassieandClaude Opus 5.5 c9db2fb29e Use native FormData in controller route tests
Node's FormData and Blob do what the form-data package did here, and
fetch sets the multipart Content-Type header itself.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 18:44:54 +00:00
bbassieandClaude Opus 5.5 41e3f50dbe Move typescript to web_ui devDependencies
web_ui only needs typescript to build its own types. The runtime
dependency is a leftover from webpack's ts-loader, Vite doesn't use it,
and it pulls in a 30 MB native compiler for anyone installing web_ui.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 18:44:54 +00:00
bbassieandClaude Opus 5.5 5df375d490 Take FieldData from antd instead of rc-field-form
web_ui depended on rc-field-form 1.x for a single type import, while
antd 5 uses rc-field-form 2.x. Derive the type from antd's FormProps
and drop the dependency.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 18:44:54 +00:00
bbassieandClaude Opus 5.5 0312a3b099 Start the controller and host CLIs faster
Like ctl, the controller and host now compile schema validators on first
use instead of at import, which cost about 120 ms per invocation. The
Controller and Host classes are only imported by the commands that
start them, so config, bootstrap and the other commands no longer load
express, ws and the rest of the servers. clusteriocontroller config set
goes from 413 ms to 231 ms and clusteriohost config set from 412 ms to
225 ms.

The integration tests run the bins, like npx does, instead of the
package directories.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 17:29:42 +00:00
bbassieandClaude Opus 5.5 a174c355d2 Answer the disconnect prepare in the close old connections test
The test's first connector had no Link, so the controller's disconnect
prepare went unanswered and it waited the full 10 s timeout.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 17:19:36 +00:00
Daniel 8a3aa56c11 Merge pull request #1157 from bbassie/refactor/plugin-state
Replace the PluginDetails booleans with a single state
2026-10-30 17:52:51 +01:00
Daniel c13b1fa78f Merge pull request #1155 from bbassie/fix/research-sync-session-loss
Resync research_sync after a new controller session
2026-10-30 17:50:58 +01:00
Daniel 31e132d4fd Merge pull request #1158 from bbassie/ci/setup-speed
Speed up CI setup and the coverage upload
2026-10-30 17:44:48 +01:00
bbassieandClaude Opus 5.5 b989815829 Speed up CI setup and coverage upload
pnpm/action-setup installs a bootstrap pnpm and then switches to the
packageManager version, about 10 s per job; npm installs it in 3 s.
setup-node took 4 s probing package managers for a cache it never
used. The pnpm store cache took 4 s to restore and 5 s to save, more
than downloading the packages costs, and the build cache no longer pays
off with TypeScript 7: pnpm install is 13-23 s without either cache
against 17-31 s with them.

The Codecov action searched the workspace and uploaded the raw V8
coverage in coverage/tmp next to lcov.info, 72 MB taking 11 s.

The version compatibility job ran tsc --build again after pnpm install
had already built everything.

Cancel superseded runs of branches and pull requests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 00:26:28 +00:00
bbassieandClaude Opus 5.5 814a4a201a Replace the PluginDetails booleans with a single state
loaded, enabled, pendingInstall, removed, noEntrypoint and nameTaken
allowed far more combinations than can happen, and every consumer had
to know which took priority. PluginDetails now has one state, with
loaded, loadsOnRestart and removed as getters over it.

2.0.3 web UIs and ctl send PluginListRequest straight to hosts and
require loaded and enabled, so those are still sent next to the
optional state. Plugins listed by 2.0.3 hosts get their state from
loaded and enabled.

The web UI planner gets the same treatment: MachinePlugin has one
state in place of disabled, noEntrypoint, dependencyTaken and
nameTaken. ctl host plugin list shows the state.

Closes #1134

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 00:05:23 +00:00
bbassieandClaude Opus 5.5 5589609b1b Resync research after a new controller session
While the host has no controller session, events between the instance
and the controller are dropped in both directions, and research_sync
only synced technologies on instance start. A technology finished during
the outage stayed unresearched on the controller and on every other
instance until the instance restarted.

On a new session the instance now runs the same technology sync as on
start, before taking the cluster queue. Contributions made while the
controller is unreachable are summed per technology and sent on connect
or resume, since progress from several instances can't be rebuilt from a
state sync that keeps the highest progress. A finish made while the
connection was dropped triggers the sync on resume too.

sync_technologies now runs while the game is running, so it skips
writing progress to a technology that finished after the dump.

Fixes #1123

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 00:04:15 +00:00
bbassieandClaude Opus 5.5 78f1e8a993 Live update roles on the user and role view pages
The user page set the role selector once and never updated it. The role
page reset its whole form on every role update, wiping unsaved edits.

Both pages now follow the live data and keep local edits on top. The role
page stores only the edited fields and drops them once they match the role
or when the viewer loses core.role.update. The user page syncs the
selector while it is not dirty and drops deleted roles from a dirty
selection. A role deleted while open now says so instead of "not found".

Fixes #775, fixes #776

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 22:52:59 +00:00
bbassieandClaude Opus 5.5 666dd48412 Show the instance control button as loading while starting or stopping
The button offered Stop while the instance was starting whenever the
start came from somewhere else, such as Load save, another tab or ctl.
It now shows the action in progress as loading until the status
settles. Kill stays available from the dropdown.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 22:52:09 +00:00
bbassieandClaude Opus 5.5 eb1e2e2a5e Wait for a start in progress before stopping an instance
A stop request sent while the instance was starting ran alongside the
start. During save preparation it did nothing and the instance came up
running anyway. Once Factorio was launched it put the server into the
stopping state, the start then failed with "Expected state running but
state is stopping" and its cleanup killed the server mid shutdown.

Stop and restart requests now wait for any start, stop or save in
progress to finish first, then stop the instance if it is running.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 22:52:09 +00:00
60 changed files with 851 additions and 383 deletions

No files matched your search

+6 -31
View File
@@ -1,5 +1,5 @@
name: Install dependencies
description: Set up pnpm and Node.js, restore caches and run pnpm install
description: Set up pnpm and Node.js and run pnpm install
inputs:
node-version:
@@ -12,40 +12,15 @@ inputs:
runs:
using: composite
steps:
- uses: pnpm/action-setup@v6
- uses: actions/setup-node@v7
with:
node-version: ${{ inputs.node-version }}
package-manager-cache: false
# The lockfile is not committed, so the usual lockfile hash key does not
# apply. Instead a new store snapshot is saved whenever a package.json
# changes or a week passes, and every run restores the most recent one.
- id: store
shell: bash
run: |
echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT"
echo "week=$(date -u +%G-%V)" >> "$GITHUB_OUTPUT"
- uses: actions/cache@v6
with:
path: ${{ steps.store.outputs.path }}
key: pnpm-store-${{ runner.os }}-${{ steps.store.outputs.week }}-${{ hashFiles('**/package.json', 'pnpm-workspace.yaml') }}
restore-keys: pnpm-store-${{ runner.os }}-
# The prepare scripts run tsc --build, which is incremental when its
# previous output is present. Every run saves its output and the next run
# starts from the most recent one.
- if: inputs.ignore-scripts != 'true'
uses: actions/cache@v6
with:
path: |
packages/*/dist
plugins/*/dist
key: build-${{ runner.os }}-${{ github.sha }}
restore-keys: build-${{ runner.os }}-
# pnpm/action-setup installs a bootstrap pnpm and then switches to the
# packageManager version, which takes about 10 seconds.
- shell: bash
run: npm install --global --no-audit --no-fund "$(node -p 'require("./package.json").packageManager.split("+")[0]')"
- shell: bash
run: pnpm i --no-frozen-lockfile ${{ inputs.ignore-scripts == 'true' && '--ignore-scripts' || '' }}
# Drop packages the restored store had but this install did not use, so
# the snapshot does not grow with every version bump.
- shell: bash
run: pnpm store prune
+4
View File
@@ -4,6 +4,10 @@ on:
push:
pull_request:
concurrency:
group: ${{ github.workflow }}-${{ github.ref == 'refs/heads/master' && github.sha || github.ref }}
cancel-in-progress: true
env:
EMMYLUA_CHECK_VERSION: "0.25.1"
+33 -11
View File
@@ -7,6 +7,12 @@ on:
push:
pull_request:
# Superseded runs of a branch or pull request are cancelled. Runs on master
# each get their own group so every commit there is tested.
concurrency:
group: ${{ github.workflow }}-${{ github.ref == 'refs/heads/master' && github.sha || github.ref }}
cancel-in-progress: true
env:
NODE_VERSION: 24.x
@@ -22,17 +28,33 @@ jobs:
strategy:
fail-fast: false
matrix:
# Test Node.js 24 and 26 across the Factorio matrix; Node.js 22 runs coverage below.
# To stay within the concurrent job limit, every Factorio version is
# tested on the latest Node.js, and every other supported Node.js
# version only with the latest Factorio. Coverage is performed on the
# oldest supported Node.js.
# Node.js release schedule: https://nodejs.org/en/about/releases/
node-version: [24.x, 26.x]
# Supported factorio versions are latest stable and one minor version before stable (even if from a previous major)
# Supported factorio versions are latest stable and one minor version before stable (even if from a previous major)
node-version: [26.x]
factorio-version: [1.1.110, 2.0.77, 2.1.17]
# Coverage is performed on the older of the supported node versions
# The tests run in two halves of similar length on separate runners,
# the clusterioctl, plugin system and instance integration tests and the rest.
tests: [ctl, other]
include:
- node-version: 24.x
factorio-version: 2.1.17
tests: ctl
- node-version: 24.x
factorio-version: 2.1.17
tests: other
- node-version: 22.x
factorio-version: 2.1.17
tests: ctl
coverage: true
- node-version: 22.x
factorio-version: 2.1.17
tests: other
coverage: true
steps:
@@ -45,21 +67,23 @@ jobs:
run: wget -q -O factorio.tar.gz https://www.factorio.com/get-download/${{ matrix.factorio-version }}/headless/linux64 && tar -xf factorio.tar.gz && rm factorio.tar.gz
- name: Run tests
if: ${{ !matrix.coverage }}
run: pnpm test
run: pnpm test --grep "^(Integration of Clusterio (clusterioctl|plugin system)|Clusterio Instance) " ${{ matrix.tests == 'other' && '--invert' || '' }}
- name: Run coverage
if: ${{ matrix.coverage }}
run: pnpm run ci-cover
run: pnpm run ci-cover --grep "^(Integration of Clusterio (clusterioctl|plugin system)|Clusterio Instance) " ${{ matrix.tests == 'other' && '--invert' || '' }}
- name: Upload coverage to Codecov
if: ${{ matrix.coverage }}
uses: codecov/codecov-action@v7
with:
files: ./coverage/lcov.info
# Otherwise the raw V8 coverage in coverage/tmp is uploaded as well
disable_search: true
token: ${{ secrets.CODECOV_TOKEN }}
- name: Upload test logs for debugging
if: ${{ !cancelled() }}
uses: actions/upload-artifact@v7
with:
name: logs-${{ matrix.node-version }}-${{ matrix.factorio-version }}
name: logs-${{ matrix.node-version }}-${{ matrix.factorio-version }}-${{ matrix.tests }}
path: temp/test/logs
version-compat:
@@ -87,8 +111,6 @@ jobs:
uses: ./.github/actions/pnpm-install
with:
node-version: ${{ env.NODE_VERSION }}
- name: Build
run: pnpm run build
- name: Test ${{ matrix.controller }} controller with ${{ matrix.host }} host
run: >
node scripts/version-compat-test.js
+3 -1
View File
@@ -254,6 +254,7 @@ If there are no changelog entries to add, remove the code block and start the ch
The version-compat job in CI runs the local controller with the latest published host, and the published controller with the local host.
It fails if a link message changed its src, dst, data or response schema since that release.
Control is left out of src and dst, and messages that only go between control and the controller are not checked, as the web UI is served by the controller it talks to.
If the break is intended, add an entry to [scripts/version-compat-breaks.json](../scripts/version-compat-breaks.json) in the same pull request.
Breaking changes are only accepted if the next release is intended to include them, see our decision on [breaking changes](/docs/decisions.md#breaking-changes).
The entry is keyed by the published version directly before the breaking change was introduced, which is the latest release at the time.
@@ -338,7 +339,8 @@ The format of this log should be self explanatory, please follow it carefully. P
For development, use the latest Node.js LTS release, following the [installation instructions](../README.md#installation).
Make sure the Node.js built-in APIs and npm packages you use remain compatible with the supported versions.
The GitHub Actions tests currently run on Node.js 24 and 26 across the Factorio matrix, with Node.js 22 used for coverage; see the [CI workflow](../.github/workflows/node.js.yml) for the version matrix.
The GitHub Actions tests run every Factorio version on the latest Node.js, and every other supported Node.js version with the latest Factorio only, which keeps the number of jobs within GitHub's concurrent job limit.
Coverage is collected on the oldest supported Node.js; see the [CI workflow](../.github/workflows/node.js.yml) for the version matrix.
For Factorio, active support covers the latest stable version and the previous major version, using version lines such as 2.1, 2.0 and 1.1 as described in the [Factorio support policy](decisions.md#factorio).
Older versions from 0.17 onwards receive passive support where it does not restrict actively supported versions.
+1 -2
View File
@@ -28,7 +28,7 @@
]
},
"engines": {
"node": ">=22"
"node": "^22.18.0 || >=24.2.0"
},
"workspaces": [
"packages/*",
@@ -46,7 +46,6 @@
"@types/node": "catalog:",
"c8": "^12.0.0",
"express": "catalog:",
"form-data": "^4.0.6",
"jsonwebtoken": "catalog:",
"mocha": "^11.8.0",
"oxlint": "^1.88.0",
+6 -1
View File
@@ -26,10 +26,12 @@ import DailyRotateFile from "winston-daily-rotate-file";
import jwt from "jsonwebtoken";
// homebrew modules
// Reduce startup time by lazy compiling schemas.
import "./src/set_lazy_schema_compilation.js";
import * as lib from "@clusterio/lib";
import { ConsoleTransport, levels, logger } from "@clusterio/lib";
import Controller from "./src/Controller.js";
import type Controller from "./src/Controller.js";
import UserManager from "./src/UserManager.js";
import UserRecord from "./src/UserRecord.js";
import packageConfig from "@clusterio/controller/package.json" with { type: "json" };
@@ -203,6 +205,7 @@ async function handleBootstrapCommand(
}
let ctlConfig = new lib.CtlConfig("control");
const { default: Controller } = await import("./src/Controller.js");
ctlConfig.set("ctl.controller_url", Controller.calculateControllerUrl(controllerConfig));
ctlConfig.set(
"ctl.controller_token",
@@ -475,6 +478,8 @@ async function startup() {
return;
}
// Loaded here as the server is not needed by the other commands
const { default: Controller } = await import("./src/Controller.js");
controller = await Controller.fromConfig(
controllerConfig,
pluginInfos,
+1 -1
View File
@@ -32,7 +32,7 @@
"clusteriocontroller": "./dist/node/controller.js"
},
"engines": {
"node": ">=22"
"node": "^22.18.0 || >=24.2.0"
},
"dependencies": {
@@ -0,0 +1,3 @@
// This needs to be a separate file due to ECMAScript Modules evaluating code in
// imported modules before any code in the file that imports it.
(global as any).lazySchemaCompilation = true;
-4
View File
@@ -1,4 +0,0 @@
{
"entryPoints": ["index.ts"],
"includeVersion": true
}
+1 -1
View File
@@ -28,7 +28,7 @@
"clusteriocreate": "./create.js"
},
"engines": {
"node": ">=22"
"node": "^22.18.0 || >=24.2.0"
},
"dependencies": {
+1 -1
View File
@@ -32,7 +32,7 @@
"clusterioctl": "./dist/node/ctl.js"
},
"engines": {
"node": ">=22"
"node": "^22.18.0 || >=24.2.0"
},
"dependencies": {
+1 -1
View File
@@ -271,7 +271,7 @@ hostPluginCommands.add(new lib.Command({
let hostId = await lib.resolveHost(ctl, args.host);
const plugins = await ctl.sendTo({ hostId }, new lib.PluginListRequest());
print(asTable(plugins.map(p => ({
title: p.title, version: p.version, loaded: p.loaded, enabled: p.enabled, npmPackage: p.npmPackage,
title: p.title, version: p.version, state: p.state, npmPackage: p.npmPackage,
}))));
},
}));
-4
View File
@@ -1,4 +0,0 @@
{
"entryPoints": ["index.ts"],
"includeVersion": true
}
+5 -1
View File
@@ -20,10 +20,12 @@ import winston from "winston";
import "winston-daily-rotate-file";
// internal libraries
// Reduce startup time by lazy compiling schemas.
import "./src/set_lazy_schema_compilation.js";
import * as lib from "@clusterio/lib";
import { ConsoleTransport, levels, logger } from "@clusterio/lib";
import Host from "./src/Host.js";
import type Host from "./src/Host.js";
import { createScenarioCommand, handleCreateScenarioCommand } from "./src/create_scenario.js";
let host: Host | undefined;
@@ -233,6 +235,8 @@ async function startHost() {
return;
}
// Loaded here as the server is not needed by the other commands
const { default: Host } = await import("./src/Host.js");
let hostConnector = new HostConnector(hostConfig, pluginInfos);
host = await Host.fromConfig(
hostConfig,
+6 -2
View File
@@ -33,13 +33,17 @@
"clusteriohost": "./dist/node/host.js"
},
"engines": {
"node": ">=22"
"node": "^22.18.0 || >=24.2.0"
},
"dependencies": {
"@clusterio/lib": "workspace:^",
"@jimp/core": "^1.6.1",
"@jimp/js-png": "^1.6.1",
"@jimp/plugin-crop": "^1.6.1",
"@jimp/plugin-resize": "^1.6.1",
"@jimp/types": "^1.6.1",
"@sinclair/typebox": "catalog:",
"jimp": "^1.6.1",
"pidusage": "^4.1.0",
"rcon-client": "^4.2.5",
"semver": "catalog:",
+12 -2
View File
@@ -1308,6 +1308,16 @@ end`.replace(/\r?\n/g, " ");
}
}
/** Stop the instance once a start, stop or save in progress has finished */
async _stopWhenSettled() {
// Stopping concurrently with these races them and asserts on the server state.
// Checked right before stop() as another waiter may have started stopping.
while (transitoryStatuses.has(this._status)) {
await this._waitForSettledStatus();
}
await this.stop();
}
async kill() {
if (this._status === "stopped") {
return;
@@ -1494,7 +1504,7 @@ end`.replace(/\r?\n/g, " ");
}
async handleInstanceRestartRequest(request: lib.InstanceRestartRequest) {
await this.stop();
await this._stopWhenSettled();
// Stopping invalidates this instance object, a new one must be created by the host
// To do this we simulate a waking an instance and sending a start request
const instanceConnection = await this._host._connectInstance(this.id);
@@ -1502,7 +1512,7 @@ end`.replace(/\r?\n/g, " ");
}
async handleInstanceStopRequest() {
await this.stop();
await this._stopWhenSettled();
}
async handleInstanceKillRequest() {
+9 -3
View File
@@ -1,7 +1,11 @@
// Export of item icons and locale
import fs from "node:fs/promises";
import path from "path";
import { Jimp, JimpMime, type JimpInstance } from "jimp";
import { createJimp } from "@jimp/core";
import png from "@jimp/js-png";
import * as crop from "@jimp/plugin-crop";
import * as resize from "@jimp/plugin-resize";
import type {} from "@jimp/types"; // needed to name jimp types in export.d.ts
import yazl from "yazl";
import * as lib from "@clusterio/lib";
@@ -147,7 +151,9 @@ async function loadFile(
return await modZip.zip.readFile(path.posix.join(modZip.root, filePath));
}
type Image = JimpInstance;
// Only the parts of jimp the export uses
const Jimp = createJimp({ formats: [png], plugins: [crop.methods, resize.methods] });
type Image = InstanceType<typeof Jimp>;
type IconCache = Map<string, Image | null>;
async function loadIcon(
server: FactorioServer,
@@ -618,7 +624,7 @@ async function buildExport(server: FactorioServer, zipCache: ZipCache) {
// Crop sheet to actual used height and write single spritesheet + metadata.
const usedRows = Math.ceil(state.pos / (SHEET_WIDTH / SHEET_ICON_SIZE));
state.sheet.crop({ x: 0, y: 0, w: SHEET_WIDTH, h: Math.max(usedRows * SHEET_ICON_SIZE, 1) });
zip.addBuffer(await state.sheet.getBuffer(JimpMime.png), "export/spritesheet.png");
zip.addBuffer(await state.sheet.getBuffer("image/png"), "export/spritesheet.png");
addJson("export/metadata.json", state.metadata satisfies lib.ExportMetadata);
zip.end();
@@ -0,0 +1,3 @@
// This needs to be a separate file due to ECMAScript Modules evaluating code in
// imported modules before any code in the file that imports it.
(global as any).lazySchemaCompilation = true;
-4
View File
@@ -1,4 +0,0 @@
{
"entryPoints": ["index.ts"],
"includeVersion": true
}
+1 -1
View File
@@ -46,7 +46,7 @@
"compile_validator": "node scripts/compile_validator.js"
},
"engines": {
"node": ">=22"
"node": "^22.18.0 || >=24.2.0"
},
"dependencies": {
+73 -21
View File
@@ -4,57 +4,94 @@ import type { PluginNodeEnvInfo } from "../plugin.js";
import { Address, MessageRequest } from "./messages_core.js";
import type { IUser } from "./UserDetails.js";
export const pluginStateSchema = Type.Union([
Type.Literal("loaded"),
Type.Literal("disabled"),
Type.Literal("unloadOnRestart"),
Type.Literal("loadOnRestart"),
Type.Literal("noEntrypoint"),
Type.Literal("pendingInstall"),
Type.Literal("removed"),
Type.Literal("removedLoaded"),
Type.Literal("nameTaken"),
]);
/**
* State of a plugin on a machine
*
* - loaded: Running and loaded again on the next restart
* - disabled: Not loaded as `<plugin>.load_plugin` is false
* - unloadOnRestart: Running, but disabled since startup
* - loadOnRestart: Not running, enabled since startup or skipped by recovery mode
* - noEntrypoint: Has no entrypoint for this machine, so it is never loaded here
* - pendingInstall: Installed since startup, loaded on the next restart
* - removed: Uninstalled since startup while not running
* - removedLoaded: Uninstalled since startup, running until the next restart
* - nameTaken: Not loaded as another package provides a plugin with the same name
*/
export type PluginState = Static<typeof pluginStateSchema>;
/* This is similar to other plugin definitions but with lots removed */
export class PluginDetails {
constructor(
public name: string,
public title: string,
public version: string,
public loaded: boolean,
public enabled: boolean,
public state: PluginState,
public description?: string,
public npmPackage?: string,
/** Version installed on disk, differs from version when an update is waiting for a restart */
public installedVersion?: string,
/** Installed after the machine started, will be loaded on the next restart */
public pendingInstall?: boolean,
/** Uninstalled after the machine started, will not be loaded on the next restart */
public removed?: boolean,
/** Name in the root package.json dependencies, differs from npmPackage for aliases */
public dependencyName?: string,
/** Has no entrypoint for this machine, so it is never loaded here */
public noEntrypoint?: boolean,
/** Not loaded as another package provides a plugin with the same name */
public nameTaken?: boolean,
) {}
static jsonSchema = Type.Object({
name: Type.String(),
title: Type.String(),
version: Type.String(),
// Missing from 2.0.3 and older, which require loaded and enabled instead
state: Type.Optional(pluginStateSchema),
loaded: Type.Boolean(),
enabled: Type.Boolean(),
description: Type.Optional(Type.String()),
npmPackage: Type.Optional(Type.String()),
installedVersion: Type.Optional(Type.String()),
pendingInstall: Type.Optional(Type.Boolean()),
removed: Type.Optional(Type.Boolean()),
dependencyName: Type.Optional(Type.String()),
noEntrypoint: Type.Optional(Type.Boolean()),
nameTaken: Type.Optional(Type.Boolean()),
});
static fromJSON(json: Static<typeof this.jsonSchema>) {
return new this(
json.name, json.title, json.version, json.loaded, json.enabled,
json.description, json.npmPackage, json.installedVersion, json.pendingInstall, json.removed,
json.dependencyName, json.noEntrypoint, json.nameTaken,
json.name, json.title, json.version, json.state ?? this.loadState(json.loaded, json.enabled),
json.description, json.npmPackage, json.installedVersion, json.dependencyName,
);
}
static fromNodeEnvInfo(pluginInfo: PluginNodeEnvInfo, loaded: boolean, enabled: boolean) {
toJSON(): Static<typeof PluginDetails.jsonSchema> {
return {
name: this.name,
title: this.title,
version: this.version,
state: this.state,
loaded: this.loaded,
enabled: this.loadsOnRestart,
description: this.description,
npmPackage: this.npmPackage,
installedVersion: this.installedVersion,
dependencyName: this.dependencyName,
};
}
/** State of a plugin known since startup */
static loadState(loaded: boolean, enabled: boolean): PluginState {
if (loaded) {
return enabled ? "loaded" : "unloadOnRestart";
}
return enabled ? "loadOnRestart" : "disabled";
}
static fromNodeEnvInfo(pluginInfo: PluginNodeEnvInfo, state: PluginState) {
return new this(
pluginInfo.name, pluginInfo.title, pluginInfo.version, loaded, enabled,
pluginInfo.name, pluginInfo.title, pluginInfo.version, state,
pluginInfo.description, pluginInfo.npmPackage,
);
}
@@ -63,10 +100,25 @@ export class PluginDetails {
static fromPackageJson(packageName: string, packageJson: any, npmPackage?: string, dependencyName?: string) {
const description = typeof packageJson.description === "string" ? packageJson.description : undefined;
return new this(
packageName, packageName, packageJson.version, false, false,
description, npmPackage, packageJson.version, true, undefined, dependencyName,
packageName, packageName, packageJson.version, "pendingInstall",
description, npmPackage, packageJson.version, dependencyName,
);
}
/** Running on the machine now */
get loaded() {
return ["loaded", "unloadOnRestart", "removedLoaded"].includes(this.state);
}
/** Running on the machine after the next restart */
get loadsOnRestart() {
return ["loaded", "loadOnRestart", "pendingInstall"].includes(this.state);
}
/** Uninstalled since startup */
get removed() {
return this.state === "removed" || this.state === "removedLoaded";
}
}
export class PluginListRequest {
+5 -8
View File
@@ -128,14 +128,12 @@ export async function listPluginDetails(
for (const pluginInfo of pluginInfos) {
const dir = await realDir(path.dirname(pluginInfo.packagePath));
knownDirs.add(dir);
const loaded = isLoaded(pluginInfo);
// Without an entrypoint for this machine the plugin has no load_plugin field here
const loadable = hasEntrypoint(pluginInfo);
const plugin = PluginDetails.fromNodeEnvInfo(
pluginInfo, isLoaded(pluginInfo), loadable && isEnabled(pluginInfo),
pluginInfo,
hasEntrypoint(pluginInfo) ? PluginDetails.loadState(loaded, isEnabled(pluginInfo)) : "noEntrypoint",
);
if (!loadable) {
plugin.noEntrypoint = true;
}
if (Object.hasOwn(dependencies, pluginInfo.requirePath)) {
plugin.dependencyName = pluginInfo.requirePath;
}
@@ -147,7 +145,7 @@ export async function listPluginDetails(
packageJson = await readPackageJson(path.dirname(pluginInfo.packagePath), false);
if (packageJson === undefined) {
// Uninstalled since startup, it won't be found on the next restart
plugin.removed = true;
plugin.state = loaded ? "removedLoaded" : "removed";
}
} catch {
// Ignored: npm is still writing it
@@ -185,8 +183,7 @@ export async function listPluginDetails(
const plugin = PluginDetails.fromPackageJson(packageName, packageJson, npmPackage, dependencyName);
if (takenDirs.has(dir)) {
// Skipped on startup as its plugin name is taken, will be again
plugin.pendingInstall = undefined;
plugin.nameTaken = true;
plugin.state = "nameTaken";
}
plugins.push(plugin);
}
-4
View File
@@ -1,4 +0,0 @@
{
"entryPoints": ["index.ts"],
"includeVersion": true
}
+3 -4
View File
@@ -29,7 +29,7 @@
"test": "echo \"Error: no test specified\" && exit 1"
},
"engines": {
"node": ">=22"
"node": "^22.18.0 || >=24.2.0"
},
"dependencies": {
@@ -45,7 +45,6 @@
"os-browserify": "^0.3.0",
"path-browserify": "^1.0.1",
"process": "^0.11.10",
"rc-field-form": "^1.44.0",
"react": "catalog:",
"react-dom": "catalog:",
"react-markdown": "catalog:",
@@ -53,7 +52,6 @@
"react-router-dom": "catalog:",
"semver": "catalog:",
"stream-browserify": "^3.0.0",
"typescript": "catalog:",
"util": "^0.12.5",
"vite": "catalog:"
},
@@ -61,6 +59,7 @@
"@types/node": "catalog:",
"@types/react": "catalog:",
"@types/react-dom": "^18.3.7",
"@types/semver": "catalog:"
"@types/semver": "catalog:",
"typescript": "catalog:"
}
}
@@ -25,8 +25,10 @@ export default function InstanceControlButton(props: InstanceControlButtonProps)
const instanceId = props.instance.id;
const instanceStatus = props.instance.status;
const showStart = ["stopped", "stopping"].includes(instanceStatus);
const showStop = ["running", "starting"].includes(instanceStatus);
// While starting or stopping the button shows the action in progress as loading.
const showStart = ["stopped", "starting"].includes(instanceStatus);
const showStop = ["running", "stopping"].includes(instanceStatus);
const transitioning = ["starting", "stopping"].includes(instanceStatus);
function actionFinish() {
setSwitching(false);
@@ -37,7 +39,7 @@ export default function InstanceControlButton(props: InstanceControlButtonProps)
const actions: VariableDropdownButtonProps["actions"] = [];
if (account.hasPermission("core.instance.start") && showStart !== switching) {
if (account.hasPermission("core.instance.start") && showStart) {
actions.push({
key: "start",
label: "Start",
@@ -101,7 +103,7 @@ export default function InstanceControlButton(props: InstanceControlButtonProps)
{...props}
type="primary"
actions={actions}
loading={switching}
loading={switching || transitioning}
disabled={!(showStart || showStop)}
/>
<Popconfirm
@@ -1,11 +1,10 @@
import React, { Fragment, memo, useCallback, useEffect, useContext, useRef, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { FieldData } from "rc-field-form/lib/interface";
import {
Button, Card, ColorPicker, Checkbox, Col, ConfigProvider, Descriptions, Form, Input, Pagination,
Popconfirm, Row, Table, Tag, Typography, Select, Skeleton, Space, Spin, Switch, Modal, Tooltip,
TablePaginationConfig, Alert, notification,
TablePaginationConfig, Alert, notification, FormProps,
} from "antd";
import {
@@ -35,6 +34,8 @@ const { logger } = lib;
const { Text } = Typography;
const strcmp = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" }).compare;
type FieldData = NonNullable<FormProps["fields"]>[number];
type ModChange =
{
type: "name" | "description"
@@ -160,7 +160,7 @@ export function PluginVersions(props: { status: PluginStatus, latest?: string })
.sort(compareVersions)
.reverse();
const count = (filter: (machine: MachinePlugin) => boolean) => machines.filter(filter).length;
const missing = count(machine => (!machine.plugin || Boolean(machine.plugin.removed)) && !machine.nameTaken);
const missing = count(machine => machine.state === "missing" || machine.state === "dependencyTaken");
return <>
{versions.map(version => {
@@ -175,14 +175,14 @@ export function PluginVersions(props: { status: PluginStatus, latest?: string })
</div>;
})}
{count(isPending) > 0 && <CountLine label="Restart pending on" count={count(isPending)} />}
{count(machine => machine.disabled) > 0 && <CountLine
label="Disabled on" count={count(machine => machine.disabled)} type="secondary"
{count(machine => machine.state === "disabled") > 0 && <CountLine
label="Disabled on" count={count(machine => machine.state === "disabled")} type="secondary"
/>}
{count(machine => machine.noEntrypoint) > 0 && <CountLine
label="No entrypoint on" count={count(machine => machine.noEntrypoint)} type="secondary"
{count(machine => machine.state === "noEntrypoint") > 0 && <CountLine
label="No entrypoint on" count={count(machine => machine.state === "noEntrypoint")} type="secondary"
/>}
{count(machine => machine.nameTaken) > 0 && <CountLine
label="From another package on" count={count(machine => machine.nameTaken)} type="secondary"
{count(machine => machine.state === "nameTaken") > 0 && <CountLine
label="From another package on" count={count(machine => machine.state === "nameTaken")} type="secondary"
/>}
{missing > 0 && <CountLine label="Not installed on" count={missing} type="warning" />}
{unknown.length > 0 && <CountLine label="Plugins not reported by" count={unknown.length} type="warning" />}
+64 -43
View File
@@ -1,4 +1,4 @@
import React, { useEffect, useContext, useState, useMemo } from "react";
import React, { useEffect, useContext, useState, useMemo, useRef } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { Button, Checkbox, Col, Grid, Input, Popconfirm, Row, Space, Spin, Tooltip } from "antd";
import { ExclamationCircleOutlined, StarOutlined, DeleteOutlined} from "@ant-design/icons";
@@ -88,7 +88,7 @@ function PermissionItem({
/** A group of permissions; includes a title, clear and select all buttons, and the permission items */
function PermissionGroup({
groupName, permissions, canUpdate, baselineState, permissionState, setPermissionState,
groupName, permissions, canUpdate, baselineState, permissionState, setPermissions,
}: {
groupName: string;
permissions: lib.Permission[];
@@ -96,17 +96,10 @@ function PermissionGroup({
baselineState: Record<string, boolean>;
permissionState: Record<string, boolean>;
setPermissionState: React.Dispatch<React.SetStateAction<Record<string, boolean>>>;
setEdited?: (value: boolean) => void;
setPermissions: (names: string[], value: boolean) => void;
}) {
function setGroup(value: boolean) {
setPermissionState((prev: any) => {
const next = { ...prev };
for (let p of permissions) {
next[p.name] = value;
}
return next;
});
setPermissions(permissions.map(p => p.name), value);
}
return (
@@ -129,7 +122,7 @@ function PermissionGroup({
checked={permissionState[p.name]}
baseline={baselineState[p.name]}
disabled={!canUpdate}
onChange={v => setPermissionState((prev: any) => ({ ...prev, [p.name]: v }))}
onChange={v => setPermissions([p.name], v)}
/>
))}
</div>
@@ -171,11 +164,12 @@ export default function RoleViewPage() {
const control = useContext(ControlContext);
const [role, synced] = useRole(roleId);
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [permissionState, setPermissionState] = useState<Record<string, boolean>>({});
const [baselineState, setBaselineState] = useState<Record<string, boolean>>({});
// Local edits on top of the live role
const [nameEdit, setNameEdit] = useState<string>();
const [descriptionEdit, setDescriptionEdit] = useState<string>();
const [permissionEdits, setPermissionEdits] = useState<Record<string, boolean>>({});
const [search, setSearch] = useState("");
const loadedRoleId = useRef<number>(undefined);
// Hidden below lg, where the site sider leaves too little room; undefined until measured
const screens = Grid.useBreakpoint();
const showGroupList = screens.lg !== false;
@@ -183,20 +177,35 @@ export default function RoleViewPage() {
const allPermissions = useMemo(() => [...lib.permissions.values()], []);
const canUpdate = Boolean(account.hasPermission("core.role.update"));
const baselineState = useMemo(() => Object.fromEntries(
allPermissions.map(p => [p.name, Boolean(role?.permissions.has(p.name))])
), [role, allPermissions]);
// Drop edits that now match the role, and all edits if we can no longer apply them
useEffect(() => {
if (!role) {
if (!role || !canUpdate) {
setNameEdit(undefined);
setDescriptionEdit(undefined);
setPermissionEdits(prev => (Object.keys(prev).length ? {} : prev));
return;
}
setNameEdit(prev => (prev === role.name ? undefined : prev));
setDescriptionEdit(prev => (prev === role.description ? undefined : prev));
setPermissionEdits(prev => {
const next = Object.fromEntries(Object.entries(prev).filter(([k, v]) => v !== baselineState[k]));
return Object.keys(next).length === Object.keys(prev).length ? prev : next;
});
}, [role, canUpdate, baselineState]);
const base = Object.fromEntries(
allPermissions.map(p => [p.name, role.permissions.has(p.name)])
);
useEffect(() => {
setNameEdit(undefined);
setDescriptionEdit(undefined);
setPermissionEdits({});
}, [roleId]);
setName(role.name);
setDescription(role.description);
setPermissionState(base);
setBaselineState(base);
}, [role, allPermissions]);
if (role) {
loadedRoleId.current = roleId;
}
// If no role then display either the loading screen or the not found screen
if (!role) {
@@ -211,10 +220,16 @@ export default function RoleViewPage() {
return <PageLayout nav={nav}>
<PageHeader title="Role not found" />
<p>Role with id {roleId} was not found on the controller.</p>
<p>Role with id {roleId} {
loadedRoleId.current === roleId ? "has been deleted" : "was not found on the controller"
}.</p>
</PageLayout>;
}
const name = nameEdit ?? role.name;
const description = descriptionEdit ?? role.description;
const permissionState = { ...baselineState, ...permissionEdits };
// Apply the filter by searching all permission fields
const filtered = allPermissions.filter(p => {
const q = search.toLowerCase();
@@ -243,27 +258,33 @@ export default function RoleViewPage() {
const grantedGroups = new Set(allPermissions.filter(p => permissionState[p.name]).map(p => getGroupName(p.name)));
// Check if any changes have been made to the permissions or role metadata
const permissionChanged = Object.keys(permissionState).some(k => permissionState[k] !== baselineState[k]);
const permissionChanged = Object.keys(permissionEdits).some(k => permissionEdits[k] !== baselineState[k]);
const metaChanged = name !== role.name || description !== role.description;
const edited = permissionChanged || metaChanged;
/** Applies all pending changes */
function applyChanges() {
const newPermissions = Object.entries(permissionState).filter(([, v]) => v).map(([k]) => k);
control.send(new lib.RoleUpdateRequest(roleId, name || "", description || "", newPermissions))
.then(() => {
setBaselineState(permissionState);
})
.catch(notifyErrorHandler("Error applying changes"));
function setPermissions(names: string[], value: boolean) {
setPermissionEdits(prev => {
const next = { ...prev };
for (let permissionName of names) {
next[permissionName] = value;
}
return next;
});
}
/** Revert any pending changes */
function revertChanges() {
setPermissionState(baselineState);
if (role) {
setName(role.name);
setDescription(role.description);
}
setNameEdit(undefined);
setDescriptionEdit(undefined);
setPermissionEdits({});
}
/** Applies all pending changes */
function applyChanges() {
const newPermissions = Object.entries(permissionState).filter(([, v]) => v).map(([k]) => k);
control.send(new lib.RoleUpdateRequest(roleId, name, description, newPermissions))
.then(revertChanges)
.catch(notifyErrorHandler("Error applying changes"));
}
return (
@@ -320,12 +341,12 @@ export default function RoleViewPage() {
<div style={{ marginBottom: 16, display: "flex", alignItems: "center", gap: 12 }}>
<label style={{ width: 110, flexShrink: 0 }}>Name</label>
<Input value={name} disabled={!canUpdate} onChange={e => setName(e.target.value)} />
<Input value={name} disabled={!canUpdate} onChange={e => setNameEdit(e.target.value)} />
</div>
<div style={{ marginBottom: 16, display: "flex", alignItems: "center", gap: 12 }}>
<label style={{ width: 110, flexShrink: 0 }}>Description</label>
<Input value={description} disabled={!canUpdate} onChange={e => setDescription(e.target.value)} />
<Input value={description} disabled={!canUpdate} onChange={e => setDescriptionEdit(e.target.value)} />
</div>
<ResourceNotes resourceType="role" resourceId={roleId} />
@@ -363,7 +384,7 @@ export default function RoleViewPage() {
permissions={permissions}
permissionState={permissionState}
baselineState={baselineState}
setPermissionState={setPermissionState}
setPermissions={setPermissions}
canUpdate={canUpdate}
/>
))}
@@ -41,7 +41,7 @@ export default function UserViewPage() {
let control = useContext(ControlContext);
let [instances] = useInstances();
const [user, synced] = useUser(userName);
let [roles] = useRoles();
let [roles, rolesSynced] = useRoles();
let [form] = Form.useForm();
const statsTable = useTableQueryState<[number, lib.PlayerStats]>({
namespace: "stats", defaultSortKey: "instance",
@@ -62,6 +62,20 @@ export default function UserViewPage() {
}
}, [account, form, user, banReasonDirty]);
useEffect(() => {
if (!user || !account.hasPermission("core.user.update_roles")) {
return;
}
const current: number[] = form.getFieldValue("roles") ?? [];
// Keep local edits, but drop roles deleted since
const next = rolesDirty
? current.filter(id => !rolesSynced || roles.has(id))
: [...user.roleIds];
if (next.length !== current.length || next.some((id, i) => id !== current[i])) {
form.setFieldsValue({ "roles": next });
}
}, [account, form, user, roles, rolesSynced, rolesDirty]);
let nav = [{ name: "Users", path: "/users" }, { name: userName }];
if (!user) {
if (!synced) {
@@ -77,7 +91,6 @@ export default function UserViewPage() {
</PageLayout>;
}
// TODO: Update displayed roles when user roles change and have not been edited locally
let roleSelector = <Form.Item
noStyle
name="roles"
+62 -46
View File
@@ -16,18 +16,26 @@ export type Machine = {
};
export type MachinePlugins = Machine & { plugins: lib.PluginDetails[] };
/**
* What happens with a plugin on one machine, in order of precedence
*
* - nameTaken: Another package here provides the plugin by the same name, startup skips this one
* - dependencyTaken: Not installed, and the group's npm package name is a dependency here for
* another plugin, such as an alias
* - missing: Not installed
* - noEntrypoint: Installed but never loaded on this machine as it has no entrypoint for it
* - disabled: Installed but not loaded after the next restart
* - enabled: Installed and loaded after the next restart
*/
export type MachinePluginState =
"nameTaken" | "dependencyTaken" | "missing" | "noEntrypoint" | "disabled" | "enabled";
/** A plugin on one machine, with the version it runs now and the version it will run after a restart */
export type MachinePlugin = Machine & {
plugin?: lib.PluginDetails,
state: MachinePluginState,
running?: string,
afterRestart?: string,
disabled: boolean,
/** Installed but never loaded on this machine as it has no entrypoint for it */
noEntrypoint: boolean,
/** The group's npm package name is a dependency here for another plugin, such as an alias */
dependencyTaken: boolean,
/** Another package here provides the plugin by the same name, startup skips this one */
nameTaken: boolean,
/** A host from before installedVersion was reported, it can't install a specific version */
oldHost: boolean,
};
@@ -53,45 +61,51 @@ export function compareVersions(a: string, b: string) {
return strcmp(a, b);
}
/** Not uninstalled since startup and not skipped by name, so its plugin name is in use */
function holdsName(plugin: lib.PluginDetails) {
return !plugin.removed && plugin.state !== "pendingInstall" && plugin.state !== "nameTaken";
}
function machinePluginState(
plugins: lib.PluginDetails[], plugin: lib.PluginDetails | undefined, key: string, names: Set<string>,
): MachinePluginState {
if (plugin?.state === "nameTaken") {
return "nameTaken";
}
const absent = !plugin || plugin.removed;
if ((absent || plugin.state === "pendingInstall") && plugins.some(
candidate => pluginStatusKey(candidate) !== key && names.has(candidate.name) && holdsName(candidate)
)) {
return "nameTaken";
}
if (absent) {
return plugins.some(candidate => candidate.dependencyName === key && !candidate.removed)
? "dependencyTaken" : "missing";
}
if (plugin.state === "noEntrypoint") {
return "noEntrypoint";
}
return plugin.loadsOnRestart ? "enabled" : "disabled";
}
/** The plugin with the given key on one machine, names are the plugin names known for that key */
function machinePlugin(machine: MachinePlugins, key: string, names: Set<string>): MachinePlugin {
const { plugins, ...rest } = machine;
// A package removed and installed again is listed twice, the removed one may still be running
const candidates = plugins.filter(candidate => pluginStatusKey(candidate) === key);
const plugin = candidates.find(candidate => !candidate.removed) ?? candidates[0];
const absent = !plugin || Boolean(plugin.removed);
const dependencyTaken = absent && plugins.some(
candidate => candidate.dependencyName === key && !candidate.removed
);
const nameTaken = Boolean(plugin?.nameTaken) || (absent || Boolean(plugin.pendingInstall)) && plugins.some(
candidate => (
pluginStatusKey(candidate) !== key && names.has(candidate.name)
&& !candidate.removed && !candidate.pendingInstall && !candidate.nameTaken
)
);
const noEntrypoint = Boolean(plugin?.noEntrypoint);
const state = machinePluginState(plugins, plugin, key, names);
const oldHost = !machine.isController && plugins.length > 0 && plugins.every(
candidate => candidate.installedVersion === undefined && !candidate.removed
);
const entry: MachinePlugin = {
...rest, plugin, disabled: false, noEntrypoint, dependencyTaken, nameTaken, oldHost,
return {
...rest,
plugin,
state,
oldHost,
running: candidates.find(candidate => candidate.loaded)?.version,
afterRestart: state === "enabled" && plugin ? plugin.installedVersion ?? plugin.version : undefined,
};
// What runs now follows loaded, what runs after a restart follows enabled and what is on disk
entry.running = candidates.find(candidate => candidate.loaded)?.version;
if (nameTaken) {
return entry;
}
if (plugin?.pendingInstall) {
// Plugins load by default, so a new install runs after the next restart
entry.afterRestart = plugin.installedVersion ?? plugin.version;
} else if (plugin && !plugin.removed && !noEntrypoint) {
if (plugin.enabled) {
entry.afterRestart = plugin.installedVersion ?? plugin.version;
} else {
entry.disabled = true;
}
}
return entry;
}
/** Group the plugin lists of every machine by plugin */
@@ -107,7 +121,7 @@ export function computePluginStatuses(
if (!titles.has(key) || plugin.loaded) {
titles.set(key, plugin.title);
}
if (!plugin.pendingInstall && !plugin.nameTaken) {
if (plugin.state !== "pendingInstall" && plugin.state !== "nameTaken") {
pluginNames.set(key, (pluginNames.get(key) ?? new Set()).add(plugin.name));
}
}
@@ -154,15 +168,15 @@ export function planUpdate(
if (installed && compareVersions(plugin.installedVersion ?? plugin.version, version) >= 0) {
continue;
}
const kind = !installed || plugin.pendingInstall ? "install" : "update";
const kind = !installed || plugin.state === "pendingInstall" ? "install" : "update";
const skip = (reason: string, skipKind: PackageOperation["kind"] = kind) => {
plan.skipped.push({ kind: skipKind, target, reason });
};
if (machine.offline) {
skip("offline");
} else if (machine.nameTaken) {
} else if (machine.state === "nameTaken") {
skip("another package provides this plugin", "install");
} else if (machine.dependencyTaken) {
} else if (machine.state === "dependencyTaken") {
skip(`another package is installed as ${status.npmPackage}`, "install");
} else if (installed && !plugin.npmPackage) {
// An alias, private or local package, installing the group's npm package would replace it
@@ -211,18 +225,20 @@ export function machineStatusLabels(machine: MachinePlugin) {
if (machine.offline) {
labels.push("Offline");
}
if (machine.nameTaken) {
if (machine.state === "nameTaken") {
labels.push("Another package provides this plugin");
} else if (!machine.plugin || machine.plugin.removed) {
labels.push(machine.dependencyTaken ? "Another package is installed by this name" : "Not installed");
} else if (machine.state === "dependencyTaken") {
labels.push("Another package is installed by this name");
} else if (machine.state === "missing") {
labels.push("Not installed");
} else {
if (!machine.plugin.npmPackage) {
if (!machine.plugin?.npmPackage) {
labels.push("Not installed from npm");
}
if (machine.noEntrypoint) {
if (machine.state === "noEntrypoint") {
labels.push("No entrypoint for this machine");
}
if (machine.disabled) {
if (machine.state === "disabled") {
labels.push("Disabled");
}
}
-4
View File
@@ -1,4 +0,0 @@
{
"entryPoints": ["src/index.ts"],
"includeVersion": true
}
+1 -1
View File
@@ -30,7 +30,7 @@
"test": "echo \"Error: run tests from root\" && exit 1"
},
"engines": {
"node": ">=22"
"node": "^22.18.0 || >=24.2.0"
},
"peerDependencies": {
+1 -1
View File
@@ -30,7 +30,7 @@
"test": "echo \"Error: run tests from root\" && exit 1"
},
"engines": {
"node": ">=22"
"node": "^22.18.0 || >=24.2.0"
},
"peerDependencies": {
+1 -1
View File
@@ -166,7 +166,7 @@ Defaults to `true`.
### player_sync.sync_quick_bar
Sync the quick bar.
Sync the quick bar and the page shown on each of its rows.
Defaults to `true`.
+1 -1
View File
@@ -9,7 +9,7 @@ export const components = {
// inventories holds the god inventory and the hidden ghost inventory
inventories: ["character", "crafting_queue", "inventories"],
logistics: ["personal_logistic_slots"],
quick_bar: ["quick_bar", "hotbar"],
quick_bar: ["quick_bar", "quick_bar_pages", "hotbar"],
settings: ["flashlight", "shortcuts", "game_view_settings"],
recipe_notifications: ["recipe_notifications"],
} as const;
+2
View File
@@ -23,6 +23,7 @@ export type IpcPlayerData = {
character?: any,
inventories?: any,
quick_bar?: any,
quick_bar_pages?: number[],
hotbar?: Record<string, string>,
personal_logistic_slots?: any,
crafting_queue?: any,
@@ -181,6 +182,7 @@ const jsonImportedPlayerData = Type.Object({
character: Type.Optional(jsonCharacter),
inventories: Type.Optional(jsonInventories),
quick_bar: Type.Optional(Type.Record(Type.String(), jsonQuickBarSlot)),
quick_bar_pages: Type.Optional(Type.Array(Type.Integer({ minimum: 1, maximum: 10 }), { maxItems: 10 })),
hotbar: Type.Optional(Type.Record(Type.String(), Type.String())),
personal_logistic_slots: Type.Optional(jsonPersonalLogisticSlots),
crafting_queue: Type.Optional(jsonCraftingQueue),
+27
View File
@@ -713,6 +713,30 @@ function serialize.deserialize_quick_bar(player, serialized)
end
end
--- Quick bar page shown on each quick bar row of the screen, kept by every save on its own
--- @param player LuaPlayer
--- @return integer[]?
function serialize.serialize_quick_bar_pages(player)
local pages = {}
for screen = 1, 10 do
-- pcall: the number of rows the API knows is not documented for older versions
local ok, page = pcall(player.get_active_quick_bar_page, screen)
if not ok or not page then
break
end
pages[screen] = page
end
return pages[1] and pages or nil
end
--- @param player LuaPlayer
--- @param pages integer[]? Absent in data from before pages were synced, the rows then stay as they are here
function serialize.deserialize_quick_bar_pages(player, pages)
for screen, page in ipairs(pages or {}) do
player.set_active_quick_bar_page(screen, page)
end
end
-- Crafting queue is a table with the following fields:
-- crafting_queue
-- ingredients
@@ -974,6 +998,7 @@ end
--- @field inventories table<string, table>?
--- @field hotbar table<string, string>?
--- @field quick_bar table<string, QuickBarSlotEncoded>?
--- @field quick_bar_pages integer[]? Page shown on each quick bar row
--- @field personal_logistic_slots table?
--- @field crafting_queue table?
--- @field recipe_notifications string?
@@ -1178,6 +1203,7 @@ function serialize.serialize_player(player, failed_deserialization, components)
-- Serialize quick bar
if syncs(components, "quick_bar") then
serialized.quick_bar = serialize.serialize_quick_bar(player)
serialized.quick_bar_pages = serialize.serialize_quick_bar_pages(player)
end
-- Serialize crafting queue
@@ -1444,6 +1470,7 @@ function serialize.deserialize_player(player, serialized, components, local_cont
-- Deserialize quick bar (named hotbar in old data)
if syncs(components, "quick_bar") then
serialize.deserialize_quick_bar(player, serialized.quick_bar or serialized.hotbar)
serialize.deserialize_quick_bar_pages(player, serialized.quick_bar_pages)
end
-- Deserialize recipe notifications
+1 -1
View File
@@ -30,7 +30,7 @@
"test": "echo \"Error: run tests from root\" && exit 1"
},
"engines": {
"node": ">=22"
"node": "^22.18.0 || >=24.2.0"
},
"peerDependencies": {
+3
View File
@@ -117,6 +117,7 @@ describe("player_sync plugin", function() {
3: { t: "i", n: "main", s: 4, b: [2] },
4: { t: "r", p: [1], y: "blueprint" },
},
quick_bar_pages: [2, 1, 3],
crafting_queue: {
crafting_queue: [{ recipe: "iron-gear-wheel", count: 5 }],
crafting_queue_progress: 0.25,
@@ -172,6 +173,8 @@ describe("player_sync plugin", function() {
["quick_bar", { 1: { t: "i", n: "main" } }],
["quick_bar", { 1: { t: "r", y: "blueprint" } }],
["quick_bar", { 1: 5 }],
["quick_bar_pages", [0]],
["quick_bar_pages", [11]],
["personal_logistic_slots", { enabled: true, sections: [] }],
["personal_logistic_slots", { enabled: true, trash_not_requested: false, sections: [{}] }],
["personal_logistic_slots", { 1: { min: 50 } }],
+84 -40
View File
@@ -24,9 +24,13 @@ type IpcFinished = {
type IpcQueue = { queue: unknown, player?: string };
export default async function(context: InstancePluginContext) {
const { instance, logger, plugin } = context;
const { instance, host, logger, plugin } = context;
let syncStarted = false;
let queueEventsApplied = 0;
// Contributions made while the controller is unreachable, summed per technology.
// Unlike finished research they can't be recovered from the game state.
const pendingContributions = new Map<string, IpcContribution>();
let finishedWhileDisconnected = false;
// Commands are sent one at a time so they execute in order
let rconQueue: Promise<unknown> = Promise.resolve();
@@ -69,11 +73,68 @@ export default async function(context: InstancePluginContext) {
}
async function researchContribution(tech: IpcContribution) {
instance.sendTo("controller", new ContributionEvent(tech.name, tech.level, tech.contribution));
if (host.connector.connected) {
instance.sendTo("controller", new ContributionEvent(tech.name, tech.level, tech.contribution));
return;
}
let pending = pendingContributions.get(tech.name);
if (pending && pending.level === tech.level) {
pending.contribution += tech.contribution;
} else if (!pending || pending.level < tech.level) {
// A lower level finished here, the technology sync reports it
pendingContributions.set(tech.name, { ...tech });
}
}
async function researchFinished(tech: IpcFinished) {
instance.sendTo("controller", new FinishedEvent(tech.name, tech.level));
if (host.connector.connected) {
instance.sendTo("controller", new FinishedEvent(tech.name, tech.level));
return;
}
finishedWhileDisconnected = true;
}
function sendPendingContributions() {
for (let tech of pendingContributions.values()) {
instance.sendTo("controller", new ContributionEvent(tech.name, tech.level, tech.contribution));
}
pendingContributions.clear();
}
async function syncTechnologies() {
let dumpJson = await sendOrderedRcon("/sc research_sync.dump_technologies()");
let techsToSend = [];
let instanceTechs = new Map();
for (let tech of JSON.parse(dumpJson)) {
techsToSend.push(new TechnologySync(
tech.name,
tech.level,
tech.progress || null,
tech.researched,
));
instanceTechs.set(tech.name, tech);
}
let controllerTechs = await instance.sendTo("controller", new SyncTechnologiesRequest(techsToSend));
syncStarted = true;
let techsToSync = [];
for (let controllerTech of controllerTechs) {
let { name, level, progress, researched } = controllerTech;
let instanceTech = instanceTechs.get(name);
if (
!instanceTech
|| instanceTech.level !== level
|| (instanceTech.progress || null) !== progress
|| instanceTech.researched !== researched
) {
techsToSync.push(controllerTech);
}
}
if (techsToSync.length) {
let syncJson = lib.escapeString(JSON.stringify(techsToSync));
await sendOrderedRcon(`/sc research_sync.sync_technologies("${syncJson}")`, true);
}
}
instance.server.on("ipc-research_sync:contribution", (tech: IpcContribution) => {
@@ -128,52 +189,35 @@ export default async function(context: InstancePluginContext) {
});
instance.hooks.start.attach(plugin.name, async () => {
let dumpJson = await sendOrderedRcon("/sc research_sync.dump_technologies()");
let techsToSend = [];
let instanceTechs = new Map();
for (let tech of JSON.parse(dumpJson)) {
techsToSend.push(new TechnologySync(
tech.name,
tech.level,
tech.progress || null,
tech.researched,
));
instanceTechs.set(tech.name, tech);
}
let controllerTechs = await instance.sendTo("controller", new SyncTechnologiesRequest(techsToSend));
syncStarted = true;
let techsToSync = [];
for (let controllerTech of controllerTechs) {
let { name, level, progress, researched } = controllerTech;
let instanceTech = instanceTechs.get(name);
if (
!instanceTech
|| instanceTech.level !== level
|| (instanceTech.progress || null) !== progress
|| instanceTech.researched !== researched
) {
techsToSync.push(controllerTech);
}
}
if (techsToSync.length) {
let syncJson = lib.escapeString(JSON.stringify(techsToSync));
await sendOrderedRcon(`/sc research_sync.sync_technologies("${syncJson}")`, true);
}
await syncTechnologies();
// A first start counts as turning following on, which seeds an empty cluster queue
await syncQueue(true);
});
// QueueEvents sent while the controller session was down are lost, so take the cluster queue again.
// An edit made on this instance during the outage was never sent and is replaced: the cluster queue wins.
// Events are lost both ways while the controller session is down. Contributions are kept above, finished
// research comes from the technology sync, and the cluster queue replaces queue edits made meanwhile.
instance.hooks.controllerConnectionEvent.attach(plugin.name, async (event) => {
if (event !== "connect" || !syncStarted || !["starting", "running"].includes(instance.status)) {
if (event !== "connect" && event !== "resume") {
return;
}
try {
await syncQueue(false);
sendPendingContributions();
if (!syncStarted || !["starting", "running"].includes(instance.status)) {
return;
}
if (event === "connect" || finishedWhileDisconnected) {
finishedWhileDisconnected = false;
try {
await syncTechnologies();
} catch (err) {
finishedWhileDisconnected = true;
throw err;
}
}
if (event === "connect") {
await syncQueue(false);
}
} catch (err: any) {
unexpectedError(err);
}
+2 -1
View File
@@ -332,7 +332,8 @@ function research_sync.sync_technologies(data)
end
tech.researched = true
progress = nil
elseif tech_data[progressIndex] then
-- may have finished here since the dump this sync is answering
elseif tech_data[progressIndex] and not tech.researched then
send_contribution(tech)
progress = tech_data[progressIndex]
set_technology_progress(tech, progress)
+1 -1
View File
@@ -30,7 +30,7 @@
"test": "echo \"Error: run tests from root\" && exit 1"
},
"engines": {
"node": ">=22"
"node": "^22.18.0 || >=24.2.0"
},
"peerDependencies": {
+49
View File
@@ -164,6 +164,55 @@ describe("research_sync controller", function() {
});
});
// What instances send on reconnecting: the contributions summed during the outage, then a technology sync
describe("reconnect", function() {
const contribute = (name, level, contribution) => mock.getHandler(controller, ContributionEvent)(
new ContributionEvent(name, level, contribution), src
);
const syncTechs = async (...techs) => new Map(
(await mock.getHandler(controller, SyncTechnologiesRequest)(new SyncTechnologiesRequest(techs), src))
.map(tech => [tech.name, tech])
);
const finishedEvents = () => sent.filter(([, m]) => m instanceof FinishedEvent).map(([, m]) => m);
it("should count progress made on every instance during the outage", async function() {
await contribute("automation", 1, 0.25);
// A and B each contributed 0.25 from the controller's 0.25
await contribute("automation", 1, 0.25);
await syncTechs(new TechnologySync("automation", 1, 0.5, false));
await contribute("automation", 1, 0.25);
let techs = await syncTechs(new TechnologySync("automation", 1, 0.5, false));
assert.equal(techs.get("automation").progress, 0.75);
});
it("should broadcast research finished during the outage once", async function() {
await contribute("automation", 1, 0.5);
await syncTechs(new TechnologySync("automation", 1, null, true));
await syncTechs(new TechnologySync("automation", 1, null, true));
assert.deepEqual(finishedEvents(), [new FinishedEvent("automation", 1)]);
});
const tech = (level, progress) => new TechnologySync("mining-productivity-3", level, progress, false);
it("should take a higher infinite research level from an instance", async function() {
await contribute("mining-productivity-3", 4, 0.9);
let techs = await syncTechs(tech(5, 0.2));
assert.deepEqual(finishedEvents(), [new FinishedEvent("mining-productivity-3", 4)]);
assert.deepEqual(techs.get("mining-productivity-3"), tech(5, 0.2));
});
it("should return a higher infinite research level to an instance", async function() {
await syncTechs(tech(6, 0.1));
sent.length = 0;
let techs = await syncTechs(tech(4, 0.5));
assert.equal(finishedEvents().length, 0);
assert.deepEqual(techs.get("mining-productivity-3"), tech(6, 0.1));
});
it("should ignore contributions to a level finished elsewhere", async function() {
await syncTechs(tech(5, null));
await contribute("mining-productivity-3", 4, 0.5);
let techs = await syncTechs();
assert.deepEqual(techs.get("mining-productivity-3"), tech(5, null));
});
});
it("should persist the queue across loads", async function() {
await update(["a", "b"]);
await controller.hooks.save.invoke();
+111 -3
View File
@@ -5,11 +5,12 @@ import * as mock from "../../../test/mock.js";
import loadInstancePlugin from "../dist/node/instance.js";
import { plugin as info } from "../dist/node/index.js";
import {
QueueEvent, QueueSyncRequest, QueueUpdateEvent, SyncTechnologiesRequest,
ContributionEvent, FinishedEvent, QueueEvent, QueueSyncRequest, QueueUpdateEvent, SyncTechnologiesRequest,
TechnologySync,
} from "../dist/node/messages.js";
describe("research_sync instance", function() {
let instance, sent;
let instance, host, sent;
const clusterQueue = ["a", "b"];
before(function() {
@@ -20,7 +21,7 @@ describe("research_sync instance", function() {
}
});
beforeEach(async function() {
({ instance } = await mock.loadInstancePlugin(loadInstancePlugin, info));
({ instance, host } = await mock.loadInstancePlugin(loadInstancePlugin, info));
sent = [];
instance.sendTo = async (dst, message) => {
sent.push(message);
@@ -95,6 +96,7 @@ describe("research_sync instance", function() {
await instance.hooks.start.invoke();
sent.length = 0;
instance.server.reset();
instance.server.rconCommandResults.set("/sc research_sync.dump_technologies()", "[]");
queueState({ follow: true, first: false });
});
@@ -108,6 +110,31 @@ describe("research_sync instance", function() {
await instance.hooks.controllerConnectionEvent.invoke("resume");
assert.equal(sent.length, 0);
});
it("should sync technologies before the queue on a new session", async function() {
await instance.hooks.controllerConnectionEvent.invoke("connect");
assert.ok(sent[0] instanceof SyncTechnologiesRequest);
assert.ok(sent[1] instanceof QueueSyncRequest);
});
it("should apply research finished elsewhere during the outage", async function() {
instance.server.rconCommandResults.set(
"/sc research_sync.dump_technologies()",
JSON.stringify([{ name: "automation", level: 1, progress: 0.5, researched: false }]),
);
let controllerTechs = [new TechnologySync("automation", 1, null, true)];
let sendTo = instance.sendTo;
instance.sendTo = async (dst, message) => {
if (message instanceof SyncTechnologiesRequest) {
sent.push(message);
return controllerTechs;
}
return sendTo(dst, message);
};
await instance.hooks.controllerConnectionEvent.invoke("connect");
let syncRequest = sent.find(m => m instanceof SyncTechnologiesRequest);
assert.deepEqual(syncRequest.technologies, [new TechnologySync("automation", 1, 0.5, false)]);
let syncJson = lib.escapeString(JSON.stringify(controllerTechs));
assert.ok(instance.server.rconCommands.includes(`/sc research_sync.sync_technologies("${syncJson}")`));
});
it("should do nothing while stopped", async function() {
instance.status = "stopped";
await instance.hooks.controllerConnectionEvent.invoke("connect");
@@ -122,6 +149,9 @@ describe("research_sync instance", function() {
// reply resolves the request's await
instance.sendTo = async (dst, message) => {
sent.push(message);
if (message instanceof SyncTechnologiesRequest) {
return [];
}
mock.getHandler(instance, QueueEvent)(new QueueEvent(["new"], null, "B"));
return ["old"];
};
@@ -130,6 +160,84 @@ describe("research_sync instance", function() {
});
});
describe("controller outage", function() {
beforeEach(async function() {
queueState({ follow: false });
await instance.hooks.start.invoke();
sent.length = 0;
host.connector.connected = false;
});
const contributions = () => sent.filter(m => m instanceof ContributionEvent);
const techSyncs = () => sent.filter(m => m instanceof SyncTechnologiesRequest);
function contribute(name, level, contribution) {
instance.server.emit("ipc-research_sync:contribution", { name, level, contribution });
}
it("should send contributions while connected", async function() {
host.connector.connected = true;
contribute("automation", 1, 0.25);
instance.server.emit("ipc-research_sync:finished", { name: "automation", level: 1 });
assert.ok(sent[0] instanceof ContributionEvent);
assert.ok(sent[1] instanceof FinishedEvent);
});
it("should sum contributions and send them on a new session", async function() {
contribute("automation", 1, 0.25);
contribute("automation", 1, 0.25);
contribute("logistics", 1, 0.125);
assert.equal(sent.length, 0);
host.connector.connected = true;
await instance.hooks.controllerConnectionEvent.invoke("connect");
assert.deepEqual(
contributions().map(m => [m.name, m.level, m.contribution]),
[["automation", 1, 0.5], ["logistics", 1, 0.125]],
);
assert.ok(sent.indexOf(contributions()[1]) < sent.indexOf(techSyncs()[0]));
});
it("should keep only contributions to the highest level", async function() {
contribute("mining-productivity-3", 3, 0.5);
contribute("mining-productivity-3", 4, 0.25);
contribute("mining-productivity-3", 3, 0.5);
host.connector.connected = true;
await instance.hooks.controllerConnectionEvent.invoke("resume");
assert.deepEqual(
contributions().map(m => [m.name, m.level, m.contribution]),
[["mining-productivity-3", 4, 0.25]],
);
});
it("should send contributions once", async function() {
contribute("automation", 1, 0.25);
host.connector.connected = true;
await instance.hooks.controllerConnectionEvent.invoke("resume");
await instance.hooks.controllerConnectionEvent.invoke("resume");
assert.equal(contributions().length, 1);
});
it("should not sync technologies on resume when nothing finished", async function() {
contribute("automation", 1, 0.25);
host.connector.connected = true;
await instance.hooks.controllerConnectionEvent.invoke("resume");
assert.equal(techSyncs().length, 0);
});
it("should sync technologies on resume when research finished", async function() {
instance.server.emit("ipc-research_sync:finished", { name: "automation", level: 1 });
assert.equal(sent.length, 0);
host.connector.connected = true;
await instance.hooks.controllerConnectionEvent.invoke("resume");
assert.equal(techSyncs().length, 1);
await instance.hooks.controllerConnectionEvent.invoke("resume");
assert.equal(techSyncs().length, 1);
});
it("should retry the technology sync after it fails on resume", async function() {
instance.server.emit("ipc-research_sync:finished", { name: "automation", level: 1 });
host.connector.connected = true;
let sendTo = instance.sendTo;
instance.sendTo = async () => { throw new Error("gone"); };
await instance.hooks.controllerConnectionEvent.invoke("resume");
instance.sendTo = sendTo;
await instance.hooks.controllerConnectionEvent.invoke("resume");
assert.equal(techSyncs().length, 1);
});
});
it("should forward queue changes from the game", async function() {
instance.server.emit("ipc-research_sync:queue", { queue: {}, player: "Bob" });
assert.equal(sent.length, 1);
+1 -1
View File
@@ -30,7 +30,7 @@
"test": "echo \"Error: run tests from root\" && exit 1"
},
"engines": {
"node": ">=22"
"node": "^22.18.0 || >=24.2.0"
},
"peerDependencies": {
+1 -1
View File
@@ -30,7 +30,7 @@
"test": "echo \"Error: run tests from root\" && exit 1"
},
"engines": {
"node": ">=22"
"node": "^22.18.0 || >=24.2.0"
},
"peerDependencies": {
+26 -7
View File
@@ -12,7 +12,10 @@
* added and removed messages are only warned about since breaking
* compatibility is unavoidable there. Optional properties that only one
* version has are left out of the comparison, as the other version ignores
* them when received and does not need them when sent.
* them when received and does not need them when sent. Control clients are
* left out too, as the web UI is served by the controller it talks to, so
* control is removed from src and dst and messages that only go between
* control and the controller are skipped.
*
* Intended breaks are listed in scripts/version-compat-breaks.json, see
* scripts/version-compat-breaks.js for the format.
@@ -196,6 +199,15 @@ const dumpMessageSpecs = `
console.log(JSON.stringify(specs));
`;
function withoutControl(spec) {
const strip = types => types.filter(type => type !== "control");
return { ...spec, src: strip(spec.src), dst: strip(spec.dst) };
}
function isControlOnly(spec) {
return [...spec.src, ...spec.dst].every(type => type === "control" || type === "controller");
}
async function messageSpecs(libPath) {
const result = await execFile(process.execPath, ["-e", dumpMessageSpecs, libPath]);
return new Map(Object.entries(JSON.parse(result.stdout)));
@@ -215,12 +227,19 @@ async function checkMessageCompat(publishedVersion, knownBreaks) {
console.log(`| comparing ${localSpecs.size} local with ${publishedSpecs.size} published link messages`);
const changed = [];
for (const [name, localSpec] of localSpecs) {
const publishedSpec = publishedSpecs.get(name);
if (!publishedSpec) {
annotate(`Link ${name} does not exist in ${publishedVersion} which cannot receive it`);
for (const [name, fullLocalSpec] of localSpecs) {
const fullPublishedSpec = publishedSpecs.get(name);
if (!fullPublishedSpec) {
if (!isControlOnly(fullLocalSpec)) {
annotate(`Link ${name} does not exist in ${publishedVersion} which cannot receive it`);
}
continue;
}
if (isControlOnly(fullLocalSpec) && isControlOnly(fullPublishedSpec)) {
continue;
}
const localSpec = withoutControl(fullLocalSpec);
const publishedSpec = withoutControl(fullPublishedSpec);
const differs = Object.keys(localSpec).filter(
field => stableStringify(localSpec[field]) !== stableStringify(publishedSpec[field])
);
@@ -238,8 +257,8 @@ async function checkMessageCompat(publishedVersion, knownBreaks) {
changed.push({ name, fields, localSpec, publishedSpec });
}
}
for (const name of publishedSpecs.keys()) {
if (!localSpecs.has(name)) {
for (const [name, publishedSpec] of publishedSpecs) {
if (!localSpecs.has(name) && !isControlOnly(publishedSpec)) {
annotate(`Link ${name} was removed after ${publishedVersion} which may still send it`);
}
}
+10 -16
View File
@@ -1,6 +1,5 @@
import assert from "node:assert/strict";
import events from "node:events";
import FormData from "form-data";
import http from "node:http";
import { wait, expositionContentType } from "@clusterio/lib";
@@ -308,27 +307,25 @@ describe("controller/src/routes", function() {
let form;
form = new FormData();
form.append("instance_id", "123");
form.append("file", "totally a zip file", { filename: "test.zip", contentType: "text/plain" });
form.append("file", new Blob(["totally a zip file"], { type: "text/plain" }), "test.zip");
response = await fetch(endpoint, {
method: "POST",
headers: {
"X-Access-Token": testToken,
...form.getHeaders(),
},
body: form.getBuffer(),
body: form,
});
assert.equal(response.status, 400);
assert.deepEqual(await response.json(), { request_errors: ["invalid file Content-Type"] });
form = new FormData();
form.append("file", "totally a zip file", { filename: "test.zip", contentType: "application/zip" });
form.append("file", new Blob(["totally a zip file"], { type: "application/zip" }), "test.zip");
form.append("instance_id", "123");
response = await fetch(endpoint, {
method: "POST",
headers: {
"X-Access-Token": testToken,
...form.getHeaders(),
},
body: form.getBuffer(),
body: form,
});
assert.equal(response.status, 400);
assert.deepEqual(
@@ -336,14 +333,13 @@ describe("controller/src/routes", function() {
);
form = new FormData();
form.append("instance_id", "123");
form.append("file", "totally a zip file", { filename: "test.txt", contentType: "application/zip" });
form.append("file", new Blob(["totally a zip file"], { type: "application/zip" }), "test.txt");
response = await fetch(endpoint, {
method: "POST",
headers: {
"X-Access-Token": testToken,
...form.getHeaders(),
},
body: form.getBuffer(),
body: form,
});
assert.equal(response.status, 400);
assert.deepEqual(
@@ -351,14 +347,13 @@ describe("controller/src/routes", function() {
);
form = new FormData();
form.append("instance_id", "invalid");
form.append("file", "totally a zip file", { filename: "test.zip", contentType: "application/zip" });
form.append("file", new Blob(["totally a zip file"], { type: "application/zip" }), "test.zip");
response = await fetch(endpoint, {
method: "POST",
headers: {
"X-Access-Token": testToken,
...form.getHeaders(),
},
body: form.getBuffer(),
body: form,
});
assert.equal(response.status, 400);
assert.deepEqual(
@@ -410,14 +405,13 @@ describe("controller/src/routes", function() {
let form;
form = new FormData();
form.append("instance_id", "123");
form.append("file", "totally a zip file", { filename: "test.zip", contentType: "application/zip" });
form.append("file", new Blob(["totally a zip file"], { type: "application/zip" }), "test.zip");
response = await fetch(endpoint, {
method: "POST",
headers: {
"X-Access-Token": testToken,
...form.getHeaders(),
},
body: form.getBuffer(),
body: form,
});
assert.equal(response.status, 500);
assert.deepEqual(await response.json(), { errors: ["Something went wrong"], request_errors: [] });
+1 -2
View File
@@ -302,8 +302,7 @@ describe("host/src/Host", function() {
await host.loadPlugins();
const plugins = await host.handlePluginListRequest(new lib.PluginListRequest());
const plugin = plugins.find(candidate => candidate.name === "controller_only");
assert.equal(plugin.loaded, false);
assert.equal(plugin.noEntrypoint, true);
assert.equal(plugin.state, "noEntrypoint");
const [entry] = computePluginStatuses([
{ name: "host", isController: false, address: { hostId: 1 }, plugins: [plugin] },
]).get("controller_only").machines;
+40
View File
@@ -268,6 +268,46 @@ describe("class Instance", function() {
});
});
describe(".handleInstanceStopRequest()", function() {
let calls;
beforeEach(function() {
calls = [];
instance.server._state = "running";
instance.server.stop = async () => { calls.push("stop"); instance.server._state = "stopping"; };
instance.server.kill = async () => { calls.push("kill"); };
instance.sendSaveListUpdate = async () => {};
});
it("should stop once a start in progress finishes", async function() {
instance.notifyStatus("starting");
let stopped = instance.handleInstanceStopRequest();
await wait(10);
assert.deepEqual(calls, [], "stopped while starting");
instance.notifyStatus("running");
await stopped;
assert.deepEqual(calls, ["stop"]);
});
it("should do nothing if a start in progress fails", async function() {
instance.notifyStatus("starting");
let stopped = instance.handleInstanceStopRequest();
await wait(10);
instance.notifyStatus("stopped");
await stopped;
assert.deepEqual(calls, []);
});
it("should not kill the server when stopped while stopping", async function() {
instance.notifyStatus("running");
let first = instance.handleInstanceStopRequest();
let second = instance.handleInstanceStopRequest();
await first;
instance.notifyStatus("stopped");
await second;
assert.deepEqual(calls, ["stop"]);
});
});
describe(".checkModPackVersion()", function() {
function modPack(factorioVersion) {
return lib.ModPack.fromJSON({ name: "pack", factorio_version: factorioVersion });
+2 -4
View File
@@ -443,14 +443,12 @@ describe("Integration of Clusterio", function() {
it("should load the plugin on the controller", async function() {
const details = await pluginDetails("controller");
assert(details.enabled, "test_plugin is not enabled on the controller");
assert(details.loaded, "test_plugin is not loaded on the controller");
assert.equal(details.state, "loaded", "test_plugin is not loaded on the controller");
});
it("should load the plugin on the host", async function() {
const details = await pluginDetails({ hostId: 4 });
assert(details.enabled, "test_plugin is not enabled on the host");
assert(details.loaded, "test_plugin is not loaded on the host");
assert.equal(details.state, "loaded", "test_plugin is not loaded on the host");
});
it("should report the plugin's title and version", async function() {
+3 -3
View File
@@ -205,17 +205,17 @@ export function exec(command, options = {}) {
}
export function execController(...args) {
args[0] = `node --enable-source-maps ../../packages/controller ${args[0]}`;
args[0] = `node --enable-source-maps ../../packages/controller/dist/node/controller.js ${args[0]}`;
return exec(...args);
}
export function execHost(...args) {
args[0] = `node --enable-source-maps ../../packages/host ${args[0]}`;
args[0] = `node --enable-source-maps ../../packages/host/dist/node/host.js ${args[0]}`;
return exec(...args);
}
export function execCtlProcess(...args) {
args[0] = `node --enable-source-maps ../../packages/ctl ${args[0]}`;
args[0] = `node --enable-source-maps ../../packages/ctl/dist/node/ctl.js ${args[0]}`;
return exec(...args);
}
+22
View File
@@ -474,4 +474,26 @@ describe("start-all and stop-all commands", function() {
assert.equal(await getInstanceStatus(testInstId3), "stopped");
});
});
describe("stop while starting", function() {
it("stops the instance once it has started", async function() {
this.timeout(30000);
const control = getControl();
const started = control.sendTo({ instanceId: testInstId1 }, new lib.InstanceStartRequest());
// Unroutable until the host has connected the instance
for (;;) {
try {
await control.sendTo({ instanceId: testInstId1 }, new lib.InstanceStopRequest());
break;
} catch (err) {
if (!(err instanceof lib.UnavailableError)) {
throw err;
}
await lib.wait(10);
}
}
await started;
assert.equal(await getInstanceStatus(testInstId1), "stopped");
});
});
});
+3 -1
View File
@@ -147,7 +147,9 @@ describe("Integration of lib/link", function() {
it("should close old connections from the same address", async function() {
slowTest(this);
const hostConnectorFirst = new TestHostConnector(url, 0.2);
// Without a link the disconnect prepare goes unanswered and the controller waits 10 s for it
const hostLinkFirst = new lib.Link(new TestHostConnector(url, 0.2));
const hostConnectorFirst = hostLinkFirst.connector;
hostConnectorFirst.token = hostConnector.token;
hostConnectorFirst.hostId = hostConnector.hostId;
const onceClose = events.once(hostConnectorFirst, "close");
+1 -1
View File
@@ -41,7 +41,7 @@ describe("lib/data/SystemInfo", function() {
testRoundTripJsonSerialisable(SystemInfo, [
base,
[...base, []],
[...base, [new PluginDetails("name", "Title", "1.0.0", true, true, undefined, "name", "1.1.0")]],
[...base, [new PluginDetails("name", "Title", "1.0.0", "loaded", undefined, "name", "1.1.0")]],
]);
});
});
+14 -12
View File
@@ -414,12 +414,12 @@ describe("lib/plugin_loader", function() {
const loaded = byPackage.get("plugin-npm");
assert.equal(loaded.version, "0.0.0");
assert.equal(loaded.installedVersion, "0.0.1");
assert.equal(loaded.pendingInstall, undefined);
assert.equal(loaded.state, "loaded");
// Dependencies of plugin packages are found the same way as on startup
for (const packageName of ["plugin-aliased", "plugin-deep", "plugin-transitive"]) {
const pending = byPackage.get(packageName);
assert.equal(pending.pendingInstall, true, packageName);
assert.equal(pending.state, "pendingInstall", packageName);
assert.equal(pending.installedVersion, "0.0.1", packageName);
assert.equal(pending.loaded, false, packageName);
}
@@ -513,13 +513,12 @@ describe("lib/plugin_loader", function() {
};
let plugins = await lib.listPluginDetails([local], () => true, () => true);
const taken = plugins.find(plugin => plugin.name === "plugin-npm");
assert.equal(taken.nameTaken, true);
assert.equal(taken.pendingInstall, undefined);
assert.equal(taken.state, "nameTaken");
// Loaded on the next restart once the plugin with that name is gone
const gone = { ...local, packagePath: path.resolve("plugins", "gone", "package.json") };
plugins = await lib.listPluginDetails([gone], () => true, () => true);
assert.equal(plugins.find(plugin => plugin.name === "plugin-npm").pendingInstall, true);
assert.equal(plugins.find(plugin => plugin.name === "plugin-npm").state, "pendingInstall");
});
it("should list a dependency made direct since startup as pending", async function() {
@@ -531,7 +530,7 @@ describe("lib/plugin_loader", function() {
await fs.writeFile("package.json", JSON.stringify(root));
const plugins = await lib.listPluginDetails([], () => true, () => true);
const hidden = plugins.find(plugin => plugin.name === "plugin-hidden");
assert.equal(hidden.pendingInstall, true);
assert.equal(hidden.state, "pendingInstall");
assert.equal(hidden.npmPackage, "plugin-hidden");
} finally {
await fs.writeFile("package.json", rootPackageJson);
@@ -573,7 +572,7 @@ describe("lib/plugin_loader", function() {
assert.equal(npm.npmPackage, undefined);
assert.equal(npm.dependencyName, "plugin-npm");
const deep = plugins.find(plugin => plugin.name === "plugin-deep");
assert.equal(deep.pendingInstall, true);
assert.equal(deep.state, "pendingInstall");
assert.equal(deep.npmPackage, undefined);
} finally {
await fs.writeFile("package.json", rootPackageJson);
@@ -637,7 +636,7 @@ describe("lib/plugin_loader", function() {
npmPackage: "plugin-npm",
}], () => true, () => true);
assert.equal(plugin.npmPackage, undefined);
assert.equal(plugin.removed, undefined);
assert.equal(plugin.state, "loaded");
} finally {
await fs.writeFile(packageJsonPath, original);
}
@@ -650,7 +649,7 @@ describe("lib/plugin_loader", function() {
await fs.writeFile(packageJsonPath, JSON.stringify({ ...JSON.parse(original), private: true }));
const plugins = await lib.listPluginDetails([], () => true, () => true);
const deep = plugins.find(plugin => plugin.name === "plugin-deep");
assert.equal(deep.pendingInstall, true);
assert.equal(deep.state, "pendingInstall");
assert.equal(deep.npmPackage, undefined);
} finally {
await fs.writeFile(packageJsonPath, original);
@@ -705,7 +704,7 @@ describe("lib/plugin_loader", function() {
assert.equal(alias.npmPackage, undefined);
const published = new lib.PluginDetails(
"published", "Published", "1.0.0", true, true, undefined, "plugin-aliased", "1.0.0",
"published", "Published", "1.0.0", "loaded", undefined, "plugin-aliased", "1.0.0",
);
const statuses = computePluginStatuses([
{ name: "A", isController: false, address: { hostId: 1 }, plugins: [published] },
@@ -740,9 +739,12 @@ describe("lib/plugin_loader", function() {
}];
const plugins = await lib.listPluginDetails(pluginInfos, () => true, () => true);
const byPackage = new Map(plugins.map(plugin => [plugin.npmPackage ?? plugin.name, plugin]));
assert.equal(byPackage.get("plugin-gone").removed, true);
assert.equal(byPackage.get("plugin-gone").state, "removedLoaded");
assert.equal(byPackage.get("plugin-gone").installedVersion, undefined);
assert.equal(byPackage.get("plugin-npm").removed, undefined);
assert.equal(byPackage.get("plugin-npm").state, "loaded");
const [disabled] = await lib.listPluginDetails([pluginInfos[0]], () => false, () => false);
assert.equal(disabled.state, "removed");
});
it("should load existing plugin list", async function() {
+14
View File
@@ -52,6 +52,20 @@ describe("messages/controller", function() {
});
});
describe("RoleDeleteRequest", function() {
it("removes the role from users and sends a user update", async function() {
const id = await controlConnection.handleRoleCreateRequest(new lib.RoleCreateRequest("r", "", []));
const user = controller.users.getOrCreateUser("player");
user.addRole(id);
const updated = [];
controller.users.records.on("update", users => updated.push(...users));
await controlConnection.handleRoleDeleteRequest(new lib.RoleDeleteRequest(id));
assert.equal(controller.roles.has(id), false);
assert.equal(user.roleIds.has(id), false);
assert(updated.some(u => u.name === "player" && !u.roleIds.has(id)), "no user update sent");
});
});
describe("ControllerConfigGetRequest", function() {
it("runs", async function() {
const config = await controlConnection.handleControllerConfigGetRequest(
+37 -11
View File
@@ -38,20 +38,46 @@ describe("messages/plugin", function() {
describe("PluginDetails", function() {
it("has round trip json serialisation", function() {
testRoundTripJsonSerialisable(lib.PluginDetails, [
["Name", "Title", "Version", true, true],
[
"Name", "Title", "Version", false, false, "Description", "Npm Package",
"Installed Version", true, true, "Dependency Name", true, true,
],
["Name", "Title", "Version", "loaded"],
["Name", "Title", "Version", "removedLoaded", "Description", "Npm Package", "Installed Version", "Dep"],
]);
});
it("sends loaded and enabled for 2.0.3 and older", function() {
const cases = [
["loaded", true, true],
["disabled", false, false],
["unloadOnRestart", true, false],
["loadOnRestart", false, true],
["noEntrypoint", false, false],
["pendingInstall", false, true],
["removed", false, false],
["removedLoaded", true, false],
["nameTaken", false, false],
];
for (const [state, loaded, enabled] of cases) {
const json = JSON.parse(JSON.stringify(new lib.PluginDetails("Name", "Title", "Version", state)));
assert.deepEqual([json.loaded, json.enabled], [loaded, enabled], state);
}
});
it("reads the state of plugins listed by 2.0.3 and older", function() {
const cases = [
[true, true, "loaded"],
[true, false, "unloadOnRestart"],
[false, true, "loadOnRestart"],
[false, false, "disabled"],
];
for (const [loaded, enabled, state] of cases) {
const json = { name: "Name", title: "Title", version: "1.0.0", loaded, enabled };
assert.equal(lib.PluginDetails.fromJSON(json).state, state);
}
});
it("can be created from NodeEnvInfo interface", function() {
const pluginDetails = lib.PluginDetails.fromNodeEnvInfo({
name: "Name", title: "Title", version: "Version",
description: "Description", npmPackage: "Npm Package",
}, true, true);
}, "loaded");
assert.deepEqual(pluginDetails, new lib.PluginDetails(
"Name", "Title", "Version", true, true,
"Name", "Title", "Version", "loaded",
"Description", "Npm Package",
));
});
@@ -60,8 +86,8 @@ describe("messages/plugin", function() {
"package", { version: "1.0.0", description: "Description" }, "package", "package",
);
assert.deepEqual(pluginDetails, new lib.PluginDetails(
"package", "package", "1.0.0", false, false,
"Description", "package", "1.0.0", true, undefined, "package",
"package", "package", "1.0.0", "pendingInstall",
"Description", "package", "1.0.0", "package",
));
const invalid = lib.PluginDetails.fromPackageJson("package", { version: "1.0.0", description: 1 });
assert.equal(invalid.description, undefined);
@@ -99,7 +125,7 @@ describe("messages/plugin", function() {
const response = await controlConnection.handlePluginListRequest(new lib.PluginListRequest());
assert.deepEqual(response, [new lib.PluginDetails(
"Name", "Title", "Version", true, true,
"Name", "Title", "Version", "loaded",
"Description", "Npm Package", "Version",
)]);
});
@@ -114,7 +140,7 @@ describe("messages/plugin", function() {
const response = await host.handlePluginListRequest(new lib.PluginListRequest());
assert.deepEqual(response, [new lib.PluginDetails(
"Name", "Title", "Version", true, true,
"Name", "Title", "Version", "loaded",
"Description", "Npm Package", "Version",
)]);
});
+11 -13
View File
@@ -13,11 +13,11 @@ describe("web_ui/util/plugin_status", function() {
return { name, isController: false, address: { hostId: name.length }, plugins };
}
function loaded(npmPackage, version) {
return new lib.PluginDetails("plugin", "Plugin", version, true, true, undefined, npmPackage, version);
return new lib.PluginDetails("plugin", "Plugin", version, "loaded", undefined, npmPackage, version);
}
function pending(packageName, npmPackage, version) {
return new lib.PluginDetails(
packageName, packageName, version, false, false, undefined, npmPackage, version, true,
packageName, packageName, version, "pendingInstall", undefined, npmPackage, version,
);
}
function skippedText(result) {
@@ -50,7 +50,7 @@ describe("web_ui/util/plugin_status", function() {
it("should not update a loaded package that isn't from npm", function() {
const result = plan([
machine("a", [loaded("plugin-aliased", "1.0.0")]),
machine("b", [new lib.PluginDetails("plugin-aliased", "Plugin", "1.0.0", true, true)]),
machine("b", [new lib.PluginDetails("plugin-aliased", "Plugin", "1.0.0", "loaded")]),
]);
assert.deepEqual(result.operations.map(op => op.target.name), ["a"]);
assert.deepEqual(skippedText(result), [{ kind: "update", text: "b (not installed from npm)" }]);
@@ -58,7 +58,7 @@ describe("web_ui/util/plugin_status", function() {
it("should not send a specific version to hosts that can't parse it", function() {
// Hosts from before installedVersion was reported take name@version as a package name
const old = new lib.PluginDetails("plugin", "Plugin", "1.0.0", true, true, undefined, "plugin-aliased");
const old = new lib.PluginDetails("plugin", "Plugin", "1.0.0", "loaded", undefined, "plugin-aliased");
const result = plan([
machine("a", [loaded("plugin-aliased", "1.0.0")]),
machine("old", [old]),
@@ -70,7 +70,7 @@ describe("web_ui/util/plugin_status", function() {
});
it("should not install over a loaded alias that has the package name", function() {
const alias = new lib.PluginDetails("real_plugin", "Real", "1.0.0", true, true);
const alias = new lib.PluginDetails("real_plugin", "Real", "1.0.0", "loaded");
alias.dependencyName = "plugin-aliased";
const statuses = computePluginStatuses([
machine("a", [loaded("plugin-aliased", "1.0.0")]),
@@ -110,7 +110,7 @@ describe("web_ui/util/plugin_status", function() {
describe("planMachineUpdate()", function() {
it("should plan for the given machine only", function() {
const loaded = new lib.PluginDetails(
"plugin", "Plugin", "1.0.0", true, true, undefined, "plugin-aliased", "1.0.0",
"plugin", "Plugin", "1.0.0", "loaded", undefined, "plugin-aliased", "1.0.0",
);
const statuses = computePluginStatuses([
{ name: "a", isController: false, address: { hostId: 1 }, plugins: [loaded] },
@@ -136,7 +136,7 @@ describe("web_ui/util/plugin_status", function() {
}
function loadedPlugin(version = "1.0.0", installedVersion = version) {
return new lib.PluginDetails(
"plugin", "Plugin", version, true, true, undefined, "plugin", installedVersion,
"plugin", "Plugin", version, "loaded", undefined, "plugin", installedVersion,
);
}
@@ -146,24 +146,22 @@ describe("web_ui/util/plugin_status", function() {
assert.deepEqual(labels([]), ["Not installed"]);
assert.deepEqual(labels([loadedPlugin()], { offline: true }), ["Offline"]);
const disabled = new lib.PluginDetails(
"plugin", "Plugin", "1.0.0", false, false, undefined, "plugin", "1.0.0",
"plugin", "Plugin", "1.0.0", "disabled", undefined, "plugin", "1.0.0",
);
assert.deepEqual(labels([disabled]), ["Disabled"]);
const local = new lib.PluginDetails("plugin", "Plugin", "1.0.0", true, true);
const local = new lib.PluginDetails("plugin", "Plugin", "1.0.0", "loaded");
assert.deepEqual(labels([local]), ["Not installed from npm"]);
});
});
describe("computePluginStatuses()", function() {
it("should not expect a plugin without an entrypoint for the machine to run after restart", function() {
const plugin = new lib.PluginDetails("controller_only", "Plugin", "1.0.0", false, false);
plugin.noEntrypoint = true;
const plugin = new lib.PluginDetails("controller_only", "Plugin", "1.0.0", "noEntrypoint");
const [status] = computePluginStatuses([
{ name: "host", isController: false, address: { hostId: 1 }, plugins: [plugin] },
]).values();
const [entry] = status.machines;
assert.equal(entry.noEntrypoint, true);
assert.equal(entry.disabled, false);
assert.equal(entry.state, "noEntrypoint");
assert.equal(entry.running, undefined);
assert.equal(entry.afterRestart, undefined);
});
+47 -45
View File
@@ -9,62 +9,64 @@ const target = "2.0.0";
const versions = ["1.0.0", "2.0.0", "3.0.0"];
function details(fields) {
const plugin = new lib.PluginDetails(
fields.name, fields.name, fields.version, fields.loaded, fields.enabled, undefined, fields.npmPackage,
fields.installedVersion, fields.pendingInstall, fields.removed, fields.dependencyName, fields.noEntrypoint,
fields.nameTaken,
return new lib.PluginDetails(
fields.name, fields.name, fields.version, fields.state, undefined, fields.npmPackage,
fields.installedVersion, fields.dependencyName,
);
return plugin;
}
function* states() {
yield { label: "absent", plugins: [] };
for (const version of versions) {
for (const installed of [version, ...versions.filter(other => other !== version)]) {
for (const enabled of [true, false]) {
for (const loaded of enabled ? [true] : [true, false]) {
const state = `${enabled ? "enabled" : "disabled"}${loaded ? " loaded" : ""}`;
yield {
label: `npm ${version} on disk ${installed} ${state}`,
entry: { source: "npm", version, installed, enabled, loaded },
plugins: [details({
name: "plugin", version, loaded, enabled, npmPackage: "pkg",
installedVersion: installed, dependencyName: "pkg",
})],
};
}
for (const [state, loaded, enabled] of [
["loaded", true, true],
["unloadOnRestart", true, false],
["loadOnRestart", false, true],
["disabled", false, false],
]) {
yield {
label: `npm ${version} on disk ${installed} ${state}`,
entry: { source: "npm", version, installed, enabled, loaded },
plugins: [details({
name: "plugin", version, state, npmPackage: "pkg",
installedVersion: installed, dependencyName: "pkg",
})],
};
}
}
yield {
label: `npm ${version} removed`,
entry: { source: "npm", version, removed: true },
plugins: [details({
name: "plugin", version, loaded: true, enabled: true, npmPackage: "pkg",
removed: true, dependencyName: "pkg",
})],
};
for (const loaded of [true, false]) {
yield {
label: `npm ${version} removed${loaded ? " while loaded" : ""}`,
entry: { source: "npm", version, removed: true, loaded },
plugins: [details({
name: "plugin", version, state: loaded ? "removedLoaded" : "removed", npmPackage: "pkg",
dependencyName: "pkg",
})],
};
}
yield {
label: `npm ${version} no entrypoint`,
entry: { source: "npm", version, installed: version, noEntrypoint: true },
plugins: [details({
name: "plugin", version, loaded: false, enabled: false, npmPackage: "pkg",
installedVersion: version, dependencyName: "pkg", noEntrypoint: true,
name: "plugin", version, state: "noEntrypoint", npmPackage: "pkg",
installedVersion: version, dependencyName: "pkg",
})],
};
yield {
label: `npm ${version} pending`,
entry: { source: "npm", version, installed: version, pending: true },
plugins: [details({
name: "pkg", version, loaded: false, enabled: false, npmPackage: "pkg",
installedVersion: version, pendingInstall: true, dependencyName: "pkg",
name: "pkg", version, state: "pendingInstall", npmPackage: "pkg",
installedVersion: version, dependencyName: "pkg",
})],
};
yield {
label: `alias ${version} pending`,
entry: { source: "alias", version, installed: version, pending: true },
plugins: [details({
name: "pkg", version, loaded: false, enabled: false,
installedVersion: version, pendingInstall: true, dependencyName: "pkg",
name: "pkg", version, state: "pendingInstall",
installedVersion: version, dependencyName: "pkg",
})],
};
yield {
@@ -72,12 +74,12 @@ function* states() {
entry: { source: "npm", version, installed: target, replaced: true },
plugins: [
details({
name: "plugin", version, loaded: true, enabled: true, npmPackage: "pkg",
removed: true, dependencyName: "pkg",
name: "plugin", version, state: "removedLoaded", npmPackage: "pkg",
dependencyName: "pkg",
}),
details({
name: "pkg", version: target, loaded: false, enabled: false, npmPackage: "pkg",
installedVersion: target, pendingInstall: true, dependencyName: "pkg",
name: "pkg", version: target, state: "pendingInstall", npmPackage: "pkg",
installedVersion: target, dependencyName: "pkg",
}),
],
};
@@ -86,7 +88,7 @@ function* states() {
label: "alias loaded as pkg",
taken: "dependency",
plugins: [details({
name: "real_plugin", version: "1.0.0", loaded: true, enabled: true,
name: "real_plugin", version: "1.0.0", state: "loaded",
installedVersion: "1.0.0", dependencyName: "pkg",
})],
};
@@ -95,10 +97,10 @@ function* states() {
label: `npm ${version} skipped as its name is taken`,
taken: "name",
plugins: [
details({ name: "plugin", version: "1.0.0", loaded: true, enabled: true, installedVersion: "1.0.0" }),
details({ name: "plugin", version: "1.0.0", state: "loaded", installedVersion: "1.0.0" }),
details({
name: "pkg", version, loaded: false, enabled: false, npmPackage: "pkg",
installedVersion: version, dependencyName: "pkg", nameTaken: true,
name: "pkg", version, state: "nameTaken", npmPackage: "pkg",
installedVersion: version, dependencyName: "pkg",
}),
],
entry: { source: "npm", version, installed: version },
@@ -108,17 +110,17 @@ function* states() {
label: "local plugin with the same name",
taken: "name",
plugins: [details({
name: "plugin", version: "1.0.0", loaded: true, enabled: true, installedVersion: "1.0.0",
name: "plugin", version: "1.0.0", state: "loaded", installedVersion: "1.0.0",
})],
};
yield {
label: "local plugin with the same name and pkg pending",
taken: "name",
plugins: [
details({ name: "plugin", version: "1.0.0", loaded: true, enabled: true, installedVersion: "1.0.0" }),
details({ name: "plugin", version: "1.0.0", state: "loaded", installedVersion: "1.0.0" }),
details({
name: "pkg", version: "1.0.0", loaded: false, enabled: false, npmPackage: "pkg",
installedVersion: "1.0.0", pendingInstall: true, dependencyName: "pkg",
name: "pkg", version: "1.0.0", state: "pendingInstall", npmPackage: "pkg",
installedVersion: "1.0.0", dependencyName: "pkg",
}),
],
};
@@ -145,7 +147,7 @@ function expected(state) {
}
let running;
let afterRestart;
if (entry?.loaded || entry?.removed || entry?.replaced) {
if (entry?.loaded || entry?.replaced) {
running = entry.version;
}
if (entry && !entry.removed && !entry.noEntrypoint && (entry.pending || entry.replaced || entry.enabled)) {
@@ -159,7 +161,7 @@ function semverGte(a, b) {
}
const reference = { name: "ref", isController: true, address: "controller", plugins: [details({
name: "plugin", version: "1.0.0", loaded: true, enabled: true, npmPackage: "pkg",
name: "plugin", version: "1.0.0", state: "loaded", npmPackage: "pkg",
installedVersion: "1.0.0", dependencyName: "pkg",
})] };