mirror of
https://github.com/clusterio/clusterio.git
synced 2026-11-14 15:24:52 +09:00
Merge pull request #1010 from bbassie/feature/plugin-browser
Plugin management through web interface
This commit is contained in:
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 }
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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")]],
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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" };
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user