import assert from "node:assert/strict"; 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"; import { computePluginStatuses, planUpdate } from "../../packages/web_ui/src/util/plugin_status.ts"; 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, skippedNpmPackage: undefined, packagePath: path.resolve(path.join(testPlugin, "package.json")), requirePath: path.resolve(testPlugin), webStaticPath: path.resolve(path.join(testPlugin, "dist", "web", "static")), }] ); }); it("should record the npm package skipped for a plugin", async function() { const [pluginInfo] = await lib.loadPluginInfos( new Map([["test", path.resolve(testPlugin)]]), new Map([["test", "/skipped"], ["other", "/other"]]), ); assert.equal(pluginInfo.skippedNpmPackage, "/skipped"); }); 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}` } ); }); describe("plugin installed from npm", function() { // Plugins from npm are listed by package name, so they must be resolved like a dependency const packageDir = path.resolve("node_modules", "@clusterio-test", "npm_plugin"); before(async function() { await fs.mkdir(path.join(packageDir, "dist"), { recursive: true }); await fs.writeFile(path.join(packageDir, "package.json"), JSON.stringify({ name: "@clusterio-test/npm_plugin", version: "0.0.2", main: "dist/index.js", type: "module", })); await fs.writeFile( path.join(packageDir, "dist", "index.js"), 'export const plugin = { name: "npm_plugin", controllerEntrypoint: "dist/controller.js" };', ); await fs.writeFile( path.join(packageDir, "dist", "controller.js"), "export default async function(context) { context.controller.loadedNpmPlugin = true; }", ); }); after(async function() { await fs.rm(path.dirname(packageDir), { recursive: true, force: true }); }); it("should load the plugin and its entrypoints", async function() { const [pluginInfo] = await lib.loadPluginInfos( new Map([["npm_plugin", "@clusterio-test/npm_plugin"]]) ); assert.equal(pluginInfo.name, "npm_plugin"); assert.equal(pluginInfo.npmPackage, "@clusterio-test/npm_plugin"); assert.equal(pluginInfo.packagePath, path.join(packageDir, "package.json")); const controller = new mock.MockController(); await lib.loadPlugin( pluginInfo, "controller", { controller, metrics: {}, logger: new mock.MockLogger() }, "ControllerPlugin", BaseControllerPlugin, ); assert.equal(controller.loadedNpmPlugin, true); }); }); 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("loadPluginMigrations()", function() { const baseDir = path.resolve("temp", "test", "plugin", "migration_plugin"); before(async function() { await fs.mkdir(baseDir, { recursive: true }); await fs.writeFile(path.join(baseDir, "migrations.js"), ` export default function(context) { context.hooks.controllerConfig.attach(context.plugin.name, config => { config.migratedBy = context.plugin.name; return config; }); } `); await fs.writeFile(path.join(baseDir, "throwing.js"), ` export default function() { throw new Error("load failed"); } `); }); afterEach(function() { lib.ControllerConfig.pluginMigrations.clear(); lib.Config.pluginVersions.clear(); }); function info(entrypoint) { return { name: "test", version: "1.0.0", packagePath: path.join(baseDir, "package.json"), migrationEntrypoint: entrypoint, }; } it("should attach migrations from the migration entrypoint", async function() { await lib.loadPluginMigrations([info("migrations.js"), { name: "other" }]); assert.deepEqual([...lib.ControllerConfig.pluginMigrations.attached], ["test"]); assert.deepEqual(lib.ControllerConfig.pluginMigrations.reduce({}), { migratedBy: "test" }); }); it("should register the version of every plugin", async function() { await lib.loadPluginMigrations([info("migrations.js"), { name: "other", version: "2.0.0" }]); assert.deepEqual([...lib.Config.pluginVersions], [["test", "1.0.0"], ["other", "2.0.0"]]); }); it("should reject with a PluginError if the entrypoint throws", async function() { await assert.rejects( lib.loadPluginMigrations([info("throwing.js")]), err => err instanceof lib.PluginError && err.pluginName === "test" ); }); }); 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(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, packagePath: path.join(requirePath, "package.json"), 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 the class is not a subclass of the base class", async function() { await assert.rejects( lib.loadPlugin( info(wrongParentClassPlugin), "controller", context, "ControllerPlugin", BaseControllerPlugin ), new Error("Expected ControllerPlugin exported from test to extend 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 npmPluginPathAbs = path.resolve(npmPluginPath); 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 list installed versions and plugins pending a restart", async function() { const pluginInfos = [{ name: "npm", title: "NPM", version: "0.0.0", requirePath: npmPluginPathAbs, packagePath: path.join(npmPluginPathAbs, "package.json"), npmPackage: "plugin-npm", }]; const plugins = await lib.listPluginDetails(pluginInfos, () => true, () => true); const byPackage = new Map(plugins.map(plugin => [plugin.npmPackage ?? plugin.name, plugin])); const loaded = byPackage.get("plugin-npm"); assert.equal(loaded.version, "0.0.0"); assert.equal(loaded.installedVersion, "0.0.1"); assert.equal(loaded.pendingInstall, undefined); // Dependencies of plugin packages are found the same way as on startup for (const packageName of ["plugin-aliased", "plugin-deep", "plugin-transitive"]) { const pending = byPackage.get(packageName); assert.equal(pending.pendingInstall, true, packageName); assert.equal(pending.installedVersion, "0.0.1", packageName); assert.equal(pending.loaded, false, packageName); } // An alias installs a different package than its dependency name assert.equal(byPackage.get("plugin-aliased").npmPackage, undefined); assert.equal(byPackage.get("plugin-deep").npmPackage, "plugin-deep"); assert.equal(byPackage.has("not-a-plugin"), false); assert.equal(byPackage.has("plugin-hidden"), false); assert.equal(plugins.length, 4); }); it("should not list a plugin loaded from a path as pending when npm links to it", async function() { const rootPackageJson = await fs.readFile("package.json", "utf8"); const linkPath = path.join("node_modules", "plugin-linked"); try { await fs.symlink(externalPluginPathAbs, linkPath, "junction"); await fs.writeFile("package.json", JSON.stringify({ dependencies: { "plugin-linked": "file:external_plugins/external_plugin" }, })); const plugins = await lib.listPluginDetails([{ name: "external", title: "External", version: "0.0.1", requirePath: externalPluginPathAbs, packagePath: path.join(externalPluginPathAbs, "package.json"), }], () => true, () => true); assert.deepEqual(plugins.map(plugin => plugin.name), ["external"]); } finally { await fs.writeFile("package.json", rootPackageJson); await fs.rm(linkPath, { force: true }); } }); it("should record npm plugins skipped because their name is taken", async function() { await fs.writeFile(pluginListPath, JSON.stringify([["npm", localPluginPathAbs]])); const skipped = new Map(); const list = await lib.loadPluginList(pluginListPath, false, skipped); assert.equal(list.get("npm"), localPluginPathAbs); assert.deepEqual([...skipped], [["npm", await fs.realpath(npmPluginPathAbs)]]); }); it("should not record npm plugins already in the list as skipped", async function() { await fs.writeFile(pluginListPath, JSON.stringify([["npm", "plugin-npm"]])); const skipped = new Map(); await lib.loadPluginList(pluginListPath, false, skipped); assert.deepEqual([...skipped], []); }); it("should not give plugins from a path, git or url an npm package on startup", async function() { const rootPackageJson = await fs.readFile("package.json", "utf8"); try { const root = JSON.parse(rootPackageJson); root.dependencies["plugin-npm"] = "github:user/plugin-npm"; await fs.writeFile("package.json", JSON.stringify(root)); // loadPluginInfos resolves from where lib is installed, which can't reach these fixtures const dirs = await lib.findNonRegistryDirs(); assert.equal(dirs.has(await fs.realpath(npmPluginPathAbs)), true); assert.equal(dirs.has(await fs.realpath(path.resolve(old_cwd, deepPluginPath))), false); } finally { await fs.writeFile("package.json", rootPackageJson); } }); it("should treat tarball dependencies as not from the registry", function() { assert.equal(lib.isRegistrySpec("plugin-x-1.0.0.tgz"), false); assert.equal(lib.isRegistrySpec("plugin-x.tar.gz"), false); assert.equal(lib.isRegistrySpec("^1.0.0"), true); assert.equal(lib.isRegistrySpec("latest"), true); }); it("should replace plugin list entries that no longer exist with npm plugins", async function() { const gone = path.resolve("plugins", "gone"); await fs.writeFile(pluginListPath, JSON.stringify([["npm", gone], ["aliased", "plugin-gone"]])); const skipped = new Map(); const list = await lib.loadPluginList(pluginListPath, false, skipped); assert.equal(list.get("npm"), "plugin-npm"); assert.equal(list.get("aliased"), "plugin-aliased"); assert.deepEqual([...skipped], []); const saved = new Map(JSON.parse(await fs.readFile(pluginListPath, "utf8"))); assert.equal(saved.get("npm"), "plugin-npm"); }); it("should list npm plugins skipped on startup as not loading while their name is taken", async function() { const local = { name: "npm", title: "Local", version: "0.0.1", requirePath: localPluginPathAbs, packagePath: path.join(localPluginPathAbs, "package.json"), skippedNpmPackage: await fs.realpath(npmPluginPathAbs), }; let plugins = await lib.listPluginDetails([local], () => true, () => true); const taken = plugins.find(plugin => plugin.name === "plugin-npm"); assert.equal(taken.nameTaken, true); assert.equal(taken.pendingInstall, undefined); // Loaded on the next restart once the plugin with that name is gone const gone = { ...local, packagePath: path.resolve("plugins", "gone", "package.json") }; plugins = await lib.listPluginDetails([gone], () => true, () => true); assert.equal(plugins.find(plugin => plugin.name === "plugin-npm").pendingInstall, true); }); it("should list a dependency made direct since startup as pending", async function() { // npm leaves the hoisted package untouched when it becomes a direct dependency const rootPackageJson = await fs.readFile("package.json", "utf8"); try { const root = JSON.parse(rootPackageJson); root.dependencies["plugin-hidden"] = "^0.0.1"; await fs.writeFile("package.json", JSON.stringify(root)); const plugins = await lib.listPluginDetails([], () => true, () => true); const hidden = plugins.find(plugin => plugin.name === "plugin-hidden"); assert.equal(hidden.pendingInstall, true); assert.equal(hidden.npmPackage, "plugin-hidden"); } finally { await fs.writeFile("package.json", rootPackageJson); } }); it("should not list plugins nested under another package as pending", async function() { // Startup loads plugins by package name from the root, which doesn't reach nested packages const packageJsonPath = path.join("node_modules", "plugin-transitive", "package.json"); const original = await fs.readFile(packageJsonPath, "utf8"); try { const packageJson = JSON.parse(original); packageJson.dependencies["plugin-nested"] = "^0.0.1"; await fs.writeFile(packageJsonPath, JSON.stringify(packageJson)); const plugins = await lib.listPluginDetails([], () => true, () => true); assert.equal(plugins.some(plugin => plugin.name === "plugin-nested"), false); assert.ok(plugins.some(plugin => plugin.name === "plugin-transitive")); } finally { await fs.writeFile(packageJsonPath, original); } }); it("should not list packages from a path, git or url as installable from npm", async function() { const rootPackageJson = await fs.readFile("package.json", "utf8"); try { const root = JSON.parse(rootPackageJson); root.dependencies["plugin-npm"] = "file:../plugin-npm"; root.dependencies["plugin-deep"] = "github:user/plugin-deep"; await fs.writeFile("package.json", JSON.stringify(root)); const plugins = await lib.listPluginDetails([{ name: "npm", title: "NPM", version: "0.0.1", requirePath: "plugin-npm", packagePath: path.join(npmPluginPathAbs, "package.json"), npmPackage: "plugin-npm", }], () => true, () => true); const npm = plugins.find(plugin => plugin.name === "npm"); assert.equal(npm.npmPackage, undefined); assert.equal(npm.dependencyName, "plugin-npm"); const deep = plugins.find(plugin => plugin.name === "plugin-deep"); assert.equal(deep.pendingInstall, true); assert.equal(deep.npmPackage, undefined); } finally { await fs.writeFile("package.json", rootPackageJson); } }); it("should not list plugins another plugin installs from a path, git or url as installable", async function() { const packageJsonPath = path.join("node_modules", "plugin-npm", "package.json"); const original = await fs.readFile(packageJsonPath, "utf8"); try { const packageJson = JSON.parse(original); packageJson.dependencies["plugin-transitive"] = "github:user/plugin-transitive"; await fs.writeFile(packageJsonPath, JSON.stringify(packageJson)); const transitive = { name: "transitive", title: "Transitive", version: "0.0.1", requirePath: "plugin-transitive", packagePath: path.resolve(old_cwd, transitivePluginPath, "package.json"), npmPackage: "plugin-transitive", }; const [loaded] = await lib.listPluginDetails([transitive], () => true, () => true); assert.equal(loaded.npmPackage, undefined); const plugins = await lib.listPluginDetails([], () => true, () => true); const pending = plugins.find(plugin => plugin.name === "plugin-transitive"); assert.equal(pending.npmPackage, undefined); assert.equal(await lib.isNonRegistryPackage("plugin-transitive"), true); assert.equal(await lib.isNonRegistryPackage("plugin-npm"), false); } finally { await fs.writeFile(packageJsonPath, original); } }); it("should let optional dependencies override where a plugin is installed from", async function() { const packageJsonPath = path.join("node_modules", "plugin-npm", "package.json"); const original = await fs.readFile(packageJsonPath, "utf8"); try { const packageJson = JSON.parse(original); packageJson.optionalDependencies = { "plugin-transitive": "github:user/plugin-transitive" }; await fs.writeFile(packageJsonPath, JSON.stringify(packageJson)); const plugins = await lib.listPluginDetails([], () => true, () => true); const pending = plugins.find(plugin => plugin.name === "plugin-transitive"); assert.equal(pending.npmPackage, undefined); assert.equal(await lib.isNonRegistryPackage("plugin-transitive"), true); } finally { await fs.writeFile(packageJsonPath, original); } }); it("should not list a plugin replaced by another package since startup as installable", async function() { const packageJsonPath = path.join("node_modules", "plugin-npm", "package.json"); const original = await fs.readFile(packageJsonPath, "utf8"); try { await fs.writeFile(packageJsonPath, JSON.stringify({ ...JSON.parse(original), name: "other" })); const [plugin] = await lib.listPluginDetails([{ name: "npm", title: "NPM", version: "0.0.1", requirePath: "plugin-npm", packagePath: path.join(npmPluginPathAbs, "package.json"), npmPackage: "plugin-npm", }], () => true, () => true); assert.equal(plugin.npmPackage, undefined); assert.equal(plugin.removed, undefined); } finally { await fs.writeFile(packageJsonPath, original); } }); it("should not list private packages as installable from npm", async function() { const packageJsonPath = path.join("node_modules", "plugin-deep", "package.json"); const original = await fs.readFile(packageJsonPath, "utf8"); try { await fs.writeFile(packageJsonPath, JSON.stringify({ ...JSON.parse(original), private: true })); const plugins = await lib.listPluginDetails([], () => true, () => true); const deep = plugins.find(plugin => plugin.name === "plugin-deep"); assert.equal(deep.pendingInstall, true); assert.equal(deep.npmPackage, undefined); } finally { await fs.writeFile(packageJsonPath, original); } }); it("should skip packages whose package.json is being rewritten", async function() { const packageJsonPath = path.join("node_modules", "plugin-deep", "package.json"); const original = await fs.readFile(packageJsonPath, "utf8"); try { await fs.writeFile(packageJsonPath, original.slice(0, 20)); const plugins = await lib.listPluginDetails([], () => true, () => true); assert.equal(plugins.some(plugin => plugin.name === "plugin-deep"), false); assert.ok(plugins.some(plugin => plugin.name === "plugin-aliased")); } finally { await fs.writeFile(packageJsonPath, original); } }); it("should skip packages with a malformed package.json before they are first resolved", async function() { const rootPackageJson = await fs.readFile("package.json", "utf8"); const brokenPath = path.join("node_modules", "plugin-half-written"); try { await fs.mkdir(brokenPath, { recursive: true }); await fs.writeFile(path.join(brokenPath, "package.json"), '{ "name": "plugin-half'); const root = JSON.parse(rootPackageJson); root.dependencies["plugin-half-written"] = "^1.0.0"; await fs.writeFile("package.json", JSON.stringify(root)); const plugins = await lib.listPluginDetails([], () => true, () => true); assert.equal(plugins.some(plugin => plugin.name === "plugin-half-written"), false); assert.ok(plugins.some(plugin => plugin.name === "plugin-aliased")); } finally { await fs.writeFile("package.json", rootPackageJson); await fs.rm(brokenPath, { recursive: true, force: true }); } }); it("should not plan installing over an alias loaded on another machine", async function() { // As loadPluginInfos returns it for the aliased plugin found on startup assert.equal(pluginList.get("aliased"), "plugin-aliased"); const aliasInfo = { name: "aliased", title: "Aliased", version: "0.0.1", requirePath: "plugin-aliased", packagePath: path.resolve("node_modules", "plugin-aliased", "package.json"), npmPackage: undefined, }; const hostB = await lib.listPluginDetails([aliasInfo], () => true, () => true); const alias = hostB.find(plugin => plugin.name === "aliased"); assert.equal(alias.dependencyName, "plugin-aliased"); assert.equal(alias.npmPackage, undefined); const published = new lib.PluginDetails( "published", "Published", "1.0.0", true, true, undefined, "plugin-aliased", "1.0.0", ); const statuses = computePluginStatuses([ { name: "A", isController: false, address: { hostId: 1 }, plugins: [published] }, { name: "B", isController: false, address: { hostId: 2 }, plugins: hostB }, ]); const plan = planUpdate( statuses.get("plugin-aliased"), "2.0.0", { canInstall: () => true, canUpdate: () => true }, { hasPermission: () => true }, ); assert.deepEqual(plan.operations.map(operation => operation.target.name), ["A"]); assert.deepEqual( plan.skipped.map(skip => `${skip.target.name} (${skip.reason})`), ["B (another package is installed as plugin-aliased)"], ); }); it("should mark plugins uninstalled since startup as removed", async function() { const pluginInfos = [{ name: "gone", title: "Gone", version: "1.0.0", requirePath: "plugin-gone", packagePath: path.resolve("node_modules", "plugin-gone", "package.json"), npmPackage: "plugin-gone", }, { name: "npm", title: "NPM", version: "0.0.1", requirePath: npmPluginPathAbs, packagePath: path.join(npmPluginPathAbs, "package.json"), npmPackage: "plugin-npm", }]; const plugins = await lib.listPluginDetails(pluginInfos, () => true, () => true); const byPackage = new Map(plugins.map(plugin => [plugin.npmPackage ?? plugin.name, plugin])); assert.equal(byPackage.get("plugin-gone").removed, true); assert.equal(byPackage.get("plugin-gone").installedVersion, undefined); assert.equal(byPackage.get("plugin-npm").removed, 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); }); }); }); });