Files
factorio-clusterio/test/web_ui/plugin_status_matrix.js
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

199 lines
6.5 KiB
JavaScript

import assert from "node:assert/strict";
import * as lib from "@clusterio/lib";
import { computePluginStatuses, planUpdate } from "../../packages/web_ui/src/util/plugin_status.ts";
// Every state a machine can report for the plugin "plugin" published as "pkg", checked against
// what the next startup and npm install would do, independent of the other machines.
const target = "2.0.0";
const versions = ["1.0.0", "2.0.0", "3.0.0"];
function details(fields) {
return new lib.PluginDetails(
fields.name, fields.name, fields.version, fields.state, undefined, fields.npmPackage,
fields.installedVersion, fields.dependencyName,
);
}
function* states() {
yield { label: "absent", plugins: [] };
for (const version of versions) {
for (const installed of [version, ...versions.filter(other => other !== version)]) {
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",
})],
};
}
}
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, 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, 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, state: "pendingInstall",
installedVersion: version, dependencyName: "pkg",
})],
};
yield {
label: `npm ${version} removed and reinstalled at ${target}`,
entry: { source: "npm", version, installed: target, replaced: true },
plugins: [
details({
name: "plugin", version, state: "removedLoaded", npmPackage: "pkg",
dependencyName: "pkg",
}),
details({
name: "pkg", version: target, state: "pendingInstall", npmPackage: "pkg",
installedVersion: target, dependencyName: "pkg",
}),
],
};
}
yield {
label: "alias loaded as pkg",
taken: "dependency",
plugins: [details({
name: "real_plugin", version: "1.0.0", state: "loaded",
installedVersion: "1.0.0", dependencyName: "pkg",
})],
};
for (const version of versions) {
yield {
label: `npm ${version} skipped as its name is taken`,
taken: "name",
plugins: [
details({ name: "plugin", version: "1.0.0", state: "loaded", installedVersion: "1.0.0" }),
details({
name: "pkg", version, state: "nameTaken", npmPackage: "pkg",
installedVersion: version, dependencyName: "pkg",
}),
],
entry: { source: "npm", version, installed: version },
};
}
yield {
label: "local plugin with the same name",
taken: "name",
plugins: [details({
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", state: "loaded", installedVersion: "1.0.0" }),
details({
name: "pkg", version: "1.0.0", state: "pendingInstall", npmPackage: "pkg",
installedVersion: "1.0.0", dependencyName: "pkg",
}),
],
};
}
function expectedOperation(state) {
const { entry, taken } = state;
const present = entry && !entry.removed;
if (present && semverGte(entry.installed, target)) {
return "none";
}
if (taken || present && entry.source !== "npm") {
// Startup would skip it, or npm install pkg would replace the package at that dependency name
return "skip";
}
return !present || entry.pending || entry.replaced ? "install" : "update";
}
function expected(state) {
const { entry, taken } = state;
const op = expectedOperation(state);
if (taken === "name") {
return { running: undefined, afterRestart: undefined, op };
}
let running;
let afterRestart;
if (entry?.loaded || entry?.replaced) {
running = entry.version;
}
if (entry && !entry.removed && !entry.noEntrypoint && (entry.pending || entry.replaced || entry.enabled)) {
afterRestart = entry.installed;
}
return { running, afterRestart, op };
}
function semverGte(a, b) {
return versions.indexOf(a) >= versions.indexOf(b);
}
const reference = { name: "ref", isController: true, address: "controller", plugins: [details({
name: "plugin", version: "1.0.0", state: "loaded", npmPackage: "pkg",
installedVersion: "1.0.0", dependencyName: "pkg",
})] };
function actual(machines, name) {
const status = computePluginStatuses(machines).get("pkg");
const entry = status.machines.find(machine => machine.name === name);
const plan = planUpdate(
status, target, { canInstall: () => true, canUpdate: () => true }, { hasPermission: () => true },
);
let op = "none";
const operation = plan.operations.find(candidate => candidate.target.name === name);
if (operation) {
op = operation.kind;
} else if (plan.skipped.some(skip => skip.target.name === name)) {
op = "skip";
}
return { running: entry.running, afterRestart: entry.afterRestart, op };
}
describe("web_ui/util/plugin_status matrix", function() {
const all = [...states()];
for (const state of all) {
it(`should predict ${state.label}`, function() {
const machine = { name: "m", isController: false, address: { hostId: 1 }, plugins: state.plugins };
const result = actual([reference, machine], "m");
assert.deepEqual(result, expected(state));
// Independent of what other machines report
for (const other of all) {
const otherMachine = { name: "o", isController: false, address: { hostId: 2 }, plugins: other.plugins };
assert.deepEqual(actual([reference, otherMachine, machine], "m"), result, other.label);
}
});
}
});