diff --git a/packages/ctl/src/commands_host.ts b/packages/ctl/src/commands_host.ts index 3d20e050..91bd83c5 100644 --- a/packages/ctl/src/commands_host.ts +++ b/packages/ctl/src/commands_host.ts @@ -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, })))); }, })); diff --git a/packages/lib/src/data/messages_plugin.ts b/packages/lib/src/data/messages_plugin.ts index 0514feee..46fb0cd1 100644 --- a/packages/lib/src/data/messages_plugin.ts +++ b/packages/lib/src/data/messages_plugin.ts @@ -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 `.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; + /* 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) { 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 { + 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 { diff --git a/packages/lib/src/plugin_loader.ts b/packages/lib/src/plugin_loader.ts index 04c4cf70..1aec0eee 100644 --- a/packages/lib/src/plugin_loader.ts +++ b/packages/lib/src/plugin_loader.ts @@ -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); } diff --git a/packages/web_ui/src/components/PluginClusterStatus.tsx b/packages/web_ui/src/components/PluginClusterStatus.tsx index 2faa6716..8c17234f 100644 --- a/packages/web_ui/src/components/PluginClusterStatus.tsx +++ b/packages/web_ui/src/components/PluginClusterStatus.tsx @@ -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 }) ; })} {count(isPending) > 0 && } - {count(machine => machine.disabled) > 0 && machine.disabled)} type="secondary" + {count(machine => machine.state === "disabled") > 0 && machine.state === "disabled")} type="secondary" />} - {count(machine => machine.noEntrypoint) > 0 && machine.noEntrypoint)} type="secondary" + {count(machine => machine.state === "noEntrypoint") > 0 && machine.state === "noEntrypoint")} type="secondary" />} - {count(machine => machine.nameTaken) > 0 && machine.nameTaken)} type="secondary" + {count(machine => machine.state === "nameTaken") > 0 && machine.state === "nameTaken")} type="secondary" />} {missing > 0 && } {unknown.length > 0 && } diff --git a/packages/web_ui/src/util/plugin_status.ts b/packages/web_ui/src/util/plugin_status.ts index bf007669..d2e03cfe 100644 --- a/packages/web_ui/src/util/plugin_status.ts +++ b/packages/web_ui/src/util/plugin_status.ts @@ -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, +): 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): 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"); } } diff --git a/test/host/Host.test.js b/test/host/Host.test.js index 9ec08e08..0dcdc1f9 100644 --- a/test/host/Host.test.js +++ b/test/host/Host.test.js @@ -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; diff --git a/test/integration/clusterio.js b/test/integration/clusterio.js index afb554ef..ef8fec1c 100644 --- a/test/integration/clusterio.js +++ b/test/integration/clusterio.js @@ -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() { diff --git a/test/lib/data/SystemInfo.js b/test/lib/data/SystemInfo.js index ae453afe..510f8915 100644 --- a/test/lib/data/SystemInfo.js +++ b/test/lib/data/SystemInfo.js @@ -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")]], ]); }); }); diff --git a/test/lib/plugin_loader.js b/test/lib/plugin_loader.js index c7491b0b..ba999482 100644 --- a/test/lib/plugin_loader.js +++ b/test/lib/plugin_loader.js @@ -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() { diff --git a/test/messages/plugin.test.js b/test/messages/plugin.test.js index 9ac42427..416058fa 100644 --- a/test/messages/plugin.test.js +++ b/test/messages/plugin.test.js @@ -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", )]); }); diff --git a/test/web_ui/plugin_status.js b/test/web_ui/plugin_status.js index ddd351af..c945eaa2 100644 --- a/test/web_ui/plugin_status.js +++ b/test/web_ui/plugin_status.js @@ -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); }); diff --git a/test/web_ui/plugin_status_matrix.js b/test/web_ui/plugin_status_matrix.js index 778820a2..a8a3c420 100644 --- a/test/web_ui/plugin_status_matrix.js +++ b/test/web_ui/plugin_status_matrix.js @@ -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", })] };