mirror of
https://github.com/clusterio/clusterio.git
synced 2026-11-15 07:24:52 +09:00
Add typed hook system for plugins
Replace invokeHook and the per plugin on* methods with AsyncHook collections on the controller, host, instance, clusterioctl and web control objects. Plugin entrypoints export a default function that receives a load context and attaches handlers to the hooks it needs. Class exports still load through fromContext with a deprecation warning. The create templates and docs use the entrypoint function. This is clusterio/clusterio#921 recreated on top of master after the ESM migration and the PluginDeclaration permissions change. Closes #730 Co-Authored-By: Cooldude2606 <25043174+Cooldude2606@users.noreply.github.com> Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
8c03ac15ab
commit
7122cbccf8
64 files changed
+1878
-1184
No files matched your search
@@ -1,21 +1,59 @@
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import * as mock from "../mock.js";
|
||||
import * as controller from "@clusterio/controller";
|
||||
|
||||
describe("controller/src/BaseControllerPlugin", function() {
|
||||
describe("class BaseControllerPlugin", function() {
|
||||
let controllerPlugin;
|
||||
it("should be constructible", async function() {
|
||||
controllerPlugin = new controller.BaseControllerPlugin({}, {}, {}, new mock.MockLogger());
|
||||
await controllerPlugin.init();
|
||||
const info = { name: "test" };
|
||||
let mockController;
|
||||
beforeEach(function() {
|
||||
mockController = new mock.MockController();
|
||||
});
|
||||
it("should define defaults for hooks", async function() {
|
||||
await controllerPlugin.onInstanceStatusChanged({}, "running", "initialized");
|
||||
await controllerPlugin.onControllerConfigFieldChanged({}, "foo", null);
|
||||
await controllerPlugin.onInstanceConfigFieldChanged({}, {}, "foo", null);
|
||||
await controllerPlugin.onMetrics();
|
||||
await controllerPlugin.onShutdown();
|
||||
controllerPlugin.onHostConnectionEvent({}, "connect");
|
||||
await controllerPlugin.onPrepareHostDisconnect({});
|
||||
|
||||
it("should be constructible", async function() {
|
||||
const plugin = new controller.BaseControllerPlugin(info, mockController, {}, new mock.MockLogger());
|
||||
await plugin.init();
|
||||
assert.equal(mockController.hooks.size, 0);
|
||||
});
|
||||
it("should be constructible from a load context", async function() {
|
||||
const logger = new mock.MockLogger();
|
||||
const plugin = controller.BaseControllerPlugin.fromContext({
|
||||
plugin: info, controller: mockController, metrics: {}, logger,
|
||||
});
|
||||
assert.equal(plugin.info, info);
|
||||
assert.equal(plugin.controller, mockController);
|
||||
assert.equal(plugin.logger, logger);
|
||||
});
|
||||
it("should attach overridden hooks", async function() {
|
||||
let calls = [];
|
||||
class ControllerPlugin extends controller.BaseControllerPlugin {
|
||||
async onSaveData() { calls.push(["save", this]); }
|
||||
async onInstanceStatusChanged(instance, prev) { calls.push(["status", instance, prev]); }
|
||||
onHostConnectionEvent(connection, event) { calls.push(["host", connection, event]); }
|
||||
}
|
||||
const plugin = new ControllerPlugin(info, mockController, {}, new mock.MockLogger());
|
||||
assert.deepEqual([...mockController.hooks.attached], ["test"]);
|
||||
assert.equal(mockController.hooks.shutdown.size, 0);
|
||||
|
||||
await mockController.hooks.save.invoke();
|
||||
await mockController.hooks.instanceStatusChanged.invoke("instance", "running");
|
||||
await mockController.hooks.hostConnectionEvent.invoke("connection", "connect");
|
||||
assert.deepEqual(calls, [
|
||||
["save", plugin],
|
||||
["status", "instance", "running"],
|
||||
["host", "connection", "connect"],
|
||||
]);
|
||||
});
|
||||
it("should detach hooks", async function() {
|
||||
class ControllerPlugin extends controller.BaseControllerPlugin {
|
||||
async onSaveData() { }
|
||||
async onShutdown() { }
|
||||
}
|
||||
const plugin = new ControllerPlugin(info, mockController, {}, new mock.MockLogger());
|
||||
assert.equal(mockController.hooks.size, 1);
|
||||
plugin.detachHooks();
|
||||
assert.equal(mockController.hooks.size, 0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -85,7 +85,6 @@ describe("controller/src/routes", function() {
|
||||
let endpoint;
|
||||
beforeEach(function() {
|
||||
endpoint = `http://localhost:${port}/api/plugins`;
|
||||
controller.plugins = new Map([["foo", {}]]);
|
||||
controller.mockConfigEntries.set("foo.load_plugin", true);
|
||||
});
|
||||
it("should strip static/ from the plugin bundle path", async function() {
|
||||
@@ -93,6 +92,7 @@ describe("controller/src/routes", function() {
|
||||
name: "foo", version: "1.0.0", npmPackage: "foo",
|
||||
manifest: { "foo.js": "static/foo.abc.js" },
|
||||
}];
|
||||
controller.loadedPlugins = new Set(controller.pluginInfos);
|
||||
let response = await fetch(endpoint);
|
||||
assert.equal(response.status, 200);
|
||||
assert.deepEqual(await response.json(), [{
|
||||
@@ -106,6 +106,7 @@ describe("controller/src/routes", function() {
|
||||
{ name: "bar", version: "1.0.0", npmPackage: "bar", manifest: {} },
|
||||
{ name: "baz", version: "1.0.0", npmPackage: "baz", manifest: { "baz.js": "remoteEntry.js" } },
|
||||
];
|
||||
controller.loadedPlugins = new Set([controller.pluginInfos[0]]);
|
||||
let response = await fetch(endpoint);
|
||||
assert.equal(response.status, 200);
|
||||
let data = await response.json();
|
||||
|
||||
@@ -1,21 +1,16 @@
|
||||
import { BaseHostPlugin } from "@clusterio/host";
|
||||
|
||||
import { HostEcho, HostEchoReceived } from "./messages.js";
|
||||
|
||||
export class HostPlugin extends BaseHostPlugin {
|
||||
async init() {
|
||||
this.logger.info("test_plugin host loaded");
|
||||
this.receivedEchoes = new Set();
|
||||
this.host.handle(HostEcho, this.handleHostEcho.bind(this));
|
||||
this.host.handle(HostEchoReceived, this.handleHostEchoReceived.bind(this));
|
||||
}
|
||||
// Uses the default export entrypoint, the controller and ctl parts of this
|
||||
// plugin use the deprecated class export so both paths are exercised.
|
||||
export default async function(context) {
|
||||
const { host, logger } = context;
|
||||
logger.info("test_plugin host loaded");
|
||||
const receivedEchoes = new Set();
|
||||
|
||||
async handleHostEcho(event) {
|
||||
this.logger.info(`test_plugin host echo ${event.text}`);
|
||||
this.receivedEchoes.add(event.text);
|
||||
}
|
||||
host.handle(HostEcho, async (event) => {
|
||||
logger.info(`test_plugin host echo ${event.text}`);
|
||||
receivedEchoes.add(event.text);
|
||||
});
|
||||
|
||||
async handleHostEchoReceived(request) {
|
||||
return this.receivedEchoes.has(request.text);
|
||||
}
|
||||
host.handle(HostEchoReceived, async (request) => receivedEchoes.has(request.text));
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import { BaseInstancePlugin } from "@clusterio/host";
|
||||
export default async function(context) {
|
||||
const { instance, plugin, logger } = context;
|
||||
logger.info("test_plugin instance loaded");
|
||||
|
||||
export class InstancePlugin extends BaseInstancePlugin {
|
||||
async init() {
|
||||
this.logger.info("test_plugin instance loaded");
|
||||
}
|
||||
instance.hooks.start.attach(plugin.name, async () => {
|
||||
logger.info("test_plugin instance started");
|
||||
});
|
||||
}
|
||||
+40
-10
@@ -1,20 +1,50 @@
|
||||
import assert from "node:assert/strict";
|
||||
import * as mock from "../mock.js";
|
||||
|
||||
import * as host from "@clusterio/host";
|
||||
|
||||
describe("host/src/BaseHostPlugin", function() {
|
||||
describe("class BaseHostPlugin", function() {
|
||||
let hostPlugin;
|
||||
it("should be constructible", async function() {
|
||||
hostPlugin = new host.BaseHostPlugin({}, new mock.MockHost(), new mock.MockLogger());
|
||||
await hostPlugin.init();
|
||||
const info = { name: "test" };
|
||||
let mockHost;
|
||||
beforeEach(function() {
|
||||
mockHost = new mock.MockHost();
|
||||
});
|
||||
it("should define defaults for hooks", async function() {
|
||||
await hostPlugin.onHostConfigFieldChanged();
|
||||
await hostPlugin.onMetrics();
|
||||
await hostPlugin.onShutdown();
|
||||
hostPlugin.onControllerConnectionEvent("connect");
|
||||
await hostPlugin.onPrepareControllerDisconnect();
|
||||
|
||||
it("should be constructible", async function() {
|
||||
const plugin = new host.BaseHostPlugin(info, mockHost, new mock.MockLogger());
|
||||
await plugin.init();
|
||||
assert.equal(mockHost.hooks.size, 0);
|
||||
});
|
||||
it("should be constructible from a load context", async function() {
|
||||
const logger = new mock.MockLogger();
|
||||
const plugin = host.BaseHostPlugin.fromContext({ plugin: info, host: mockHost, logger });
|
||||
assert.equal(plugin.info, info);
|
||||
assert.equal(plugin.host, mockHost);
|
||||
assert.equal(plugin.logger, logger);
|
||||
});
|
||||
it("should attach overridden hooks", async function() {
|
||||
let calls = [];
|
||||
class HostPlugin extends host.BaseHostPlugin {
|
||||
async onHostConfigFieldChanged(field, curr, prev) { calls.push(["config", field, curr, prev]); }
|
||||
async onShutdown() { calls.push(["shutdown", this]); }
|
||||
}
|
||||
const plugin = new HostPlugin(info, mockHost, new mock.MockLogger());
|
||||
assert.deepEqual([...mockHost.hooks.attached], ["test"]);
|
||||
assert.equal(mockHost.hooks.metrics.size, 0);
|
||||
|
||||
await mockHost.hooks.hostConfigFieldChanged.invoke("host.name", "new", "old");
|
||||
await mockHost.hooks.shutdown.invoke();
|
||||
assert.deepEqual(calls, [["config", "host.name", "new", "old"], ["shutdown", plugin]]);
|
||||
});
|
||||
it("should detach hooks", async function() {
|
||||
class HostPlugin extends host.BaseHostPlugin {
|
||||
async onShutdown() { }
|
||||
}
|
||||
const plugin = new HostPlugin(info, mockHost, new mock.MockLogger());
|
||||
assert.equal(mockHost.hooks.size, 1);
|
||||
plugin.detachHooks();
|
||||
assert.equal(mockHost.hooks.size, 0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -5,19 +5,51 @@ import * as host from "@clusterio/host";
|
||||
|
||||
describe("host/src/BaseInstancePlugin", function() {
|
||||
describe("class BaseInstancePlugin", function() {
|
||||
const info = { name: "test" };
|
||||
let instancePlugin;
|
||||
it("should be constructible", async function() {
|
||||
instancePlugin = new host.BaseInstancePlugin({}, new mock.MockInstance(), {});
|
||||
instancePlugin = new host.BaseInstancePlugin(
|
||||
info, new mock.MockInstance(), new mock.MockHost(), new mock.MockLogger()
|
||||
);
|
||||
await instancePlugin.init();
|
||||
assert.equal(instancePlugin.instance.hooks.size, 0);
|
||||
});
|
||||
it("should define defaults for hooks", async function() {
|
||||
await instancePlugin.onMetrics();
|
||||
await instancePlugin.onStart();
|
||||
await instancePlugin.onStop();
|
||||
instancePlugin.onExit();
|
||||
await instancePlugin.onOutput({});
|
||||
instancePlugin.onControllerConnectionEvent("connect");
|
||||
await instancePlugin.onPrepareControllerDisconnect();
|
||||
it("should be constructible from a load context", async function() {
|
||||
const instance = new mock.MockInstance();
|
||||
const mockHost = new mock.MockHost();
|
||||
const logger = new mock.MockLogger();
|
||||
const plugin = host.BaseInstancePlugin.fromContext({ plugin: info, instance, host: mockHost, logger });
|
||||
assert.equal(plugin.info, info);
|
||||
assert.equal(plugin.instance, instance);
|
||||
assert.equal(plugin.host, mockHost);
|
||||
assert.equal(plugin.logger, logger);
|
||||
});
|
||||
it("should attach overridden hooks", async function() {
|
||||
let calls = [];
|
||||
class InstancePlugin extends host.BaseInstancePlugin {
|
||||
async onStart() { calls.push(["start", this]); }
|
||||
onExit() { calls.push(["exit"]); }
|
||||
async onOutput(parsed, line) { calls.push(["output", parsed, line]); }
|
||||
}
|
||||
const instance = new mock.MockInstance();
|
||||
const plugin = new InstancePlugin(info, instance, new mock.MockHost(), new mock.MockLogger());
|
||||
assert.deepEqual([...instance.hooks.attached], ["test"]);
|
||||
assert.equal(instance.hooks.stop.size, 0);
|
||||
|
||||
await instance.hooks.start.invoke();
|
||||
await instance.hooks.exit.invoke();
|
||||
await instance.hooks.output.invoke({ type: "generic" }, "line");
|
||||
assert.deepEqual(calls, [["start", plugin], ["exit"], ["output", { type: "generic" }, "line"]]);
|
||||
});
|
||||
it("should detach hooks", async function() {
|
||||
class InstancePlugin extends host.BaseInstancePlugin {
|
||||
async onStart() { }
|
||||
}
|
||||
const instance = new mock.MockInstance();
|
||||
const plugin = new InstancePlugin(info, instance, new mock.MockHost(), new mock.MockLogger());
|
||||
assert.equal(instance.hooks.size, 1);
|
||||
plugin.detachHooks();
|
||||
assert.equal(instance.hooks.size, 0);
|
||||
});
|
||||
describe("sendRcon", function() {
|
||||
it("should send commands out of order", async function() {
|
||||
|
||||
@@ -235,7 +235,7 @@ export async function execCtl(command) {
|
||||
inExecCtl = true;
|
||||
process.chdir("temp/test");
|
||||
try {
|
||||
const initArgs = await initializeCtl(command, control.plugins, true);
|
||||
const initArgs = await initializeCtl(command, control.hooks, true);
|
||||
if (!initArgs.shouldRun) {
|
||||
return;
|
||||
}
|
||||
@@ -394,7 +394,7 @@ before(async function() {
|
||||
|
||||
const initArgs = await initializeCtl(`--plugin-list ${pluginListPath} control-config list`, undefined, true);
|
||||
control.config = initArgs.controlConfig;
|
||||
control.plugins = initArgs.ctlPlugins;
|
||||
control.hooks = initArgs.ctlHooks;
|
||||
|
||||
const testPack = lib.ModPack.fromJSON({});
|
||||
testPack.id = 12;
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import * as mock from "../mock.js";
|
||||
import * as lib from "@clusterio/lib";
|
||||
|
||||
|
||||
describe("lib/AsyncHook", function() {
|
||||
describe("class AsyncHook", function() {
|
||||
let logger;
|
||||
let hook;
|
||||
beforeEach(function() {
|
||||
logger = new mock.MockLogger();
|
||||
logger.errors = [];
|
||||
logger.error = msg => logger.errors.push(msg);
|
||||
hook = new lib.AsyncHook(logger);
|
||||
});
|
||||
|
||||
it("should track attached handlers", function() {
|
||||
assert.equal(hook.size, 0);
|
||||
hook.attach("alpha", () => {});
|
||||
hook.attach("beta", () => {});
|
||||
assert.equal(hook.size, 2);
|
||||
assert.deepEqual([...hook.attached], ["alpha", "beta"]);
|
||||
});
|
||||
it("should reject duplicate handler names", function() {
|
||||
hook.attach("alpha", () => {});
|
||||
assert.throws(
|
||||
() => hook.attach("alpha", () => {}),
|
||||
new Error("Handler with name alpha is already attached")
|
||||
);
|
||||
});
|
||||
it("should detach handlers", async function() {
|
||||
let called = false;
|
||||
hook.attach("alpha", () => { called = true; });
|
||||
hook.detach("alpha");
|
||||
hook.detach("missing");
|
||||
await hook.invoke();
|
||||
assert.equal(called, false);
|
||||
assert.equal(hook.size, 0);
|
||||
});
|
||||
it("should clear all handlers", function() {
|
||||
hook.attach("alpha", () => {});
|
||||
hook.attach("beta", () => {});
|
||||
hook.clear();
|
||||
assert.equal(hook.size, 0);
|
||||
});
|
||||
it("should invoke all handlers with the given arguments", async function() {
|
||||
let calls = [];
|
||||
hook.attach("alpha", (a, b) => { calls.push(["alpha", a, b]); });
|
||||
hook.attach("beta", async (a, b) => { calls.push(["beta", a, b]); });
|
||||
await hook.invoke(1, "two");
|
||||
assert.deepEqual(calls, [["alpha", 1, "two"], ["beta", 1, "two"]]);
|
||||
});
|
||||
it("should log and ignore errors thrown by handlers", async function() {
|
||||
let betaCalled = false;
|
||||
hook.attach("alpha", () => { throw new Error("sync"); });
|
||||
hook.attach("beta", async () => { betaCalled = true; });
|
||||
hook.attach("gamma", async () => { throw new Error("async"); });
|
||||
await hook.invoke();
|
||||
assert(betaCalled, "beta was not called");
|
||||
assert.equal(logger.errors.length, 2);
|
||||
assert(logger.errors[0].startsWith('Ignoring error in hook "alpha":'), logger.errors[0]);
|
||||
assert(logger.errors[1].startsWith('Ignoring error in hook "gamma":'), logger.errors[1]);
|
||||
});
|
||||
it("should time out handlers that never resolve", async function() {
|
||||
hook = new lib.AsyncHook(logger, 10);
|
||||
hook.attach("stuck", () => new Promise(() => {}));
|
||||
hook.attach("fast", () => 1);
|
||||
assert.deepEqual(await hook.collect(), [1]);
|
||||
assert.equal(logger.errors.length, 1);
|
||||
assert(logger.errors[0].includes("Hook stuck timed out after 10ms"), logger.errors[0]);
|
||||
});
|
||||
it("should collect results from handlers", async function() {
|
||||
hook.attach("alpha", arg => arg);
|
||||
hook.attach("beta", () => undefined);
|
||||
hook.attach("gamma", async arg => arg * 2);
|
||||
hook.attach("delta", () => { throw new Error("Test"); });
|
||||
assert.deepEqual(await hook.collect(21), [21, 42]);
|
||||
});
|
||||
it("should collect results with their source", async function() {
|
||||
hook.attach("alpha", arg => arg);
|
||||
hook.attach("beta", () => undefined);
|
||||
hook.attach("gamma", async arg => arg * 2);
|
||||
assert.deepEqual(await hook.collectEntries(21), [["alpha", 21], ["gamma", 42]]);
|
||||
});
|
||||
it("should provide a stable listener bound to the hook", async function() {
|
||||
let calls = [];
|
||||
hook.attach("alpha", arg => { calls.push(arg); });
|
||||
const { listener } = hook;
|
||||
assert.equal(listener, hook.listener);
|
||||
await listener("value");
|
||||
assert.deepEqual(calls, ["value"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("class AsyncHookCollection", function() {
|
||||
class TestHooks extends lib.AsyncHookCollection {
|
||||
constructor(logger) {
|
||||
super(logger);
|
||||
this.first = this.newHook();
|
||||
this.second = this.newHook();
|
||||
}
|
||||
}
|
||||
|
||||
let hooks;
|
||||
beforeEach(function() {
|
||||
hooks = new TestHooks(new mock.MockLogger());
|
||||
});
|
||||
|
||||
it("should count each name once across hooks", function() {
|
||||
hooks.first.attach("alpha", () => {});
|
||||
hooks.second.attach("alpha", () => {});
|
||||
hooks.second.attach("beta", () => {});
|
||||
assert.equal(hooks.size, 2);
|
||||
assert.deepEqual([...hooks.attached], ["alpha", "beta"]);
|
||||
});
|
||||
it("should detach a name from all hooks", function() {
|
||||
hooks.first.attach("alpha", () => {});
|
||||
hooks.second.attach("alpha", () => {});
|
||||
hooks.second.attach("beta", () => {});
|
||||
hooks.detachAll("alpha");
|
||||
assert.equal(hooks.first.size, 0);
|
||||
assert.deepEqual([...hooks.second.attached], ["beta"]);
|
||||
});
|
||||
it("should clear all hooks", function() {
|
||||
hooks.first.attach("alpha", () => {});
|
||||
hooks.second.attach("beta", () => {});
|
||||
hooks.clearAll();
|
||||
assert.equal(hooks.size, 0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,36 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import * as mock from "../mock.js";
|
||||
import * as lib from "@clusterio/lib";
|
||||
|
||||
|
||||
describe("lib/plugin", function() {
|
||||
describe("invokeHook()", function() {
|
||||
let betaTestCalled = false;
|
||||
let plugins = new Map([
|
||||
["alpha", {
|
||||
test: async function() { },
|
||||
pass: async function(arg) { return arg; },
|
||||
error: async function() { throw new Error("Test"); },
|
||||
logger: new mock.MockLogger(),
|
||||
}],
|
||||
["beta", {
|
||||
test: async function() { betaTestCalled = true; },
|
||||
pass: async function() { },
|
||||
error: async function() { },
|
||||
logger: new mock.MockLogger(),
|
||||
}],
|
||||
]);
|
||||
it("should invoke the hook on the plugin", async function() {
|
||||
await lib.invokeHook(plugins, "test");
|
||||
assert(betaTestCalled, "Hook was not called");
|
||||
});
|
||||
it("should pass and return args", async function() {
|
||||
let result = await lib.invokeHook(plugins, "pass", 1234);
|
||||
assert.deepEqual(result, [1234]);
|
||||
});
|
||||
it("should ignore errors", async function() {
|
||||
await lib.invokeHook(plugins, "error");
|
||||
});
|
||||
});
|
||||
});
|
||||
+89
-35
@@ -3,6 +3,7 @@ import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
import * as mock from "../mock.js";
|
||||
import * as lib from "@clusterio/lib";
|
||||
import { BaseControllerPlugin } from "@clusterio/controller";
|
||||
|
||||
@@ -72,51 +73,104 @@ describe("lib/plugin_loader", function() {
|
||||
);
|
||||
});
|
||||
});
|
||||
describe("loadPluginClass()", function() {
|
||||
let baseDir = path.join("temp", "test", "plugin");
|
||||
let missingClass = path.join(baseDir, "missing_class_plugin");
|
||||
let wrongParentClass = path.join(baseDir, "wrong_parent_class_plugin");
|
||||
describe("loadPlugin()", function() {
|
||||
const baseDir = path.join("temp", "test", "plugin");
|
||||
const functionPlugin = path.resolve(baseDir, "function_plugin");
|
||||
const classPlugin = path.resolve(baseDir, "class_plugin");
|
||||
const throwingClassPlugin = path.resolve(baseDir, "throwing_class_plugin");
|
||||
const missingClassPlugin = path.resolve(baseDir, "missing_class_plugin");
|
||||
const wrongParentClassPlugin = path.resolve(baseDir, "wrong_parent_class_plugin");
|
||||
let controller;
|
||||
let logger;
|
||||
let context;
|
||||
|
||||
before(async function() {
|
||||
async function writeEntrypoint(pluginPath, content) {
|
||||
await fs.mkdir(pluginPath, { recursive: true });
|
||||
await fs.writeFile(path.join(pluginPath, "controller.js"), content);
|
||||
}
|
||||
|
||||
await writeEntrypoint(missingClass, "");
|
||||
await writeEntrypoint(
|
||||
wrongParentClass, "export class ControllerPlugin {};\n"
|
||||
await writeEntrypoint(functionPlugin, `
|
||||
export default async function(context) {
|
||||
context.controller.loadedWith = context;
|
||||
}
|
||||
`);
|
||||
await writeEntrypoint(classPlugin, `
|
||||
import { BaseControllerPlugin } from "@clusterio/controller";
|
||||
export class ControllerPlugin extends BaseControllerPlugin {
|
||||
async init() { this.controller.loadedWith = this; }
|
||||
async onSaveData() { }
|
||||
}
|
||||
`);
|
||||
await writeEntrypoint(throwingClassPlugin, `
|
||||
import { BaseControllerPlugin } from "@clusterio/controller";
|
||||
export class ControllerPlugin extends BaseControllerPlugin {
|
||||
async init() { throw new Error("init failed"); }
|
||||
async onSaveData() { }
|
||||
}
|
||||
`);
|
||||
await writeEntrypoint(missingClassPlugin, "");
|
||||
await writeEntrypoint(wrongParentClassPlugin, "export class ControllerPlugin {};\n");
|
||||
});
|
||||
|
||||
beforeEach(function() {
|
||||
controller = new mock.MockController();
|
||||
logger = new mock.MockLogger();
|
||||
logger.warnings = [];
|
||||
logger.warn = msg => logger.warnings.push(msg);
|
||||
context = { controller, metrics: {}, logger };
|
||||
});
|
||||
|
||||
function info(requirePath, entrypoint = "controller.js") {
|
||||
return { name: "test", requirePath, controllerEntrypoint: entrypoint };
|
||||
}
|
||||
|
||||
it("should do nothing when the entrypoint is not set", async function() {
|
||||
await lib.loadPlugin(
|
||||
{ name: "test", requirePath: missingClassPlugin }, "controller", context,
|
||||
"ControllerPlugin", BaseControllerPlugin,
|
||||
);
|
||||
assert.equal(controller.loadedWith, undefined);
|
||||
});
|
||||
it("should call the default export with the load context", async function() {
|
||||
const pluginInfo = info(functionPlugin);
|
||||
await lib.loadPlugin(pluginInfo, "controller", context, "ControllerPlugin", BaseControllerPlugin);
|
||||
assert.equal(controller.loadedWith.controller, controller);
|
||||
assert.equal(controller.loadedWith.plugin, pluginInfo);
|
||||
assert.equal(controller.loadedWith.logger, logger);
|
||||
assert.deepEqual(logger.warnings, []);
|
||||
});
|
||||
it("should load a deprecated class export", async function() {
|
||||
const pluginInfo = info(classPlugin);
|
||||
await lib.loadPlugin(pluginInfo, "controller", context, "ControllerPlugin", BaseControllerPlugin);
|
||||
assert(controller.loadedWith instanceof BaseControllerPlugin);
|
||||
assert.equal(controller.loadedWith.info, pluginInfo);
|
||||
assert.deepEqual([...controller.hooks.save.attached], ["test"]);
|
||||
assert.deepEqual(logger.warnings, ["Plugin test is using deprecated class export"]);
|
||||
});
|
||||
it("should detach hooks if init throws", async function() {
|
||||
await assert.rejects(
|
||||
lib.loadPlugin(
|
||||
info(throwingClassPlugin), "controller", context, "ControllerPlugin", BaseControllerPlugin
|
||||
),
|
||||
new Error("init failed")
|
||||
);
|
||||
assert.equal(controller.hooks.size, 0);
|
||||
});
|
||||
it("should throw if neither a function nor a class is exported", async function() {
|
||||
await assert.rejects(
|
||||
lib.loadPlugin(
|
||||
info(missingClassPlugin), "controller", context, "ControllerPlugin", BaseControllerPlugin
|
||||
),
|
||||
new Error("Plugin test must export either a default function or ControllerPlugin class")
|
||||
);
|
||||
});
|
||||
it("should throw if class is missing from entrypoint", async function() {
|
||||
const requirePath = path.resolve(missingClass);
|
||||
it("should throw if the class is not a subclass of the base class", async function() {
|
||||
await assert.rejects(
|
||||
lib.loadPluginClass(
|
||||
"test",
|
||||
path.posix.join(requirePath, "controller.js"),
|
||||
"ControllerPlugin",
|
||||
BaseControllerPlugin,
|
||||
lib.loadPlugin(
|
||||
info(wrongParentClassPlugin), "controller", context, "ControllerPlugin", BaseControllerPlugin
|
||||
),
|
||||
{
|
||||
message:
|
||||
`PluginError: Expected ${path.posix.join(requirePath, "controller.js")} ` +
|
||||
"to export a class named ControllerPlugin",
|
||||
}
|
||||
);
|
||||
});
|
||||
it("should throw if class is not a subclass of BaseControllerPlugin", async function() {
|
||||
const requirePath = path.resolve(wrongParentClass);
|
||||
await assert.rejects(
|
||||
lib.loadPluginClass(
|
||||
"test",
|
||||
path.posix.join(requirePath, "controller.js"),
|
||||
"ControllerPlugin",
|
||||
BaseControllerPlugin,
|
||||
),
|
||||
{
|
||||
message:
|
||||
"PluginError: Expected ControllerPlugin exported from " +
|
||||
`${path.posix.join(requirePath, "controller.js")} to be a subclass of BaseControllerPlugin`,
|
||||
}
|
||||
new Error("Expected ControllerPlugin exported from test to extend BaseControllerPlugin")
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -74,7 +74,7 @@ describe("messages/plugin", function() {
|
||||
description: "Description", npmPackage: "Npm Package",
|
||||
}];
|
||||
|
||||
controller.plugins.set("Name", {});
|
||||
controller.loadedPlugins.add(controller.pluginInfos[0]);
|
||||
controller.config.set("Name.load_plugin", true);
|
||||
|
||||
const response = await controlConnection.handlePluginListRequest(new lib.PluginListRequest());
|
||||
@@ -89,7 +89,7 @@ describe("messages/plugin", function() {
|
||||
description: "Description", npmPackage: "Npm Package",
|
||||
}];
|
||||
|
||||
host.plugins.set("Name", {});
|
||||
host.loadedPlugins.add(host.pluginInfos[0]);
|
||||
host.config.set("Name.load_plugin", true);
|
||||
|
||||
const response = await host.handlePluginListRequest(new lib.PluginListRequest());
|
||||
|
||||
+9
-2
@@ -3,7 +3,8 @@ import http from "node:http";
|
||||
import express from "express";
|
||||
|
||||
import * as lib from "@clusterio/lib";
|
||||
import { User, UserManager, UserRecord, InstanceManager } from "@clusterio/controller";
|
||||
import { User, UserManager, UserRecord, InstanceManager, ControllerHooks } from "@clusterio/controller";
|
||||
import { HostHooks, InstanceHooks } from "@clusterio/host";
|
||||
|
||||
const addr = lib.Address.fromShorthand;
|
||||
|
||||
@@ -123,6 +124,8 @@ export class MockInstance extends lib.Link {
|
||||
this.name = "test";
|
||||
this.id = 7357;
|
||||
this.status = "running";
|
||||
this.hooks = new InstanceHooks(this.logger);
|
||||
this.loadedPlugins = new Set();
|
||||
this.mockConfigEntries = new Map([
|
||||
["instance.id", 7357],
|
||||
["factorio.enable_save_patching", true],
|
||||
@@ -146,6 +149,8 @@ export class MockInstance extends lib.Link {
|
||||
export class MockHost extends lib.Link {
|
||||
constructor() {
|
||||
super(new MockConnector(addr({ hostId: 1 }), addr("controller")));
|
||||
this.hooks = new HostHooks(new MockLogger());
|
||||
this.loadedPlugins = new Set();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -192,6 +197,8 @@ export class MockController {
|
||||
|
||||
this.hosts = new lib.KeyValueDatastore();
|
||||
this.handles = new Map();
|
||||
this.hooks = new ControllerHooks(new MockLogger());
|
||||
this.loadedPlugins = new Set();
|
||||
}
|
||||
|
||||
get authSecret() {
|
||||
@@ -235,7 +242,7 @@ export async function createControllerPlugin(ControllerPluginClass, info) {
|
||||
export async function createInstancePlugin(InstancePluginClass, info) {
|
||||
let instance = new MockInstance();
|
||||
let host = new MockHost();
|
||||
let plugin = new InstancePluginClass(info, instance, host);
|
||||
let plugin = new InstancePluginClass(info, instance, host, new MockLogger());
|
||||
await plugin.init();
|
||||
return plugin;
|
||||
}
|
||||
Reference in new issue
Block a user