mirror of
https://github.com/PHIDIAS0303/ExpCluster.git
synced 2026-11-11 23:54:52 +09:00
clusterio logs "using deprecated class export" for every node entrypoint that exports a Base*Plugin subclass, and the web UI logs "using deprecated class hooks" for WebPlugin classes. Every entrypoint now has a default export function, like exp_auto_restart and the exp_roles web plugin. The classes stay where they hold state, but no longer extend the base classes. The constructor takes the load context and init() attaches the hooks the base class used to attach from the on* methods, so the tests can still call those methods directly. exp_server_ups sends its RCON command through instance.sendRcon with the plugin name, which is what the removed BaseInstancePlugin.sendRcon did. The instance entrypoints of exp_commands, exp_gui, exp_legacy, exp_util and exp_scenario were empty classes, kept only so the host loads the plugin and patches its module into the save. An empty function does the same. The exp_groups web components read the subscriptions through exported useGroups, useAssignments and useRoleMappings hooks instead of looking the plugin up in control.plugins, which only holds class plugins. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
203 lines
9.5 KiB
JavaScript
203 lines
9.5 KiB
JavaScript
import t from "tap";
|
|
import * as lib from "@clusterio/lib";
|
|
import { Controller, InstanceRecord } from "@clusterio/controller";
|
|
import entrypoint, { ControllerPlugin } from "../dist/node/controller.js";
|
|
import * as messages from "../dist/node/messages.js";
|
|
import { plugin as pluginDeclaration } from "../dist/node/index.js";
|
|
|
|
// The controller validates message classes against the link registry, and the
|
|
// plugin's config fields must be defined before a ControllerConfig can set them
|
|
for (const Message of pluginDeclaration.messages) {
|
|
lib.Link.register(Message);
|
|
}
|
|
lib.addPluginConfigFields([pluginDeclaration]);
|
|
|
|
const logger = { child: () => logger, info: () => {}, warn: () => {}, error: () => {}, verbose: () => {} };
|
|
|
|
// Silence the singleton logger to avoid polluting the test output
|
|
lib.logger.silent = true;
|
|
t.after(() => {
|
|
lib.logger.silent = false;
|
|
});
|
|
|
|
const fromInstance = lib.Address.fromShorthand({ instanceId: 1 });
|
|
const fromUnknownInstance = lib.Address.fromShorthand({ instanceId: 9 });
|
|
const fromControl = lib.Address.fromShorthand({ controlId: 7 });
|
|
|
|
/** Build the plugin around a real controller, which is side effect free while not started. */
|
|
async function startPlugin(t2, { config = {} } = {}) {
|
|
const controllerConfig = new lib.ControllerConfig("controller", {
|
|
"controller.database_directory": t2.testdir(),
|
|
...config,
|
|
});
|
|
const controller = new Controller(logger, [], controllerConfig);
|
|
|
|
const instanceConfig = new lib.InstanceConfig("controller");
|
|
instanceConfig.set("instance.id", 1);
|
|
instanceConfig.set("instance.name", "EXP");
|
|
controller.instances.records.set(new InstanceRecord(instanceConfig, "running"));
|
|
controller.wsServer.controlConnections.set(7, { user: { name: "admin" } });
|
|
|
|
// Spies which record and then defer to the real behaviour
|
|
const state = { broadcasts: [], posts: [], warnings: [] };
|
|
const broadcast = controller.subscriptions.broadcast.bind(controller.subscriptions);
|
|
controller.subscriptions.broadcast = event => {
|
|
state.broadcasts.push(event);
|
|
broadcast(event);
|
|
};
|
|
|
|
const plugin = new ControllerPlugin({ plugin: { name: "exp_reports" }, controller, logger });
|
|
plugin.logger = { ...logger, warn: message => state.warnings.push(message) };
|
|
plugin.postWebhook = async (url, body) => { state.posts.push({ url, body }); };
|
|
await plugin.init();
|
|
return { plugin, controller, state };
|
|
}
|
|
|
|
t.test("entrypoint", async t2 => {
|
|
const controllerConfig = new lib.ControllerConfig("controller", { "controller.database_directory": t2.testdir() });
|
|
const controller = new Controller(logger, [], controllerConfig);
|
|
await entrypoint({ controller, logger, plugin: { name: "exp_reports" }, metrics: undefined });
|
|
for (const hook of ["shutdown"]) {
|
|
t2.ok([...controller.hooks[hook].attached].includes("exp_reports"), `attached to ${hook}`);
|
|
}
|
|
await controller.hooks.shutdown.invoke();
|
|
});
|
|
|
|
t.test("class ControllerPlugin", t2 => {
|
|
t2.test(".handleReportCreateRequest() from an instance records the instance and the reporter", async t3 => {
|
|
const { plugin } = await startPlugin(t3);
|
|
const report = await plugin.handleReportCreateRequest(
|
|
new messages.ReportCreateRequest("bob", " griefing ", "alice"), fromInstance,
|
|
);
|
|
|
|
t3.strictSame(report.playerName, "bob");
|
|
t3.strictSame(report.byPlayerName, "alice", "the reporter comes from the request");
|
|
t3.strictSame(report.reason, "griefing", "the reason is trimmed");
|
|
t3.strictSame(report.instanceName, "EXP", "the instance is named");
|
|
t3.ok(report.updatedAtMs > 0, "the report is timestamped");
|
|
t3.strictSame(plugin.listReports(), [report], "the report is stored");
|
|
|
|
const unknown = await plugin.handleReportCreateRequest(
|
|
new messages.ReportCreateRequest("carol", "spam", "alice"), fromUnknownInstance,
|
|
);
|
|
t3.strictSame(unknown.instanceName, "9", "an unknown instance is named by its id");
|
|
});
|
|
|
|
t2.test(".handleReportCreateRequest() from the web ui uses the user of the connection", async t3 => {
|
|
const { plugin } = await startPlugin(t3);
|
|
const report = await plugin.handleReportCreateRequest(
|
|
new messages.ReportCreateRequest("bob", "griefing", "someone-else"), fromControl,
|
|
);
|
|
|
|
t3.strictSame(report.byPlayerName, "admin", "the reporter is the connected user");
|
|
t3.strictSame(report.instanceName, "", "there is no instance");
|
|
});
|
|
|
|
t2.test(".handleReportCreateRequest() rejects reports which can not be stored", async t3 => {
|
|
const { plugin } = await startPlugin(t3);
|
|
await plugin.handleReportCreateRequest(new messages.ReportCreateRequest("bob", "griefing", "alice"), fromInstance);
|
|
|
|
await t3.rejects(
|
|
plugin.handleReportCreateRequest(new messages.ReportCreateRequest("bob", "again", "alice"), fromInstance),
|
|
{ message: "alice has already reported bob" },
|
|
"a player can only report another once",
|
|
);
|
|
await t3.rejects(
|
|
plugin.handleReportCreateRequest(new messages.ReportCreateRequest("bob", " ", "carol"), fromInstance),
|
|
{ message: "A report needs a reason" },
|
|
"a reason is required",
|
|
);
|
|
await t3.rejects(
|
|
plugin.handleReportCreateRequest(new messages.ReportCreateRequest("bob", "spam", ""), fromInstance),
|
|
{ message: "A report needs the name of the player making it" },
|
|
"an instance must name the reporter",
|
|
);
|
|
t3.strictSame(plugin.listReports().length, 1, "nothing else was stored");
|
|
});
|
|
|
|
t2.test(".handleReportListRequest() and .handleReportGetRequest() find reports", async t3 => {
|
|
const { plugin } = await startPlugin(t3);
|
|
const first = await plugin.handleReportCreateRequest(new messages.ReportCreateRequest("bob", "griefing", "alice"), fromInstance);
|
|
await plugin.handleReportCreateRequest(new messages.ReportCreateRequest("carol", "spam", "alice"), fromInstance);
|
|
|
|
t3.strictSame((await plugin.handleReportListRequest(new messages.ReportListRequest())).length, 2, "everything is listed");
|
|
t3.strictSame(
|
|
await plugin.handleReportListRequest(new messages.ReportListRequest("bob")), [first],
|
|
"a player's reports are listed",
|
|
);
|
|
t3.strictSame(await plugin.handleReportGetRequest(new messages.ReportGetRequest(first.id)), first);
|
|
await t3.rejects(
|
|
plugin.handleReportGetRequest(new messages.ReportGetRequest(123)),
|
|
{ message: "Report with ID 123 does not exist" },
|
|
);
|
|
});
|
|
|
|
t2.test(".handleReportDeleteRequest() removes the report and broadcasts the deletion", async t3 => {
|
|
const { plugin, state } = await startPlugin(t3);
|
|
const report = await plugin.handleReportCreateRequest(new messages.ReportCreateRequest("bob", "griefing", "alice"), fromInstance);
|
|
|
|
state.broadcasts.length = 0;
|
|
await plugin.handleReportDeleteRequest(new messages.ReportDeleteRequest(report.id));
|
|
t3.strictSame(plugin.listReports(), [], "the report is gone");
|
|
t3.ok(
|
|
state.broadcasts.some(event => event instanceof messages.ReportUpdatedEvent && event.updates[0].isDeleted),
|
|
"subscribers are told it was deleted",
|
|
);
|
|
await t3.rejects(
|
|
plugin.handleReportDeleteRequest(new messages.ReportDeleteRequest(report.id)),
|
|
{ message: `Report with ID ${report.id} does not exist` },
|
|
);
|
|
});
|
|
|
|
t2.test(".handleReportSubscription() replays only newer records", async t3 => {
|
|
const { plugin } = await startPlugin(t3);
|
|
const report = await plugin.handleReportCreateRequest(new messages.ReportCreateRequest("bob", "griefing", "alice"), fromInstance);
|
|
|
|
const all = await plugin.handleReportSubscription({ lastRequestTimeMs: 0 });
|
|
t3.strictSame(all.updates, [report], "everything is replayed from the start");
|
|
const none = await plugin.handleReportSubscription({ lastRequestTimeMs: report.updatedAtMs });
|
|
t3.strictSame(none, null, "nothing is replayed when up to date");
|
|
});
|
|
|
|
t2.test(".sendWebhooks() posts new reports to the configured hooks", async t3 => {
|
|
const { plugin, state } = await startPlugin(t3, { config: {
|
|
"exp_reports.discord_webhook_url": "https://discord.example/hook",
|
|
"exp_reports.json_webhook_url": "https://json.example/hook",
|
|
} });
|
|
const report = await plugin.handleReportCreateRequest(new messages.ReportCreateRequest("bob", "griefing", "alice"), fromInstance);
|
|
|
|
t3.strictSame(state.posts.map(post => post.url), ["https://discord.example/hook", "https://json.example/hook"]);
|
|
const embed = state.posts[0].body.embeds[0];
|
|
t3.strictSame(embed.fields.map(field => field.value), ["bob", "alice", "EXP", "griefing"], "the embed names the report");
|
|
t3.strictSame(state.posts[1].body, { type: "report_created", report: report.toJSON() }, "the json hook gets the record");
|
|
|
|
state.posts.length = 0;
|
|
await plugin.handleReportDeleteRequest(new messages.ReportDeleteRequest(report.id));
|
|
t3.strictSame(state.posts, [], "deletions are not posted");
|
|
});
|
|
|
|
t2.test(".sendWebhooks() does nothing without hooks", async t3 => {
|
|
const { plugin, state } = await startPlugin(t3);
|
|
await plugin.handleReportCreateRequest(new messages.ReportCreateRequest("bob", "griefing", "alice"), fromInstance);
|
|
t3.strictSame(state.posts, []);
|
|
});
|
|
|
|
t2.test(".postWebhook() logs rather than throws when the hook fails", async t3 => {
|
|
const { plugin, state } = await startPlugin(t3);
|
|
plugin.postWebhook = ControllerPlugin.prototype.postWebhook;
|
|
|
|
const realFetch = globalThis.fetch;
|
|
globalThis.fetch = async () => { throw new Error("connection refused"); };
|
|
t3.teardown(() => { globalThis.fetch = realFetch; });
|
|
|
|
await plugin.postWebhook("https://down.example/hook", {});
|
|
t3.strictSame(state.warnings, ["Webhook https://down.example/hook failed: connection refused"]);
|
|
|
|
globalThis.fetch = async () => ({ ok: false, status: 404 });
|
|
await plugin.postWebhook("https://down.example/hook", {});
|
|
t3.strictSame(state.warnings[1], "Webhook https://down.example/hook responded with 404");
|
|
});
|
|
|
|
t2.end();
|
|
});
|