mirror of
https://github.com/clusterio/clusterio.git
synced 2026-11-15 23: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
+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")
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user