Files
factorio-clusterio/test/lib/plugin_loader.js
T
Hornwitser 5dcaca4d0b Migrate to ECMAScript Modules
Migrate the usage of CommonJS specific code and imports to ESM.  Roughly
summarised this:
- Changes all require() calls to import statement or expressions.
- Changes usage of module.exports to export statements.
- Replaces require.resolve with import.meta.resolve.
- Replaces conditional imports with conditional subpath imports.
- Replaces module === require.main with import.meta.main.
- Adds the full filename with extention to all relative imports.
- Changes all usage of __dirname and __filename to their import.meta
  equivalents.
- Sets "type": "module" in package.json files.
- Removes usage of "use strict".
- Adds .ts an an alias of .js for the webpack build.

Due to differences in how require and import resolves files the plugin
entrypoints will have to include the filename and extension after this
migration in order to work.
2026-09-11 22:43:57 +02:00

280 lines
9.5 KiB
JavaScript

import assert from "node:assert/strict";
import fs from "node:fs/promises";
import path from "node:path";
import { pathToFileURL } from "node:url";
import * as lib from "@clusterio/lib";
import { BaseControllerPlugin } from "@clusterio/controller";
describe("lib/plugin_loader", function() {
describe("loadPluginInfos()", function() {
let baseDir = path.join("temp", "test", "plugin");
let missingPlugin = path.join(baseDir, "missing_plugin");
let testPlugin = path.join(baseDir, "test_plugin");
let brokenPlugin = path.join(baseDir, "broken_plugin");
let invalidPlugin = path.join(baseDir, "invalid_plugin");
before(async function() {
async function writePlugin(pluginPath, infoName) {
await fs.mkdir(pluginPath, { recursive: true });
await fs.writeFile(
path.join(pluginPath, "index.js"),
`export const plugin = { name: "${infoName}" };`
);
await fs.writeFile(
path.join(pluginPath, "package.json"),
'{ "version": "0.0.1" }'
);
}
await writePlugin(testPlugin, "test");
await writePlugin(brokenPlugin, "broken");
await fs.writeFile(path.join(brokenPlugin, "index.js"), "Syntax Error");
await writePlugin(invalidPlugin, "wrong");
});
it("should ignore missing plugins", async function() {
const result = await lib.loadPluginInfos(new Map([
["missing", path.resolve(missingPlugin)],
["no_index", path.resolve(baseDir)],
]), []);
assert.deepEqual(result, []);
});
it("should load test plugin", async function() {
assert.deepEqual(
await lib.loadPluginInfos(new Map([["test", path.resolve(testPlugin)]])),
[{
name: "test",
version: "0.0.1",
npmPackage: undefined,
packagePath: path.resolve(path.join(testPlugin, "package.json")),
requirePath: path.resolve(testPlugin),
webStaticPath: path.resolve(path.join(testPlugin, "dist", "web", "static")),
}]
);
});
it("should reject on broken plugin", async function() {
let brokenMessage;
try {
await import(pathToFileURL(path.resolve(path.join(brokenPlugin, "index.js"))));
} catch (err) {
brokenMessage = err.message;
}
await assert.rejects(
lib.loadPluginInfos(new Map([["broken", path.resolve(brokenPlugin)]])),
{ message: `PluginError: ${brokenMessage}` }
);
});
it("should reject on invalid plugin", async function() {
await assert.rejects(
lib.loadPluginInfos(new Map([["invalid", path.resolve(invalidPlugin)]])),
{ message: `Expected plugin at ${path.resolve(invalidPlugin)} to be named invalid but got wrong` }
);
});
});
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");
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"
);
});
it("should throw if class is missing from entrypoint", async function() {
const requirePath = path.resolve(missingClass);
await assert.rejects(
lib.loadPluginClass(
"test",
path.posix.join(requirePath, "controller.js"),
"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`,
}
);
});
});
describe("loadPluginList()", async function() {
const old_cwd = process.cwd();
const baseDir = path.join("temp", "test", "plugin_list");
const pluginListPath = path.join(baseDir, "plugin_list.json");
const localPluginPath = path.join(baseDir, "plugins", "local_plugin");
const localPluginPathAbs = path.resolve(localPluginPath);
const externalPluginPath = path.join(baseDir, "external_plugins", "external_plugin");
const externalPluginPathAbs = path.resolve(externalPluginPath);
const monorepoPluginPath = path.join(baseDir, "external_plugins", "monorepo", "monorepo_plugin");
const monorepoPluginPathAbs = path.resolve(monorepoPluginPath);
const nodeModules = path.join(baseDir, "node_modules");
const npmPluginPath = path.join(nodeModules, "plugin-npm");
const aliasedPluginPath = path.join(nodeModules, "plugin-aliased");
const deepPluginPath = path.join(nodeModules, "plugin-deep");
const transitivePluginPath = path.join(nodeModules, "plugin-transitive");
const nestedPluginPath = path.join(transitivePluginPath, "node_modules", "plugin-nested");
const hiddenPluginPath = path.join(nodeModules, "plugin-hidden");
const notAPluginPath = path.join(nodeModules, "not-a-plugin");
let pluginList;
before(async function() {
// Setup test plugins
async function writePlugin(
pluginPath,
name,
dependencies = {},
packageJsonFields = {},
) {
await fs.mkdir(pluginPath, { recursive: true });
await fs.writeFile(
path.join(pluginPath, "index.js"),
`export const plugin = { name: "${name}" };`
);
await fs.writeFile(
path.join(pluginPath, "package.json"),
JSON.stringify({
name: path.basename(pluginPath),
version: "0.0.1",
keywords: ["clusterio-plugin"],
dependencies,
...packageJsonFields,
})
);
}
try {
await fs.rm(baseDir, { force: true, recursive: true, maxRetries: 10 });
} catch {}
// Create local plugin
await writePlugin(localPluginPath, "local");
// Create external plugin
await writePlugin(externalPluginPath, "external");
// Write a monorepo plugin
await writePlugin(monorepoPluginPath, "monorepo");
// Create a plugin that is not in root or transitive
await writePlugin(hiddenPluginPath, "hidden");
// Create npm plugins
await writePlugin(npmPluginPath, "npm", { "plugin-transitive": "^1.0.0" });
await writePlugin(transitivePluginPath, "transitive", { "plugin-npm": "^1.0.0" });
await writePlugin(nestedPluginPath, "nested");
await writePlugin(aliasedPluginPath, "aliased", {}, { name: "real-plugin" });
// Create npm plugin with deep entry point
await writePlugin(deepPluginPath, "unused", {}, {
main: "./dist/index.js",
});
await fs.mkdir(path.join(deepPluginPath, "dist"));
await fs.writeFile(
path.join(deepPluginPath, "dist", "index.js"),
'export const plugin = { name: "deep" };'
);
// Create an npm module that is not a plugin
await writePlugin(notAPluginPath, "not-a-plugin", { "plugin-hidden": "^0.0.1" }, { keywords: [] });
// Create root package.json
await fs.writeFile(
path.join(baseDir, "package.json"),
JSON.stringify({
dependencies: {
"plugin-npm": "^1.0.0",
"not-a-plugin": "^1.0.0",
"plugin-aliased": "npm:real-plugin@^0.0.1",
"plugin-deep": "^0.0.1",
},
})
);
process.chdir(baseDir);
pluginList = await lib.loadPluginList(pluginListPath, true);
});
beforeEach(async function() {
await fs.rm(pluginListPath, { force: true });
});
after(async function() {
process.chdir(old_cwd);
});
it("should discover local plugins", async function() {
assert.ok(pluginList.has("local_plugin"));
assert.ok(pluginList.has("external_plugin"));
assert.strictEqual(pluginList.get("local_plugin"), localPluginPathAbs);
assert.strictEqual(pluginList.get("external_plugin"), externalPluginPathAbs);
});
it("should discover npm plugins", async function() {
assert.ok(pluginList.has("npm"));
assert.strictEqual(pluginList.get("npm"), "plugin-npm");
assert.strictEqual(pluginList.get("aliased"), "plugin-aliased");
assert.strictEqual(pluginList.get("deep"), "plugin-deep");
assert.strictEqual(pluginList.get("not-a-plugin"), undefined);
});
it("should discover transitive npm plugins without traversing non-plugin packages", async function() {
assert.strictEqual(pluginList.get("transitive"), "plugin-transitive");
assert.strictEqual(pluginList.get("nested"), undefined);
assert.strictEqual(pluginList.get("hidden"), undefined);
});
it("should load existing plugin list", async function() {
const existingList = new Map([["test", "/test/path"]]);
await fs.writeFile(pluginListPath, JSON.stringify([...existingList]));
const loadedPlugins = await lib.loadPluginList(pluginListPath, false);
assert.ok(loadedPlugins.has("test"));
assert.strictEqual(loadedPlugins.get("test"), "/test/path");
});
it("should support monorepo plugins", async function() {
assert.ok(pluginList.has("monorepo_plugin"));
assert.strictEqual(pluginList.get("monorepo_plugin"), monorepoPluginPathAbs);
});
it("should not throw when package.json has no dependencies field", async function() {
// Create a package.json without dependencies
await fs.writeFile(
path.join("package.json"),
JSON.stringify({
name: "test-package",
version: "1.0.0",
})
);
// Should not throw
await assert.doesNotReject(async () => {
await lib.loadPluginList(pluginListPath, true);
});
});
});
});