Merge pull request #1010 from bbassie/feature/plugin-browser

Plugin management through web interface
This commit is contained in:
Bastiaan authored and GitHub committed 2026-10-27 21:32:51 +00:00
1 parent e36a76acc5
commit ffe82f81d8
48 files changed
+4275 -536

No files matched your search

@@ -107,6 +107,10 @@ describe("controller/src/ControlConnection", function() {
await install("restart_test");
assert.equal(mockController.config.restartRequired, false);
});
it("leaves the flag alone for a pinned version of an already loaded plugin", async function() {
await install("restart_test@1.0.0");
assert.equal(mockController.config.restartRequired, false);
});
it("rejects when installs are disabled", async function() {
mockController.config = new lib.ControllerConfig(
"controller", { "controller.allow_plugin_install": false }
+44
View File
@@ -9,6 +9,7 @@ import * as lib from "@clusterio/lib";
import Host, { _discoverInstances, HostRouter } from "@clusterio/host/dist/node/src/Host.js";
import Instance from "@clusterio/host/dist/node/src/Instance.js";
import { HostConnector } from "@clusterio/host/dist/node/host.js";
import { computePluginStatuses } from "../../packages/web_ui/src/util/plugin_status.ts";
import "../setup_logging.js";
describe("host/src/Host", function() {
@@ -285,6 +286,31 @@ describe("host/src/Host", function() {
);
});
});
describe(".handlePluginListRequest()", function() {
it("does not expect a plugin without a host or instance entrypoint to load", async function() {
lib.addPluginConfigFields([{ name: "controller_only", controllerEntrypoint: "controller.js" }]);
const packagePath = fileURLToPath(import.meta.resolve("@clusterio/host/package.json"));
const pluginInfos = [{
name: "controller_only", title: "Controller only", version: "1.0.0",
requirePath: path.dirname(packagePath), packagePath, controllerEntrypoint: "controller.js",
}];
// Listed the same before and after a restart, as the host never loads it
for (let restart = 0; restart < 2; restart++) {
const hostConfig = new lib.HostConfig("host");
const host = new Host(new HostConnector(hostConfig, pluginInfos), hostConfig, pluginInfos);
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);
const [entry] = computePluginStatuses([
{ name: "host", isController: false, address: { hostId: 1 }, plugins: [plugin] },
]).get("controller_only").machines;
assert.equal(entry.running, entry.afterRestart, "restart pending");
}
});
});
describe(".handlePluginInstallRequest()", function() {
let host;
let _fetch;
@@ -307,6 +333,10 @@ describe("host/src/Host", function() {
await host.handlePluginInstallRequest(new lib.PluginInstallRequest("restart_test"));
assert.equal(host.config.restartRequired, false);
});
it("leaves the flag alone for a pinned version of an already loaded plugin", async function() {
await host.handlePluginInstallRequest(new lib.PluginInstallRequest("restart_test@1.0.0"));
assert.equal(host.config.restartRequired, false);
});
it("rejects when installs are disabled", async function() {
host.config.set("host.allow_plugin_install", false);
host.config.restartRequired = false; // Setting allow_plugin_install requires a restart
@@ -417,6 +447,20 @@ describe("host/src/Host", function() {
assert.equal(process.exitCode, undefined);
assert.equal(mockHost.shutdownCalled, false);
});
it("rejects while a plugin install or update is running", async function() {
// Held by another live process
await fs.writeFile("npm-operation.lock", `${process.ppid}\n`);
try {
await assert.rejects(
() => mockHost.handleHostRestartRequest(),
/Cannot restart while a plugin install or update is running/
);
} finally {
await fs.rm("npm-operation.lock", { force: true });
}
assert.equal(process.exitCode, undefined);
assert.equal(mockHost.shutdownCalled, false);
});
});
});
});
+14 -1
View File
@@ -15,7 +15,7 @@ import {
spawnNode, instancesDir, factorioDir, databaseDir, controllerConfigPath,
requiresFactorio, hasFactorio,
} from "./index.js";
import { slowTest } from "../common.js";
import { externalTest, slowTest } from "../common.js";
import { getTestRconPort } from "./rcon_port.js";
import { ControllerEcho, HostEchoReceived } from "../file/test_plugin/messages.js";
@@ -540,6 +540,19 @@ describe("Integration of Clusterio", function() {
// Install always fails, we can not test restart option
});
describe("controller plugin search", function() {
it("runs", async function() {
externalTest(this);
await execCtl("controller plugin search global_chat --page-size 5");
});
it("rejects invalid paging", async function() {
await assert.rejects(
execCtl("controller plugin search --page 0"),
/Invalid page or page size/
);
});
});
describe("controller update", function() {
it("runs", async function() {
await execCtl("controller update");
+163 -10
View File
@@ -1,17 +1,25 @@
/* eslint-disable node/no-sync */
import assert from "node:assert/strict";
import fs from "node:fs/promises";
import fsSync from "node:fs";
import path from "node:path";
import { LockFile } from "@clusterio/lib";
const baseDir = path.join("temp", "test", "lock_file");
const ownContent = `${process.pid} ${performance.timeOrigin}\n`;
// The parent process (mocha) is alive but isn't this process
const otherContent = `${process.ppid}\n`;
const exitedContent = "999999999\n";
describe("class LockFile", function () {
/** @type {string} */
let filePath;
/** @type {string} */
let recoveryPath;
/** @type {LockFile} */
let lock;
const writeFile = fs.writeFile;
before(async function () {
await fs.mkdir(baseDir, { recursive: true });
@@ -19,17 +27,30 @@ describe("class LockFile", function () {
beforeEach(function() {
filePath = path.join(baseDir, `${Date.now()}-${Math.random()}.lock`);
recoveryPath = `${filePath}.recovery`;
lock = new LockFile(filePath);
});
afterEach(async function () {
fs.writeFile = writeFile;
if (await lock.isHeld()) {
await lock.release();
}
await fs.unlink(filePath).catch(() => {});
await fs.unlink(recoveryPath).catch(() => {});
});
async function writeOld(content) {
await fs.writeFile(filePath, content);
const old = new Date(Date.now() - 60e3);
await fs.utimes(filePath, old, old);
return old;
}
describe("acquire()", function () {
it("should create the lock file when acquiring", async function () {
await lock.acquire();
await fs.access(filePath);
assert.equal(await fs.readFile(filePath, "utf8"), ownContent);
});
it("should throw LockFileBusyError if called when not idle", async function () {
@@ -38,15 +59,89 @@ describe("class LockFile", function () {
});
it("should remove stale lock file and acquire it", async function () {
await fs.writeFile(filePath, "999999\n"); // stale lock
await fs.writeFile(filePath, exitedContent);
await lock.acquire();
await fs.access(filePath);
assert.equal(await fs.readFile(filePath, "utf8"), ownContent);
await assert.rejects(fs.access(recoveryPath));
});
it("should throw if existing lock file is not stale", async function () {
await fs.writeFile(filePath, `${process.pid}\n`);
await fs.writeFile(filePath, otherContent);
await assert.rejects(lock.acquire(), { name: "LockFileExistsError" });
});
it("should throw if another lock in this process holds the file", async function () {
const other = new LockFile(filePath);
await other.acquire();
await assert.rejects(lock.acquire(), { name: "LockFileExistsError" });
await other.release();
});
it("should acquire a lock left by an earlier process with the same pid", async function () {
for (const content of [`${process.pid}\n`, `${process.pid} 1234.5\n`]) {
await fs.writeFile(filePath, content);
await lock.acquire();
await lock.release();
}
});
it("should throw while the lock file may still be being written", async function () {
await fs.writeFile(filePath, "");
await assert.rejects(lock.acquire(), { name: "LockFileExistsError" });
});
it("should acquire an unreadable lock that is old", async function () {
await writeOld("");
await lock.acquire();
});
// Runs takeOver right before this process starts clearing the stale lock, as another process could
function beforeRecovery(takeOver) {
fs.writeFile = async function(...args) {
if (String(args[0]).endsWith(".recovery")) {
fs.writeFile = writeFile;
await takeOver();
}
return writeFile(...args);
};
}
it("should leave a lock taken after the stale check in place", async function () {
await fs.writeFile(filePath, exitedContent);
beforeRecovery(async () => {
await fs.rm(filePath);
await fs.writeFile(filePath, otherContent);
});
await assert.rejects(lock.acquire(), { name: "LockFileExistsError" });
assert.equal(await fs.readFile(filePath, "utf8"), otherContent);
await assert.rejects(fs.access(recoveryPath));
});
it("should leave a new empty lock in place of an old empty lock", async function () {
const old = await writeOld("");
// Another process clears it and creates a new lock that it hasn't written its pid to yet
beforeRecovery(async () => {
await fs.rm(filePath);
await fs.writeFile(filePath, "");
});
await assert.rejects(lock.acquire(), { name: "LockFileExistsError" });
assert.equal(await fs.readFile(filePath, "utf8"), "");
assert((await fs.stat(filePath)).mtimeMs > old.getTime() + 30e3);
});
it("should leave a stale lock alone while another process clears it", async function () {
await fs.writeFile(filePath, exitedContent);
await fs.writeFile(recoveryPath, otherContent);
await assert.rejects(lock.acquire(), { name: "LockFileExistsError" });
assert.equal(await fs.readFile(filePath, "utf8"), exitedContent);
assert.equal(await fs.readFile(recoveryPath, "utf8"), otherContent);
});
it("should throw LockFileRecoveryError if clearing a stale lock was interrupted", async function () {
await fs.writeFile(filePath, exitedContent);
await fs.writeFile(recoveryPath, exitedContent);
await assert.rejects(lock.acquire(), { name: "LockFileRecoveryError" });
});
});
describe("release()", function () {
@@ -59,6 +154,13 @@ describe("class LockFile", function () {
it("should throw LockFileBusyError if called when not acquired", async function () {
await assert.rejects(lock.release(), { name: "LockFileBusyError" });
});
it("should leave a lock file it does not own in place", async function () {
await lock.acquire();
await fs.writeFile(filePath, otherContent);
await lock.release();
assert.equal(await fs.readFile(filePath, "utf8"), otherContent);
});
});
describe("isHeld()", function () {
@@ -74,7 +176,7 @@ describe("class LockFile", function () {
});
it("should return false if lock file contains different PID", async function () {
await fs.writeFile(filePath, "999999\n"); // simulate another process
await fs.writeFile(filePath, otherContent);
const held = await lock.isHeld();
assert.equal(held, false);
});
@@ -93,23 +195,54 @@ describe("class LockFile", function () {
assert.equal(stale, true);
});
it("should return true for invalid PID in lock file", async function () {
await fs.writeFile(filePath, "abc\n"); // non-numeric PID
it("should return true for invalid PID in an old lock file", async function () {
await writeOld("abc\n");
const stale = await lock.isStale();
assert.equal(stale, true);
});
it("should return false for invalid PID in a new lock file", async function () {
await fs.writeFile(filePath, "abc\n");
const stale = await lock.isStale();
assert.equal(stale, false);
});
it("should return true for non-existent process PID", async function () {
await fs.writeFile(filePath, "999999\n"); // assume PID does not exist
await fs.writeFile(filePath, exitedContent);
const stale = await lock.isStale();
assert.equal(stale, true);
});
it("should return false for current process PID", async function () {
await fs.writeFile(filePath, `${process.pid}\n`);
it("should return false for another running process", async function () {
await fs.writeFile(filePath, otherContent);
const stale = await lock.isStale();
assert.equal(stale, false);
});
it("should return false for a lock taken by this process", async function () {
await lock.acquire();
const stale = await new LockFile(filePath).isStale();
assert.equal(stale, false);
});
it("should return true for an earlier process with the same pid", async function () {
await fs.writeFile(filePath, `${process.pid}\n`);
const stale = await lock.isStale();
assert.equal(stale, true);
});
});
describe("holderPid()", function () {
it("should return the pid in the lock file", async function () {
await fs.writeFile(filePath, `${process.ppid} 1234.5\n`);
assert.equal(await lock.holderPid(), process.ppid);
});
it("should return undefined without a valid pid", async function () {
assert.equal(await lock.holderPid(), undefined);
await fs.writeFile(filePath, "");
assert.equal(await lock.holderPid(), undefined);
});
});
describe("attach/detach exit handlers", function () {
@@ -117,10 +250,21 @@ describe("class LockFile", function () {
await lock.acquire();
const listenersBefore = process.listeners("exit").slice();
assert.ok(listenersBefore.includes(lock.handleExitSync));
assert.ok(process.listeners("SIGTERM").includes(lock.handleExit));
await lock.release();
const listenersAfter = process.listeners("exit").slice();
assert.ok(!listenersAfter.includes(lock.handleExitSync));
assert.ok(!process.listeners("SIGTERM").includes(lock.handleExit));
});
it("should only attach the exit handler without handleSignals", async function () {
lock = new LockFile(filePath, false);
await lock.acquire();
assert.ok(process.listeners("exit").includes(lock.handleExitSync));
for (const event of ["SIGINT", "SIGTERM", "SIGHUP", "uncaughtException"]) {
assert.ok(!process.listeners(event).includes(lock.handleExit), event);
}
});
});
@@ -128,5 +272,14 @@ describe("class LockFile", function () {
it("should not throw if file already removed", function () {
assert.doesNotThrow(() => lock.handleExitSync()); // simulate non-existent file
});
it("should only remove a lock file it owns", async function () {
await fs.writeFile(filePath, otherContent);
lock.handleExitSync();
assert.equal(fsSync.readFileSync(filePath, "utf8"), otherContent);
await fs.writeFile(filePath, ownContent);
lock.handleExitSync();
assert.equal(fsSync.existsSync(filePath), false);
});
});
});
+13 -1
View File
@@ -1,6 +1,7 @@
import assert from "node:assert/strict";
import { SystemInfo } from "@clusterio/lib";
import { PluginDetails, SystemInfo } from "@clusterio/lib";
import { testRoundTripJsonSerialisable } from "../../common.js";
describe("lib/data/SystemInfo", function() {
@@ -32,5 +33,16 @@ describe("lib/data/SystemInfo", function() {
assert.equal(system.diskUsed, 1536);
assert.equal(system.diskRatio, 0.75);
});
it("has round trip json serialisation", function() {
const base = [
1, "host", "v22.0.0", "Linux", "x86_64", "CPU", [0.5],
1024, 768, 2048, 512, true, false, 0, 0, 0, false,
];
testRoundTripJsonSerialisable(SystemInfo, [
base,
[...base, []],
[...base, [new PluginDetails("name", "Title", "1.0.0", true, true, undefined, "name", "1.1.0")]],
]);
});
});
});
+61
View File
@@ -284,6 +284,67 @@ describe("lib/helpers", function() {
});
});
describe("safeExternalUrl()", function() {
it("should return http and https URLs", function() {
assert.equal(lib.safeExternalUrl("https://example.com/path?q=1#top"), "https://example.com/path?q=1#top");
assert.equal(lib.safeExternalUrl("http://example.com"), "http://example.com/");
assert.equal(lib.safeExternalUrl(" https://example.com/ "), "https://example.com/");
});
it("should reject links with other protocols", function() {
/* eslint-disable no-script-url */
for (const link of [
"javascript:alert(1)",
"JavaScript:alert(1)",
" javascript:alert(1)",
"java\tscript:alert(1)",
"\u0001javascript:alert(1)",
/* eslint-enable no-script-url */
"data:text/html,<script>alert(1)</script>",
"vbscript:msgbox(1)",
"file:///etc/passwd",
"ftp://example.com/",
]) {
assert.equal(lib.safeExternalUrl(link), undefined, link);
}
});
it("should reject values that are not URLs", function() {
for (const link of [undefined, null, 1, {}, { url: 1 }, "", "not a url", "/relative", "owner/repo"]) {
assert.equal(lib.safeExternalUrl(link), undefined, String(link));
}
});
it("should accept npm repository objects", function() {
assert.equal(
lib.safeExternalUrl({ type: "git", url: "git+https://github.com/owner/repo.git" }),
"https://github.com/owner/repo"
);
// eslint-disable-next-line no-script-url
assert.equal(lib.safeExternalUrl({ type: "git", url: "javascript:alert(1)" }), undefined);
});
it("should convert git URLs to https", function() {
for (const link of [
"git+https://github.com/owner/repo.git",
"git://github.com/owner/repo.git",
"git+ssh://git@github.com/owner/repo.git",
"git@github.com:owner/repo.git",
]) {
assert.equal(lib.safeExternalUrl(link), "https://github.com/owner/repo", link);
}
});
it("should expand repository shorthands", function() {
assert.equal(lib.safeExternalUrl("github:owner/repo"), "https://github.com/owner/repo");
assert.equal(lib.safeExternalUrl("gitlab:owner/repo"), "https://gitlab.com/owner/repo");
assert.equal(lib.safeExternalUrl("bitbucket:owner/repo"), "https://bitbucket.org/owner/repo");
assert.equal(lib.safeExternalUrl("github://evil.example/x"), "https://github.com/evil.example/x");
});
it("should remove credentials", function() {
assert.equal(lib.safeExternalUrl("https://user:secret@example.com/"), "https://example.com/");
assert.equal(
lib.safeExternalUrl("git+https://user:secret@example.com/repo.git"),
"https://example.com/repo"
);
});
});
describe("redactQuery()", function() {
it("should redact credential parameters and keep the rest", function() {
const query = { code: "secret", Token: "secret", state: "abc" };
+175
View File
@@ -0,0 +1,175 @@
import assert from "node:assert/strict";
import * as lib from "@clusterio/lib";
import "../setup_logging.js";
describe("lib/npm_registry", function() {
describe("fetchLatestVersions()", function() {
const _fetch = global.fetch;
let calls;
let cache;
beforeEach(function() {
calls = [];
cache = new Map();
global.fetch = async function(url, options) {
calls.push(url);
assert.ok(options.signal, "lookups have a timeout");
if (url.includes("broken")) {
throw new Error("network down");
}
if (url.includes("missing")) {
return { ok: false, status: 404 };
}
if (url.includes("limited")) {
return { ok: false, status: 429 };
}
return { ok: true, json: async () => ({ version: "1.2.3" }) };
};
});
after(function() {
global.fetch = _fetch;
});
it("returns the latest version of found packages", async function() {
const versions = await lib.fetchLatestVersions(
["latest-a", "@scope/latest-b", "missing-a", "broken-a"], cache,
);
assert.deepEqual(versions, { "latest-a": "1.2.3", "@scope/latest-b": "1.2.3" });
assert.ok(calls.includes("https://registry.npmjs.com/@scope/latest-b/latest"));
});
it("caches lookups", async function() {
await lib.fetchLatestVersions(["cached-a", "missing-b"], cache);
calls = [];
assert.deepEqual(await lib.fetchLatestVersions(["cached-a", "missing-b"], cache), { "cached-a": "1.2.3" });
assert.deepEqual(calls, []);
});
it("retries packages the registry couldn't be reached for or refused", async function() {
await lib.fetchLatestVersions(["broken-b", "limited-a"], cache);
calls = [];
await lib.fetchLatestVersions(["broken-b", "limited-a"], cache);
assert.equal(calls.length, 2);
});
it("drops expired lookups and keeps the cache bounded", async function() {
cache.set("expired", { version: "0.0.1", fetchedAt: Date.now() - 10 * 60e3 });
await lib.fetchLatestVersions(["fresh"], cache);
assert.equal(cache.has("expired"), false);
for (let i = 0; i < 1000; i++) {
cache.set(`filler-${i}`, { version: "1.0.0", fetchedAt: Date.now() });
}
await lib.fetchLatestVersions(["newest"], cache);
assert.equal(cache.size, 1000);
assert.equal(cache.get("newest").version, "1.2.3");
});
it("limits concurrent lookups", async function() {
let active = 0;
let maxActive = 0;
global.fetch = async function() {
active += 1;
maxActive = Math.max(maxActive, active);
await new Promise(resolve => setImmediate(resolve));
active -= 1;
return { ok: true, json: async () => ({ version: "1.0.0" }) };
};
const names = Array.from({ length: 50 }, (_, i) => `package-${i}`);
assert.equal(Object.keys(await lib.fetchLatestVersions(names, cache)).length, 50);
assert.ok(maxActive <= 8, `${maxActive} concurrent lookups`);
});
it("rejects invalid names", async function() {
await assert.rejects(lib.fetchLatestVersions(["foo@1.0.0"], cache), /Invalid plugin name/);
await assert.rejects(lib.fetchLatestVersions(["foo bar"], cache), /Invalid plugin name/);
});
});
describe("searchPlugins()", function() {
const _fetch = global.fetch;
const registryResponse = {
total: 1,
objects: [{
package: {
name: "@clusterio/plugin-foo",
version: "1.0.0",
description: "Foo",
date: "2026-01-01T00:00:00.000Z",
publisher: { username: "bar" },
links: {
npm: "https://www.npmjs.com/package/@clusterio/plugin-foo",
homepage: "https://example.com",
repository: "https://github.com/example/foo",
},
},
}],
};
after(function() {
global.fetch = _fetch;
});
it("queries the registry with the plugin keyword", async function() {
let calledWith;
global.fetch = function(url) {
calledWith = new URL(url);
return { ok: true, json: async () => registryResponse };
};
const response = await lib.searchPlugins("foo", 2, 10);
assert.equal(calledWith.origin + calledWith.pathname, "https://registry.npmjs.com/-/v1/search");
assert.equal(calledWith.searchParams.get("text"), "keywords:clusterio-plugin foo");
assert.equal(calledWith.searchParams.get("size"), "10");
assert.equal(calledWith.searchParams.get("from"), "10");
assert.deepEqual(response, new lib.PluginSearchRequest.Response(1, [
new lib.PluginSearchResult(
"@clusterio/plugin-foo", "1.0.0", "Foo", "bar", "2026-01-01T00:00:00.000Z",
"https://example.com", "https://github.com/example/foo",
"https://www.npmjs.com/package/@clusterio/plugin-foo",
),
]));
});
it("omits the query when empty", async function() {
let calledWith;
global.fetch = function(url) {
calledWith = new URL(url);
return { ok: true, json: async () => ({ total: 0, objects: [] }) };
};
await lib.searchPlugins("", 1, 20);
assert.equal(calledWith.searchParams.get("text"), "keywords:clusterio-plugin");
assert.equal(calledWith.searchParams.get("from"), "0");
});
it("drops optional fields the registry sends as null", async function() {
global.fetch = function() {
return { ok: true, json: async () => ({ total: 1, objects: [{ package: {
name: "foo", version: "1.0.0", description: null, date: null,
publisher: { username: null }, links: { npm: null, homepage: null, repository: null },
} }] }) };
};
const response = await lib.searchPlugins("", 1, 20);
assert.deepEqual(response, new lib.PluginSearchRequest.Response(1, [
new lib.PluginSearchResult("foo", "1.0.0"),
]));
const validate = lib.compile(lib.PluginSearchRequest.Response.jsonSchema);
assert.ok(validate(JSON.parse(JSON.stringify(response))), JSON.stringify(validate.errors));
});
it("handles missing optional fields", async function() {
global.fetch = function() {
return { ok: true, json: async () => ({ total: 1, objects: [{
package: { name: "foo", version: "1.0.0" },
}]}) };
};
const response = await lib.searchPlugins("", 1, 20);
assert.deepEqual(response.results, [new lib.PluginSearchResult("foo", "1.0.0")]);
});
it("rejects invalid paging", async function() {
await assert.rejects(lib.searchPlugins("", 0, 20), /Invalid page or page size/);
await assert.rejects(lib.searchPlugins("", 1, 0), /Invalid page or page size/);
await assert.rejects(lib.searchPlugins("", 1, 251), /Invalid page or page size/);
});
it("rejects overly long queries", async function() {
await assert.rejects(lib.searchPlugins("a".repeat(215), 1, 20), /Search query too long/);
});
it("rejects when the registry errors", async function() {
global.fetch = function() {
return { ok: false, status: 503, statusText: "Service Unavailable" };
};
await assert.rejects(lib.searchPlugins("", 1, 20), /npm registry search failed: 503/);
});
});
});
+356
View File
@@ -6,6 +6,7 @@ import { pathToFileURL } from "node:url";
import * as mock from "../mock.js";
import * as lib from "@clusterio/lib";
import { BaseControllerPlugin } from "@clusterio/controller";
import { computePluginStatuses, planUpdate } from "../../packages/web_ui/src/util/plugin_status.ts";
describe("lib/plugin_loader", function() {
@@ -48,12 +49,20 @@ describe("lib/plugin_loader", function() {
name: "test",
version: "0.0.1",
npmPackage: undefined,
skippedNpmPackage: undefined,
packagePath: path.resolve(path.join(testPlugin, "package.json")),
requirePath: path.resolve(testPlugin),
webStaticPath: path.resolve(path.join(testPlugin, "dist", "web", "static")),
}]
);
});
it("should record the npm package skipped for a plugin", async function() {
const [pluginInfo] = await lib.loadPluginInfos(
new Map([["test", path.resolve(testPlugin)]]),
new Map([["test", "/skipped"], ["other", "/other"]]),
);
assert.equal(pluginInfo.skippedNpmPackage, "/skipped");
});
it("should reject on broken plugin", async function() {
let brokenMessage;
try {
@@ -275,6 +284,7 @@ describe("lib/plugin_loader", function() {
const nodeModules = path.join(baseDir, "node_modules");
const npmPluginPath = path.join(nodeModules, "plugin-npm");
const npmPluginPathAbs = path.resolve(npmPluginPath);
const aliasedPluginPath = path.join(nodeModules, "plugin-aliased");
const deepPluginPath = path.join(nodeModules, "plugin-deep");
const transitivePluginPath = path.join(nodeModules, "plugin-transitive");
@@ -389,6 +399,352 @@ describe("lib/plugin_loader", function() {
assert.strictEqual(pluginList.get("hidden"), undefined);
});
it("should list installed versions and plugins pending a restart", async function() {
const pluginInfos = [{
name: "npm",
title: "NPM",
version: "0.0.0",
requirePath: npmPluginPathAbs,
packagePath: path.join(npmPluginPathAbs, "package.json"),
npmPackage: "plugin-npm",
}];
const plugins = await lib.listPluginDetails(pluginInfos, () => true, () => true);
const byPackage = new Map(plugins.map(plugin => [plugin.npmPackage ?? plugin.name, plugin]));
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);
// 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.installedVersion, "0.0.1", packageName);
assert.equal(pending.loaded, false, packageName);
}
// An alias installs a different package than its dependency name
assert.equal(byPackage.get("plugin-aliased").npmPackage, undefined);
assert.equal(byPackage.get("plugin-deep").npmPackage, "plugin-deep");
assert.equal(byPackage.has("not-a-plugin"), false);
assert.equal(byPackage.has("plugin-hidden"), false);
assert.equal(plugins.length, 4);
});
it("should not list a plugin loaded from a path as pending when npm links to it", async function() {
const rootPackageJson = await fs.readFile("package.json", "utf8");
const linkPath = path.join("node_modules", "plugin-linked");
try {
await fs.symlink(externalPluginPathAbs, linkPath, "junction");
await fs.writeFile("package.json", JSON.stringify({
dependencies: { "plugin-linked": "file:external_plugins/external_plugin" },
}));
const plugins = await lib.listPluginDetails([{
name: "external",
title: "External",
version: "0.0.1",
requirePath: externalPluginPathAbs,
packagePath: path.join(externalPluginPathAbs, "package.json"),
}], () => true, () => true);
assert.deepEqual(plugins.map(plugin => plugin.name), ["external"]);
} finally {
await fs.writeFile("package.json", rootPackageJson);
await fs.rm(linkPath, { force: true });
}
});
it("should record npm plugins skipped because their name is taken", async function() {
await fs.writeFile(pluginListPath, JSON.stringify([["npm", localPluginPathAbs]]));
const skipped = new Map();
const list = await lib.loadPluginList(pluginListPath, false, skipped);
assert.equal(list.get("npm"), localPluginPathAbs);
assert.deepEqual([...skipped], [["npm", await fs.realpath(npmPluginPathAbs)]]);
});
it("should not record npm plugins already in the list as skipped", async function() {
await fs.writeFile(pluginListPath, JSON.stringify([["npm", "plugin-npm"]]));
const skipped = new Map();
await lib.loadPluginList(pluginListPath, false, skipped);
assert.deepEqual([...skipped], []);
});
it("should not give plugins from a path, git or url an npm package on startup", async function() {
const rootPackageJson = await fs.readFile("package.json", "utf8");
try {
const root = JSON.parse(rootPackageJson);
root.dependencies["plugin-npm"] = "github:user/plugin-npm";
await fs.writeFile("package.json", JSON.stringify(root));
// loadPluginInfos resolves from where lib is installed, which can't reach these fixtures
const dirs = await lib.findNonRegistryDirs();
assert.equal(dirs.has(await fs.realpath(npmPluginPathAbs)), true);
assert.equal(dirs.has(await fs.realpath(path.resolve(old_cwd, deepPluginPath))), false);
} finally {
await fs.writeFile("package.json", rootPackageJson);
}
});
it("should treat tarball dependencies as not from the registry", function() {
assert.equal(lib.isRegistrySpec("plugin-x-1.0.0.tgz"), false);
assert.equal(lib.isRegistrySpec("plugin-x.tar.gz"), false);
assert.equal(lib.isRegistrySpec("^1.0.0"), true);
assert.equal(lib.isRegistrySpec("latest"), true);
});
it("should replace plugin list entries that no longer exist with npm plugins", async function() {
const gone = path.resolve("plugins", "gone");
await fs.writeFile(pluginListPath, JSON.stringify([["npm", gone], ["aliased", "plugin-gone"]]));
const skipped = new Map();
const list = await lib.loadPluginList(pluginListPath, false, skipped);
assert.equal(list.get("npm"), "plugin-npm");
assert.equal(list.get("aliased"), "plugin-aliased");
assert.deepEqual([...skipped], []);
const saved = new Map(JSON.parse(await fs.readFile(pluginListPath, "utf8")));
assert.equal(saved.get("npm"), "plugin-npm");
});
it("should list npm plugins skipped on startup as not loading while their name is taken", async function() {
const local = {
name: "npm",
title: "Local",
version: "0.0.1",
requirePath: localPluginPathAbs,
packagePath: path.join(localPluginPathAbs, "package.json"),
skippedNpmPackage: await fs.realpath(npmPluginPathAbs),
};
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);
// 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);
});
it("should list a dependency made direct since startup as pending", async function() {
// npm leaves the hoisted package untouched when it becomes a direct dependency
const rootPackageJson = await fs.readFile("package.json", "utf8");
try {
const root = JSON.parse(rootPackageJson);
root.dependencies["plugin-hidden"] = "^0.0.1";
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.npmPackage, "plugin-hidden");
} finally {
await fs.writeFile("package.json", rootPackageJson);
}
});
it("should not list plugins nested under another package as pending", async function() {
// Startup loads plugins by package name from the root, which doesn't reach nested packages
const packageJsonPath = path.join("node_modules", "plugin-transitive", "package.json");
const original = await fs.readFile(packageJsonPath, "utf8");
try {
const packageJson = JSON.parse(original);
packageJson.dependencies["plugin-nested"] = "^0.0.1";
await fs.writeFile(packageJsonPath, JSON.stringify(packageJson));
const plugins = await lib.listPluginDetails([], () => true, () => true);
assert.equal(plugins.some(plugin => plugin.name === "plugin-nested"), false);
assert.ok(plugins.some(plugin => plugin.name === "plugin-transitive"));
} finally {
await fs.writeFile(packageJsonPath, original);
}
});
it("should not list packages from a path, git or url as installable from npm", async function() {
const rootPackageJson = await fs.readFile("package.json", "utf8");
try {
const root = JSON.parse(rootPackageJson);
root.dependencies["plugin-npm"] = "file:../plugin-npm";
root.dependencies["plugin-deep"] = "github:user/plugin-deep";
await fs.writeFile("package.json", JSON.stringify(root));
const plugins = await lib.listPluginDetails([{
name: "npm",
title: "NPM",
version: "0.0.1",
requirePath: "plugin-npm",
packagePath: path.join(npmPluginPathAbs, "package.json"),
npmPackage: "plugin-npm",
}], () => true, () => true);
const npm = plugins.find(plugin => plugin.name === "npm");
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.npmPackage, undefined);
} finally {
await fs.writeFile("package.json", rootPackageJson);
}
});
it("should not list plugins another plugin installs from a path, git or url as installable", async function() {
const packageJsonPath = path.join("node_modules", "plugin-npm", "package.json");
const original = await fs.readFile(packageJsonPath, "utf8");
try {
const packageJson = JSON.parse(original);
packageJson.dependencies["plugin-transitive"] = "github:user/plugin-transitive";
await fs.writeFile(packageJsonPath, JSON.stringify(packageJson));
const transitive = {
name: "transitive",
title: "Transitive",
version: "0.0.1",
requirePath: "plugin-transitive",
packagePath: path.resolve(old_cwd, transitivePluginPath, "package.json"),
npmPackage: "plugin-transitive",
};
const [loaded] = await lib.listPluginDetails([transitive], () => true, () => true);
assert.equal(loaded.npmPackage, undefined);
const plugins = await lib.listPluginDetails([], () => true, () => true);
const pending = plugins.find(plugin => plugin.name === "plugin-transitive");
assert.equal(pending.npmPackage, undefined);
assert.equal(await lib.isNonRegistryPackage("plugin-transitive"), true);
assert.equal(await lib.isNonRegistryPackage("plugin-npm"), false);
} finally {
await fs.writeFile(packageJsonPath, original);
}
});
it("should let optional dependencies override where a plugin is installed from", async function() {
const packageJsonPath = path.join("node_modules", "plugin-npm", "package.json");
const original = await fs.readFile(packageJsonPath, "utf8");
try {
const packageJson = JSON.parse(original);
packageJson.optionalDependencies = { "plugin-transitive": "github:user/plugin-transitive" };
await fs.writeFile(packageJsonPath, JSON.stringify(packageJson));
const plugins = await lib.listPluginDetails([], () => true, () => true);
const pending = plugins.find(plugin => plugin.name === "plugin-transitive");
assert.equal(pending.npmPackage, undefined);
assert.equal(await lib.isNonRegistryPackage("plugin-transitive"), true);
} finally {
await fs.writeFile(packageJsonPath, original);
}
});
it("should not list a plugin replaced by another package since startup as installable", async function() {
const packageJsonPath = path.join("node_modules", "plugin-npm", "package.json");
const original = await fs.readFile(packageJsonPath, "utf8");
try {
await fs.writeFile(packageJsonPath, JSON.stringify({ ...JSON.parse(original), name: "other" }));
const [plugin] = await lib.listPluginDetails([{
name: "npm",
title: "NPM",
version: "0.0.1",
requirePath: "plugin-npm",
packagePath: path.join(npmPluginPathAbs, "package.json"),
npmPackage: "plugin-npm",
}], () => true, () => true);
assert.equal(plugin.npmPackage, undefined);
assert.equal(plugin.removed, undefined);
} finally {
await fs.writeFile(packageJsonPath, original);
}
});
it("should not list private packages as installable from npm", async function() {
const packageJsonPath = path.join("node_modules", "plugin-deep", "package.json");
const original = await fs.readFile(packageJsonPath, "utf8");
try {
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.npmPackage, undefined);
} finally {
await fs.writeFile(packageJsonPath, original);
}
});
it("should skip packages whose package.json is being rewritten", async function() {
const packageJsonPath = path.join("node_modules", "plugin-deep", "package.json");
const original = await fs.readFile(packageJsonPath, "utf8");
try {
await fs.writeFile(packageJsonPath, original.slice(0, 20));
const plugins = await lib.listPluginDetails([], () => true, () => true);
assert.equal(plugins.some(plugin => plugin.name === "plugin-deep"), false);
assert.ok(plugins.some(plugin => plugin.name === "plugin-aliased"));
} finally {
await fs.writeFile(packageJsonPath, original);
}
});
it("should skip packages with a malformed package.json before they are first resolved", async function() {
const rootPackageJson = await fs.readFile("package.json", "utf8");
const brokenPath = path.join("node_modules", "plugin-half-written");
try {
await fs.mkdir(brokenPath, { recursive: true });
await fs.writeFile(path.join(brokenPath, "package.json"), '{ "name": "plugin-half');
const root = JSON.parse(rootPackageJson);
root.dependencies["plugin-half-written"] = "^1.0.0";
await fs.writeFile("package.json", JSON.stringify(root));
const plugins = await lib.listPluginDetails([], () => true, () => true);
assert.equal(plugins.some(plugin => plugin.name === "plugin-half-written"), false);
assert.ok(plugins.some(plugin => plugin.name === "plugin-aliased"));
} finally {
await fs.writeFile("package.json", rootPackageJson);
await fs.rm(brokenPath, { recursive: true, force: true });
}
});
it("should not plan installing over an alias loaded on another machine", async function() {
// As loadPluginInfos returns it for the aliased plugin found on startup
assert.equal(pluginList.get("aliased"), "plugin-aliased");
const aliasInfo = {
name: "aliased",
title: "Aliased",
version: "0.0.1",
requirePath: "plugin-aliased",
packagePath: path.resolve("node_modules", "plugin-aliased", "package.json"),
npmPackage: undefined,
};
const hostB = await lib.listPluginDetails([aliasInfo], () => true, () => true);
const alias = hostB.find(plugin => plugin.name === "aliased");
assert.equal(alias.dependencyName, "plugin-aliased");
assert.equal(alias.npmPackage, undefined);
const published = new lib.PluginDetails(
"published", "Published", "1.0.0", true, true, undefined, "plugin-aliased", "1.0.0",
);
const statuses = computePluginStatuses([
{ name: "A", isController: false, address: { hostId: 1 }, plugins: [published] },
{ name: "B", isController: false, address: { hostId: 2 }, plugins: hostB },
]);
const plan = planUpdate(
statuses.get("plugin-aliased"), "2.0.0",
{ canInstall: () => true, canUpdate: () => true }, { hasPermission: () => true },
);
assert.deepEqual(plan.operations.map(operation => operation.target.name), ["A"]);
assert.deepEqual(
plan.skipped.map(skip => `${skip.target.name} (${skip.reason})`),
["B (another package is installed as plugin-aliased)"],
);
});
it("should mark plugins uninstalled since startup as removed", async function() {
const pluginInfos = [{
name: "gone",
title: "Gone",
version: "1.0.0",
requirePath: "plugin-gone",
packagePath: path.resolve("node_modules", "plugin-gone", "package.json"),
npmPackage: "plugin-gone",
}, {
name: "npm",
title: "NPM",
version: "0.0.1",
requirePath: npmPluginPathAbs,
packagePath: path.join(npmPluginPathAbs, "package.json"),
npmPackage: "plugin-npm",
}];
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").installedVersion, undefined);
assert.equal(byPackage.get("plugin-npm").removed, undefined);
});
it("should load existing plugin list", async function() {
const existingList = new Map([["test", "/test/path"]]);
await fs.writeFile(pluginListPath, JSON.stringify([...existingList]));
+312 -15
View File
@@ -1,35 +1,161 @@
import assert from "node:assert/strict";
import fs from "node:fs/promises";
import path from "node:path";
import fsSync from "node:fs";
import * as lib from "@clusterio/lib";
import "../setup_logging.js";
describe("rce_ops", function() {
describe("updatePackage", function() {
// Installation with dependencies installed from a path, an alias, git and the registry. Pinned
// installs and updates must not replace the ones not from the registry.
function useLocalDependencies() {
const cwd = process.cwd();
const dir = path.resolve("temp", "test", "rce_ops_local_dependency");
const _fetch = global.fetch;
before(async function() {
await fs.mkdir(dir, { recursive: true });
await fs.writeFile(path.join(dir, "package.json"), JSON.stringify({ dependencies: {
"linked": "file:../linked",
"aliased": "npm:other@^1.0.0",
"from-git": "github:user/from-git",
"published": "^1.0.0",
"optional": "^1.0.0",
}, optionalDependencies: {
"optional": "github:user/optional",
} }));
process.chdir(dir);
global.fetch = function() { return { ok: true }; };
});
after(function() {
process.chdir(cwd);
global.fetch = _fetch;
});
return dir;
}
describe("lib/rce_ops", function() {
describe("updatePackage()", function() {
it("runs", async function() {
// In dev env no side effects occur, so best we can do is check it doesn't error
await lib.updatePackage("foo");
});
});
describe("installPackage", function() {
describe("installPackage()", function() {
it("runs", async function() {
// In dev env no side effects occur, so best we can do is check it doesn't error
await lib.installPackage("foo");
});
});
describe("handlePluginUpdate", function() {
it("runs", async function() {
await lib.handlePluginUpdate("foo", [{ npmPackage: "foo" }]);
});
it("rejects when plugin not installed", async function() {
await assert.rejects(
lib.handlePluginUpdate("foo", []),
/Plugin foo is not installed on this machine/
);
describe("npm-operation.lock", function() {
const lockPath = "npm-operation.lock";
const inProgress = /Another package operation is already in progress/;
const audit = lib.logger.audit;
const recoveryPath = `${lockPath}.recovery`;
afterEach(async function() {
lib.logger.audit = audit;
await fs.rm(lockPath, { force: true });
await fs.rm(recoveryPath, { force: true });
});
it("rejects while another operation is in progress", async function() {
let nested;
lib.logger.audit = function() {
lib.logger.audit = audit;
nested = lib.installPackage("bar");
};
await lib.installPackage("foo");
await assert.rejects(nested, inProgress);
// The lock is released once the operation finishes
await lib.installPackage("foo");
await assert.rejects(fs.access(lockPath));
});
it("runs only one of several operations started together", async function() {
const results = await Promise.allSettled([
lib.installPackage("foo"), lib.updatePackage("bar"), lib.installPackage("baz"),
]);
assert.deepEqual(results.map(result => result.status), ["fulfilled", "rejected", "rejected"]);
for (const result of results.slice(1)) {
assert.match(result.reason.message, inProgress);
}
});
it("holds the lock file while the operation runs", async function() {
let lockContent;
lib.logger.audit = function() {
// eslint-disable-next-line node/no-sync
lockContent = fsSync.readFileSync(lockPath, "utf8");
};
await lib.installPackage("foo");
assert.equal(lockContent, `${process.pid} ${performance.timeOrigin}\n`);
await assert.rejects(fs.access(lockPath));
});
it("releases the lock when the operation fails", async function() {
lib.logger.audit = function() {
throw new Error("operation failed");
};
await assert.rejects(lib.installPackage("foo"), /operation failed/);
lib.logger.audit = audit;
await assert.rejects(fs.access(lockPath));
await lib.installPackage("foo");
});
it("leaves a lock it does not own in place", async function() {
lib.logger.audit = function() {
// eslint-disable-next-line node/no-sync
fsSync.writeFileSync(lockPath, `${process.ppid}\n`);
};
await lib.installPackage("foo");
assert.equal(await fs.readFile(lockPath, "utf8"), `${process.ppid}\n`);
});
it("rejects while another process holds the lock", async function() {
// A standalone controller and host share the installation directory
await fs.writeFile(lockPath, `${process.ppid}\n`);
await assert.rejects(lib.installPackage("foo"), inProgress);
assert.equal(await fs.readFile(lockPath, "utf8"), `${process.ppid}\n`);
});
it("rejects while another copy of lib in this process holds the lock", async function() {
const rceOpsUrl = new URL("../../packages/lib/dist/node/src/rce_ops.js", import.meta.url);
const otherCopy = await import(`${rceOpsUrl.href}?copy`);
await lib.installPackage("foo", async () => {
await assert.rejects(otherCopy.installPackage("bar"), inProgress);
});
});
it("names the process holding the lock", async function() {
await fs.writeFile(lockPath, `${process.ppid} 1234.5\n`);
await assert.rejects(lib.installPackage("foo"), new RegExp(`held by process ${process.ppid}\\)`));
});
it("reports an interrupted recovery", async function() {
await fs.writeFile(lockPath, "999999999\n");
await fs.writeFile(recoveryPath, "999999999\n");
await assert.rejects(lib.installPackage("foo"), /remove .*npm-operation\.lock\.recovery/);
});
});
});
describe("handleUpdateAll", function() {
describe("packageOperationRunning()", function() {
const lockPath = "npm-operation.lock";
const audit = lib.logger.audit;
afterEach(async function() {
lib.logger.audit = audit;
await fs.rm(lockPath, { force: true });
});
it("reports whether an operation is running", async function() {
assert.equal(await lib.packageOperationRunning(), false);
let running;
lib.logger.audit = function() {
lib.logger.audit = audit;
running = lib.packageOperationRunning();
};
await lib.installPackage("foo");
assert.equal(await running, true);
assert.equal(await lib.packageOperationRunning(), false);
await fs.writeFile(lockPath, `${process.ppid}\n`);
assert.equal(await lib.packageOperationRunning(), true);
await fs.writeFile(lockPath, "999999999\n");
assert.equal(await lib.packageOperationRunning(), false);
});
});
describe("handleUpdateAll()", function() {
it("updates the core package and all npm plugins in one command", async function() {
const _audit = lib.logger.audit;
const messages = [];
@@ -43,7 +169,112 @@ describe("rce_ops", function() {
});
});
describe("handlePluginInstall", function() {
describe("handlePluginUpdate()", function() {
it("runs", async function() {
await lib.handlePluginUpdate("foo", [{ npmPackage: "foo" }]);
});
it("rejects when plugin not installed", async function() {
await assert.rejects(
lib.handlePluginUpdate("foo", []),
/Plugin foo is not installed on this machine/
);
await assert.rejects(
lib.handlePluginUpdate("foo@1.2.3", []),
/Plugin foo is not installed on this machine/
);
});
it("updates to a pinned version", async function() {
const _fetch = global.fetch;
let calledWith = "";
global.fetch = function(url) {
calledWith = url;
return { ok: true };
};
try {
await lib.handlePluginUpdate("@scope/foo@1.2.3", [{ npmPackage: "@scope/foo" }]);
assert.equal(calledWith, "https://registry.npmjs.com/@scope/foo/1.2.3");
global.fetch = function() { return { ok: false }; };
await assert.rejects(
lib.handlePluginUpdate("foo@9.9.9", [{ npmPackage: "foo" }]),
/Unknown plugin: foo@9.9.9/,
);
} finally {
global.fetch = _fetch;
}
});
describe("over a dependency not from the registry", function() {
const dir = useLocalDependencies();
it("rejects pinned updates of dependencies not from the registry", async function() {
for (const name of ["linked", "aliased", "from-git", "optional"]) {
const error = new RegExp(`${name} is not installed from npm on this machine`);
await assert.rejects(lib.handlePluginUpdate(`${name}@2.0.0`, [{ npmPackage: name }]), error);
}
await lib.handlePluginUpdate("published@2.0.0", [{ npmPackage: "published" }]);
});
it("rejects pinned updates of a plugin another plugin installs from git", async function() {
const modules = path.join(dir, "node_modules");
const plugin = (name, dependencies) => fs.mkdir(path.join(modules, name), { recursive: true })
.then(() => fs.writeFile(path.join(modules, name, "package.json"), JSON.stringify({
name, version: "1.0.0", keywords: ["clusterio-plugin"], dependencies,
})));
await plugin("published", { child: "github:user/child" });
await plugin("child", {});
try {
await assert.rejects(
lib.handlePluginUpdate("child@2.0.0", [{ npmPackage: "child" }]),
/child is not installed from npm on this machine/,
);
} finally {
await fs.rm(modules, { recursive: true, force: true });
}
});
it("rejects pinned updates of a plugin an optional dependency installs from git", async function() {
const modules = path.join(dir, "node_modules");
const plugin = (name, packageJson) => fs.mkdir(path.join(modules, name), { recursive: true })
.then(() => fs.writeFile(path.join(modules, name, "package.json"), JSON.stringify({
name, version: "1.0.0", keywords: ["clusterio-plugin"], ...packageJson,
})));
await plugin("published", {
dependencies: { child: "^1.0.0" },
optionalDependencies: { child: "github:user/child" },
});
await plugin("child", {});
try {
await assert.rejects(
lib.handlePluginUpdate("child@2.0.0", [{ npmPackage: "child" }]),
/child is not installed from npm on this machine/,
);
} finally {
await fs.rm(modules, { recursive: true, force: true });
}
});
it("checks the source again once it holds the lock", async function() {
const packageJsonPath = path.join(dir, "package.json");
const original = await fs.readFile(packageJsonPath, "utf8");
// Another operation switches it to git while the registry is being checked
global.fetch = async function() {
const packageJson = JSON.parse(original);
packageJson.dependencies.published = "github:user/published";
await fs.writeFile(packageJsonPath, JSON.stringify(packageJson));
return { ok: true };
};
try {
const error = /published is not installed from npm on this machine/;
const update = lib.handlePluginUpdate("published@2.0.0", [{ npmPackage: "published" }]);
await assert.rejects(update, error);
} finally {
global.fetch = function() { return { ok: true }; };
await fs.writeFile(packageJsonPath, original);
}
});
});
});
describe("handlePluginInstall()", function() {
const _fetch = global.fetch;
before(function() {
global.fetch = function() { return { ok: false }; };
@@ -62,6 +293,28 @@ describe("rce_ops", function() {
await lib.handlePluginInstall("foo");
assert.equal(calledWith, "https://registry.npmjs.com/foo");
});
it("installs a pinned version", async function() {
let calledWith = "";
global.fetch = function(url) {
calledWith = url;
return { ok: true };
};
assert.equal(await lib.handlePluginInstall("foo@1.2.3"), "foo");
assert.equal(calledWith, "https://registry.npmjs.com/foo/1.2.3");
assert.equal(await lib.handlePluginInstall("@scope/foo@1.0.0-alpha.1"), "@scope/foo");
assert.equal(calledWith, "https://registry.npmjs.com/@scope/foo/1.0.0-alpha.1");
await lib.handlePluginInstall("@scope/foo");
assert.equal(calledWith, "https://registry.npmjs.com/@scope/foo");
});
it("rejects unregistered versions", async function() {
global.fetch = function() { return { ok: false }; };
await assert.rejects(lib.handlePluginInstall("foo@9.9.9"), /Unknown plugin: foo@9.9.9/);
});
it("rejects invalid versions", async function() {
await assert.rejects(lib.handlePluginInstall("foo@"), /Invalid plugin version: /);
await assert.rejects(lib.handlePluginInstall("foo@-1"), /Invalid plugin version: -1/);
});
it("rejects when name too long", async function() {
const pluginName = "a".repeat(215);
await assert.rejects(
@@ -69,6 +322,20 @@ describe("rce_ops", function() {
{ message: `Invalid plugin name: ${pluginName}` }
);
});
it("rejects specs npm would read as a path, repository or option", async function() {
let fetched = false;
global.fetch = function() {
fetched = true;
return { ok: true };
};
for (const spec of [
".", "..", "./foo", "../foo", "foo/latest", "@clusterio/lib/latest", "-/ping", "-foo", ".foo",
"_foo", "@scope", "@scope/", "@/foo", "@scope/foo/bar", "foo/bar@1.0.0", "github:foo/bar",
]) {
await assert.rejects(lib.handlePluginInstall(spec), /Invalid plugin name/, spec);
}
assert.equal(fetched, false);
});
it("rejects when invalid symbol present", async function() {
const pluginName = "?";
await assert.rejects(
@@ -86,5 +353,35 @@ describe("rce_ops", function() {
await assert.rejects(lib.handlePluginInstall("foo"), /Unknown plugin: foo/);
assert.equal(calledWith, "https://registry.npmjs.com/foo");
});
describe("over a dependency not from the registry", function() {
const dir = useLocalDependencies();
it("rejects pinned installs of dependencies not from the registry", async function() {
for (const name of ["linked", "aliased", "from-git", "optional"]) {
const error = new RegExp(`${name} is not installed from npm on this machine`);
await assert.rejects(lib.handlePluginInstall(`${name}@2.0.0`), error);
}
await lib.handlePluginInstall("published@2.0.0");
});
it("checks the source again once it holds the lock", async function() {
const packageJsonPath = path.join(dir, "package.json");
const original = await fs.readFile(packageJsonPath, "utf8");
// Another operation switches it to git while the registry is being checked
global.fetch = async function() {
const packageJson = JSON.parse(original);
packageJson.dependencies.published = "github:user/published";
await fs.writeFile(packageJsonPath, JSON.stringify(packageJson));
return { ok: true };
};
try {
const error = /published is not installed from npm on this machine/;
await assert.rejects(lib.handlePluginInstall("published@2.0.0"), error);
} finally {
global.fetch = function() { return { ok: true }; };
await fs.writeFile(packageJsonPath, original);
}
});
});
});
});
+81 -20
View File
@@ -1,10 +1,13 @@
import assert from "node:assert/strict";
import fs from "node:fs/promises";
import path from "node:path";
import * as lib from "@clusterio/lib";
import { Controller, ControlConnection } from "@clusterio/controller";
import { Host } from "@clusterio/host";
import "../setup_logging.js";
import { testRoundTripJsonSerialisable } from "../common.js";
describe("messages/plugin", function() {
/** @type {Host} */
@@ -34,14 +37,13 @@ describe("messages/plugin", function() {
describe("PluginDetails", function() {
it("has round trip json serialisation", function() {
const pluginDetails = new lib.PluginDetails(
"Name", "Title", "Version", true, true,
"Description", "Npm Package",
);
const json = JSON.stringify(pluginDetails);
const reconstructed = lib.PluginDetails.fromJSON(JSON.parse(json));
assert.deepEqual(reconstructed, pluginDetails);
testRoundTripJsonSerialisable(lib.PluginDetails, [
["Name", "Title", "Version", true, true],
[
"Name", "Title", "Version", false, false, "Description", "Npm Package",
"Installed Version", true, true, "Dependency Name", true, true,
],
]);
});
it("can be created from NodeEnvInfo interface", function() {
const pluginDetails = lib.PluginDetails.fromNodeEnvInfo({
@@ -53,6 +55,17 @@ describe("messages/plugin", function() {
"Description", "Npm Package",
));
});
it("can be created from the package.json of a package installed since startup", function() {
const pluginDetails = lib.PluginDetails.fromPackageJson(
"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",
));
const invalid = lib.PluginDetails.fromPackageJson("package", { version: "1.0.0", description: 1 });
assert.equal(invalid.description, undefined);
});
});
describe("PluginListRequest", function() {
@@ -66,6 +79,11 @@ describe("messages/plugin", function() {
initialValue: true,
};
});
const packagePath = path.resolve("temp", "test", "messages_plugin", "package.json");
before(async function() {
await fs.mkdir(path.dirname(packagePath), { recursive: true });
await fs.writeFile(packagePath, JSON.stringify({ name: "Npm Package", version: "Version" }));
});
after(function() {
delete lib.ControllerConfig.fieldDefinitions["Name.load_plugin"];
delete lib.HostConfig.fieldDefinitions["Name.load_plugin"];
@@ -73,7 +91,7 @@ describe("messages/plugin", function() {
it("returns correct info from the controller", async function() {
controller.pluginInfos = [{
name: "Name", title: "Title", version: "Version",
description: "Description", npmPackage: "Npm Package",
description: "Description", npmPackage: "Npm Package", packagePath,
}];
controller.loadedPlugins.add(controller.pluginInfos[0]);
@@ -82,13 +100,13 @@ describe("messages/plugin", function() {
const response = await controlConnection.handlePluginListRequest(new lib.PluginListRequest());
assert.deepEqual(response, [new lib.PluginDetails(
"Name", "Title", "Version", true, true,
"Description", "Npm Package",
"Description", "Npm Package", "Version",
)]);
});
it("returns correct info from a host", async function() {
host.pluginInfos = [{
name: "Name", title: "Title", version: "Version",
description: "Description", npmPackage: "Npm Package",
description: "Description", npmPackage: "Npm Package", packagePath, hostEntrypoint: "host.js",
}];
host.loadedPlugins.add(host.pluginInfos[0]);
@@ -97,17 +115,14 @@ describe("messages/plugin", function() {
const response = await host.handlePluginListRequest(new lib.PluginListRequest());
assert.deepEqual(response, [new lib.PluginDetails(
"Name", "Title", "Version", true, true,
"Description", "Npm Package",
"Description", "Npm Package", "Version",
)]);
});
});
describe("PluginUpdateRequest", function() {
it("has round trip json serialisation", function() {
const request = new lib.PluginUpdateRequest("foo");
const json = JSON.stringify(request);
const reconstructed = lib.PluginUpdateRequest.fromJSON(JSON.parse(json));
assert.deepEqual(reconstructed, request);
testRoundTripJsonSerialisable(lib.PluginUpdateRequest, [["foo"], ["@scope/foo@1.0.0"]]);
});
it("runs on the controller", async function() {
controller.pluginInfos = [{ npmPackage: "foo" }];
@@ -135,6 +150,33 @@ describe("messages/plugin", function() {
});
});
describe("PluginSearchRequest", function() {
const _fetch = global.fetch;
after(function() {
global.fetch = _fetch;
});
it("has round trip json serialisation", function() {
testRoundTripJsonSerialisable(lib.PluginSearchRequest, [[], ["foo", 2, 10]]);
});
it("has round trip json serialisation of the response", function() {
testRoundTripJsonSerialisable(lib.PluginSearchRequest.Response, [
[0, []],
[2, [
new lib.PluginSearchResult("foo", "1.0.0"),
new lib.PluginSearchResult("bar", "1.0.0", "Bar", "baz", "date", "home", "repo", "npm"),
]],
]);
});
it("runs on the controller", async function() {
global.fetch = function() {
return { ok: true, json: async () => ({ total: 0, objects: [] }) };
};
const response = await controlConnection.handlePluginSearchRequest(new lib.PluginSearchRequest());
assert.deepEqual(response, new lib.PluginSearchRequest.Response(0, []));
});
});
describe("UpdateAllRequest", function() {
function checkedPermissions(dst) {
const permissions = [];
@@ -201,10 +243,7 @@ describe("messages/plugin", function() {
});
it("has round trip json serialisation", function() {
const request = new lib.PluginInstallRequest("foo");
const json = JSON.stringify(request);
const reconstructed = lib.PluginInstallRequest.fromJSON(JSON.parse(json));
assert.deepEqual(reconstructed, request);
testRoundTripJsonSerialisable(lib.PluginInstallRequest, [["foo"], ["@scope/foo@1.0.0"]]);
});
it("runs on the controller", async function() {
controller.config.set("controller.allow_plugin_install", true);
@@ -229,4 +268,26 @@ describe("messages/plugin", function() {
);
});
});
describe("PluginLatestVersionsRequest", function() {
const _fetch = global.fetch;
before(function() {
global.fetch = async function() { return { ok: true, json: async () => ({ version: "4.5.6" }) }; };
});
after(function() {
global.fetch = _fetch;
});
it("has round trip json serialisation", function() {
testRoundTripJsonSerialisable(lib.PluginLatestVersionsRequest, [[[]], [["foo", "@scope/bar"]]]);
});
it("has round trip json serialisation of the response", function() {
testRoundTripJsonSerialisable(lib.PluginLatestVersionsRequest.Response, [[{}], [{ foo: "1.0.0" }]]);
});
it("runs on the controller", async function() {
const response = await controlConnection.handlePluginLatestVersionsRequest(
new lib.PluginLatestVersionsRequest(["latest-message-test"])
);
assert.deepEqual(response.versions, { "latest-message-test": "4.5.6" });
});
});
});
+35
View File
@@ -0,0 +1,35 @@
import assert from "node:assert/strict";
import { ExclusiveRunner } from "../../packages/web_ui/src/util/exclusive.ts";
describe("web_ui/util/exclusive", function() {
describe("class ExclusiveRunner", function() {
it("should refuse a restart while an install is running", async function() {
const states = [];
const runner = new ExclusiveRunner(state => states.push(state));
let finishInstall;
const install = runner.run("installing", () => new Promise(resolve => { finishInstall = resolve; }));
let restarted = false;
assert.equal(await runner.run("restarting", async () => { restarted = true; }), false);
assert.equal(restarted, false);
finishInstall();
assert.equal(await install, true);
assert.deepEqual(states, ["installing", undefined]);
});
it("should refuse an install while a restart is running", async function() {
const runner = new ExclusiveRunner(() => {});
let finishRestart;
const restart = runner.run("restarting", () => new Promise(resolve => { finishRestart = resolve; }));
assert.equal(await runner.run("installing", async () => {}), false);
finishRestart();
await restart;
assert.equal(await runner.run("installing", async () => {}), true);
});
it("should accept a task after one that failed", async function() {
const runner = new ExclusiveRunner(() => {});
await assert.rejects(runner.run("installing", async () => { throw new Error("failed"); }), /failed/);
assert.equal(runner.current, undefined);
assert.equal(await runner.run("restarting", async () => {}), true);
});
});
});
+171
View File
@@ -0,0 +1,171 @@
import assert from "node:assert/strict";
import * as lib from "@clusterio/lib";
import {
computePluginStatuses, machineStatusLabels, planMachineUpdate, planUpdate,
} from "../../packages/web_ui/src/util/plugin_status.ts";
describe("web_ui/util/plugin_status", function() {
describe("planUpdate()", function() {
const access = { canInstall: () => true, canUpdate: () => true };
const account = { hasPermission: () => true };
function machine(name, plugins) {
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);
}
function pending(packageName, npmPackage, version) {
return new lib.PluginDetails(
packageName, packageName, version, false, false, undefined, npmPackage, version, true,
);
}
function skippedText(result) {
return result.skipped.map(skip => ({ kind: skip.kind, text: `${skip.target.name} (${skip.reason})` }));
}
function plan(machines) {
const statuses = computePluginStatuses(machines);
assert.equal(statuses.size, 1);
const status = [...statuses.values()][0];
return planUpdate(status, "2.0.0", access, account);
}
it("should install on machines where the plugin is missing", function() {
const result = plan([machine("a", [loaded("plugin-aliased", "1.0.0")]), machine("b", [])]);
assert.deepEqual(result.operations.map(op => [op.kind, op.target.name, op.packageSpec]), [
["update", "a", "plugin-aliased@2.0.0"],
["install", "b", "plugin-aliased@2.0.0"],
]);
});
it("should not install over a pending alias with another machine's package", function() {
const result = plan([
machine("a", [loaded("plugin-aliased", "2.0.0")]),
machine("b", [pending("plugin-aliased", undefined, "1.0.0")]),
]);
assert.deepEqual(result.operations, []);
assert.deepEqual(skippedText(result), [{ kind: "install", text: "b (not installed from npm)" }]);
});
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)]),
]);
assert.deepEqual(result.operations.map(op => op.target.name), ["a"]);
assert.deepEqual(skippedText(result), [{ kind: "update", text: "b (not installed from npm)" }]);
});
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 result = plan([
machine("a", [loaded("plugin-aliased", "1.0.0")]),
machine("old", [old]),
]);
assert.deepEqual(result.operations.map(op => op.target.name), ["a"]);
assert.deepEqual(skippedText(result), [
{ kind: "update", text: "old (host too old to install a specific version)" },
]);
});
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);
alias.dependencyName = "plugin-aliased";
const statuses = computePluginStatuses([
machine("a", [loaded("plugin-aliased", "1.0.0")]),
machine("b", [alias]),
]);
const result = planUpdate(statuses.get("plugin-aliased"), "2.0.0", access, account);
assert.deepEqual(result.operations.map(op => op.target.name), ["a"]);
assert.deepEqual(skippedText(result), [
{ kind: "install", text: "b (another package is installed as plugin-aliased)" },
]);
});
it("should install over a pending npm package", function() {
const result = plan([
machine("a", [loaded("plugin-aliased", "2.0.0")]),
machine("b", [pending("plugin-aliased", "plugin-aliased", "1.0.0")]),
]);
assert.deepEqual(result.operations.map(op => [op.kind, op.target.name]), [["install", "b"]]);
});
it("should skip offline machines and machines that haven't reported their plugins", function() {
const offline = { ...machine("off", [loaded("plugin-aliased", "1.0.0")]), offline: true };
const statuses = computePluginStatuses(
[machine("a", [loaded("plugin-aliased", "1.0.0")]), offline, { ...machine("gone", []), offline: true }],
[{ name: "old", isController: false, address: { hostId: 9 } }],
);
const result = planUpdate(statuses.get("plugin-aliased"), "2.0.0", access, account);
assert.deepEqual(result.operations.map(op => op.target.name), ["a"]);
assert.deepEqual(skippedText(result), [
{ kind: "update", text: "off (offline)" },
{ kind: "install", text: "gone (offline)" },
{ kind: "update", text: "old (plugins not reported)" },
]);
});
});
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",
);
const statuses = computePluginStatuses([
{ name: "a", isController: false, address: { hostId: 1 }, plugins: [loaded] },
{ name: "b", isController: false, address: { hostId: 2 }, plugins: [] },
], [{ name: "old", isController: false, address: { hostId: 9 } }]);
const status = statuses.get("plugin-aliased");
const plan = planMachineUpdate(
status, status.machines[1], "2.0.0",
{ canInstall: () => true, canUpdate: () => true }, { hasPermission: () => true },
);
assert.deepEqual(plan.operations.map(op => [op.kind, op.target.name]), [["install", "b"]]);
assert.deepEqual(plan.skipped, []);
});
});
describe("machineStatusLabels()", function() {
function labels(plugins, extra = {}) {
const [status] = computePluginStatuses([
{ name: "a", isController: false, address: { hostId: 1 }, plugins: [loadedPlugin()] },
{ name: "b", isController: false, address: { hostId: 2 }, plugins, ...extra },
]).values();
return machineStatusLabels(status.machines[1]);
}
function loadedPlugin(version = "1.0.0", installedVersion = version) {
return new lib.PluginDetails(
"plugin", "Plugin", version, true, true, undefined, "plugin", installedVersion,
);
}
it("should describe the state of a plugin on a machine", function() {
assert.deepEqual(labels([loadedPlugin()]), []);
assert.deepEqual(labels([loadedPlugin("1.0.0", "2.0.0")]), ["Restart pending"]);
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",
);
assert.deepEqual(labels([disabled]), ["Disabled"]);
const local = new lib.PluginDetails("plugin", "Plugin", "1.0.0", true, true);
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 [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.running, undefined);
assert.equal(entry.afterRestart, undefined);
});
});
});
+196
View File
@@ -0,0 +1,196 @@
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) {
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 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",
})],
};
}
}
}
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",
})],
};
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,
})],
};
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",
})],
};
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",
})],
};
yield {
label: `npm ${version} removed and reinstalled at ${target}`,
entry: { source: "npm", version, installed: target, replaced: true },
plugins: [
details({
name: "plugin", version, loaded: true, enabled: true, npmPackage: "pkg",
removed: true, dependencyName: "pkg",
}),
details({
name: "pkg", version: target, loaded: false, enabled: false, npmPackage: "pkg",
installedVersion: target, pendingInstall: true, dependencyName: "pkg",
}),
],
};
}
yield {
label: "alias loaded as pkg",
taken: "dependency",
plugins: [details({
name: "real_plugin", version: "1.0.0", loaded: true, enabled: true,
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", loaded: true, enabled: true, installedVersion: "1.0.0" }),
details({
name: "pkg", version, loaded: false, enabled: false, npmPackage: "pkg",
installedVersion: version, dependencyName: "pkg", nameTaken: true,
}),
],
entry: { source: "npm", version, installed: version },
};
}
yield {
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",
})],
};
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: "pkg", version: "1.0.0", loaded: false, enabled: false, npmPackage: "pkg",
installedVersion: "1.0.0", pendingInstall: true, 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?.removed || 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", loaded: true, enabled: true, 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);
}
});
}
});
+48
View File
@@ -0,0 +1,48 @@
import assert from "node:assert/strict";
import { setTimeout as sleep } from "node:timers/promises";
import { poll } from "../../packages/web_ui/src/util/poll.ts";
describe("web_ui/util/poll", function() {
describe("poll()", function() {
it("should keep every result of a task slower than the interval", async function() {
let running = 0;
let maxRunning = 0;
const applied = [];
let count = 0;
const stop = poll(async () => {
count += 1;
const run = count;
running += 1;
maxRunning = Math.max(maxRunning, running);
await sleep(20);
running -= 1;
applied.push(run);
}, 1);
await sleep(150);
stop();
await sleep(40);
assert.equal(maxRunning, 1);
assert.ok(applied.length >= 3, `only ${applied.length} runs finished`);
assert.deepEqual(applied, applied.slice().sort((a, b) => a - b));
assert.equal(applied.length, count);
});
it("should not run again once stopped", async function() {
let count = 0;
const stop = poll(async () => { count += 1; }, 1);
stop();
await sleep(20);
assert.equal(count, 1);
});
it("should not run again when stopped while a run is in progress", async function() {
let count = 0;
const stop = poll(async () => { count += 1; await sleep(10); }, 1);
await sleep(2);
stop();
await sleep(30);
assert.equal(count, 1);
});
});
});