diff --git a/test/file/test_plugin/control.js b/test/file/test_plugin/control.js new file mode 100644 index 00000000..25a80a0c --- /dev/null +++ b/test/file/test_plugin/control.js @@ -0,0 +1,26 @@ +"use strict"; +const { Command, CommandTree } = require("@clusterio/lib"); +const { BaseCtlPlugin } = require("@clusterio/ctl"); + +const testPluginCommands = new CommandTree({ + name: "test-plugin", description: "Test plugin commands", +}); +testPluginCommands.add(new Command({ + definition: ["echo ", "Print the message given to it", (yargs) => { + yargs.positional("message", { describe: "message to print", type: "string" }); + }], + handler: async function(args, control) { + // eslint-disable-next-line no-console + console.log(args.message); + }, +})); + +class CtlPlugin extends BaseCtlPlugin { + async addCommands(rootCommand) { + rootCommand.add(testPluginCommands); + } +} + +module.exports = { + CtlPlugin, +}; diff --git a/test/file/test_plugin/controller.js b/test/file/test_plugin/controller.js new file mode 100644 index 00000000..01988f39 --- /dev/null +++ b/test/file/test_plugin/controller.js @@ -0,0 +1,21 @@ +"use strict"; +const { BaseControllerPlugin } = require("@clusterio/controller"); + +const { ControllerEcho, HostEcho } = require("./messages"); + +class ControllerPlugin extends BaseControllerPlugin { + async init() { + this.logger.info("test_plugin controller loaded"); + this.controller.handle(ControllerEcho, this.handleControllerEcho.bind(this)); + } + + async handleControllerEcho(request) { + this.logger.info(`test_plugin controller echo ${request.text}`); + this.broadcastEventToHosts(new HostEcho(request.text)); + return request.text; + } +} + +module.exports = { + ControllerPlugin, +}; diff --git a/test/file/test_plugin/host.js b/test/file/test_plugin/host.js new file mode 100644 index 00000000..ca6a3bad --- /dev/null +++ b/test/file/test_plugin/host.js @@ -0,0 +1,26 @@ +"use strict"; +const { BaseHostPlugin } = require("@clusterio/host"); + +const { HostEcho, HostEchoReceived } = require("./messages"); + +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)); + } + + async handleHostEcho(event) { + this.logger.info(`test_plugin host echo ${event.text}`); + this.receivedEchoes.add(event.text); + } + + async handleHostEchoReceived(request) { + return this.receivedEchoes.has(request.text); + } +} + +module.exports = { + HostPlugin, +}; diff --git a/test/file/test_plugin/index.js b/test/file/test_plugin/index.js new file mode 100644 index 00000000..7938c45b --- /dev/null +++ b/test/file/test_plugin/index.js @@ -0,0 +1,24 @@ +"use strict"; +// Plugin exercising the plugin system in the integration tests, see #342. +// Written as plain JavaScript so that it does not need to be built. + +const { ControllerEcho, HostEcho, HostEchoReceived } = require("./messages"); + +module.exports = { + plugin: { + name: "test_plugin", + title: "Test Plugin", + description: "Plugin used to test the plugin system.", + + controllerEntrypoint: "controller", + hostEntrypoint: "host", + instanceEntrypoint: "instance", + ctlEntrypoint: "control", + + messages: [ + ControllerEcho, + HostEcho, + HostEchoReceived, + ], + }, +}; diff --git a/test/file/test_plugin/instance.js b/test/file/test_plugin/instance.js new file mode 100644 index 00000000..5296c026 --- /dev/null +++ b/test/file/test_plugin/instance.js @@ -0,0 +1,12 @@ +"use strict"; +const { BaseInstancePlugin } = require("@clusterio/host"); + +class InstancePlugin extends BaseInstancePlugin { + async init() { + this.logger.info("test_plugin instance loaded"); + } +} + +module.exports = { + InstancePlugin, +}; diff --git a/test/file/test_plugin/messages.js b/test/file/test_plugin/messages.js new file mode 100644 index 00000000..be98c2c4 --- /dev/null +++ b/test/file/test_plugin/messages.js @@ -0,0 +1,63 @@ +"use strict"; +const { JsonBoolean, JsonString } = require("@clusterio/lib"); + +// Sent from ctl to the controller, which answers with the text it was given +// and broadcasts a HostEcho to the hosts. +class ControllerEcho { + static type = "request"; + static src = "control"; + static dst = "controller"; + static plugin = "test_plugin"; + static permission = null; + static Response = JsonString; + + constructor(text) { + this.text = text; + } + + static jsonSchema = { type: "string" }; + toJSON() { return this.text; } + static fromJSON(json) { return new this(json); } +} + +// Broadcast from the controller to the hosts by broadcastEventToHosts. +class HostEcho { + static type = "event"; + static src = "controller"; + static dst = "host"; + static plugin = "test_plugin"; + static permission = null; + + constructor(text) { + this.text = text; + } + + static jsonSchema = { type: "string" }; + toJSON() { return this.text; } + static fromJSON(json) { return new this(json); } +} + +// Sent from ctl to a host, which answers with whether it received the given +// text via a HostEcho. +class HostEchoReceived { + static type = "request"; + static src = "control"; + static dst = "host"; + static plugin = "test_plugin"; + static permission = null; + static Response = JsonBoolean; + + constructor(text) { + this.text = text; + } + + static jsonSchema = { type: "string" }; + toJSON() { return this.text; } + static fromJSON(json) { return new this(json); } +} + +module.exports = { + ControllerEcho, + HostEcho, + HostEchoReceived, +}; diff --git a/test/file/test_plugin/package.json b/test/file/test_plugin/package.json new file mode 100644 index 00000000..16079ed7 --- /dev/null +++ b/test/file/test_plugin/package.json @@ -0,0 +1,10 @@ +{ + "name": "test_plugin", + "version": "0.0.1", + "description": "Plugin used to test the plugin system, see #342", + "private": true, + "main": "index.js", + "keywords": [ + "clusterio-plugin" + ] +} diff --git a/test/integration/clusterio.js b/test/integration/clusterio.js index 2729eb8a..780a4dcc 100644 --- a/test/integration/clusterio.js +++ b/test/integration/clusterio.js @@ -16,6 +16,7 @@ const { spawnNode, instancesDir, factorioDir, databaseDir, controllerConfigPath, requiresFactorio, hasFactorio, } = require("./index"); +const { ControllerEcho, HostEchoReceived } = require("../file/test_plugin/messages"); /** @returns {Promise>} */ @@ -386,6 +387,64 @@ describe("Integration of Clusterio", function() { }); }); + describe("plugin system", function() { + // Exercised with test/file/test_plugin, which is a plugin that exists + // only to be loaded by these tests, see #342. + async function pluginDetails(address) { + const plugins = await getControl().sendTo(address, new lib.PluginListRequest()); + const details = plugins.find(plugin => plugin.name === "test_plugin"); + assert(details, `test_plugin missing from the plugin list of ${JSON.stringify(address)}`); + return details; + } + + it("should load the plugin on the controller", async function() { + const details = await pluginDetails("controller"); + assert(details.enabled, "test_plugin is not enabled on the controller"); + assert(details.loaded, "test_plugin is not loaded on the controller"); + }); + + it("should load the plugin on the host", async function() { + const details = await pluginDetails({ hostId: 4 }); + assert(details.enabled, "test_plugin is not enabled on the host"); + assert(details.loaded, "test_plugin is not loaded on the host"); + }); + + it("should report the plugin's title and version", async function() { + const details = await pluginDetails("controller"); + assert.equal(details.title, "Test Plugin"); + assert.equal(details.version, "0.0.1"); + }); + + it("should add the plugin's command to clusterioctl", async function() { + const { stdout } = await execCtlProcess("test-plugin echo works"); + assert(stdout.includes("works"), `plugin command did not print its message, got ${stdout}`); + }); + + it("should route a plugin request from control to the controller", async function() { + const response = await getControl().sendTo("controller", new ControllerEcho("to-controller")); + assert.equal(response, "to-controller"); + }); + + it("should route a plugin request from control to a host", async function() { + const received = await getControl().sendTo({ hostId: 4 }, new HostEchoReceived("never-broadcast")); + assert.equal(received, false); + }); + + it("should deliver a plugin event sent with broadcastEventToHosts", async function() { + await getControl().sendTo("controller", new ControllerEcho("to-hosts")); + + // The broadcast is sent without waiting for the hosts to receive it. + let received = false; + for (let attempt = 0; attempt < 100 && !received; attempt++) { + received = await getControl().sendTo({ hostId: 4 }, new HostEchoReceived("to-hosts")); + if (!received) { + await lib.wait(20); + } + } + assert(received, "host did not receive the event broadcast to it"); + }); + }); + describe("clusterioctl", function() { describe("controller config list", function() { it("runs", async function() { diff --git a/test/integration/index.js b/test/integration/index.js index 4a529471..1dbf56c3 100644 --- a/test/integration/index.js +++ b/test/integration/index.js @@ -350,6 +350,7 @@ before(async function() { await execController("config set controller.tls_private_key ../../test/file/tls/key.pem"); console.log("Setting Controller Plugins"); + await execCtlProcess("plugin add ../../test/file/test_plugin"); await execCtlProcess("plugin add ../../plugins/global_chat"); await execCtlProcess("plugin add ../../plugins/research_sync"); await execCtlProcess("plugin add ../../plugins/statistics_exporter");