Files
factorio-clusterio/test/lib/AsyncHook.js
T
7122cbccf8 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>
2026-09-11 22:49:09 +00:00

133 lines
4.6 KiB
JavaScript

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);
});
});
});