import fs from "node:fs/promises"; import path from "node:path"; import { describe, expect, it, vi } from "vitest"; import { withTempHome } from "./test-helpers.js"; async function writePluginFixture(params: { dir: string; id: string; schema: Record; channels?: string[]; }) { await fs.mkdir(params.dir, { recursive: true }); await fs.writeFile( path.join(params.dir, "index.js"), `export default { id: "${params.id}", register() {} };`, "utf-8", ); const manifest: Record = { id: params.id, configSchema: params.schema, }; if (params.channels) { manifest.channels = params.channels; } await fs.writeFile( path.join(params.dir, "clawdbot.plugin.json"), JSON.stringify(manifest, null, 2), "utf-8", ); } describe("config plugin validation", () => { it("rejects missing plugin load paths", async () => { await withTempHome(async (home) => { process.env.CLAWDBOT_STATE_DIR = path.join(home, ".clawdbot"); vi.resetModules(); const { validateConfigObjectWithPlugins } = await import("./config.js"); const missingPath = path.join(home, "missing-plugin"); const res = validateConfigObjectWithPlugins({ agents: { list: [{ id: "pi" }] }, plugins: { enabled: false, load: { paths: [missingPath] } }, }); expect(res.ok).toBe(false); if (!res.ok) { const hasIssue = res.issues.some( (issue) => issue.path === "plugins.load.paths" && issue.message.includes("plugin path not found"), ); expect(hasIssue).toBe(true); } }); }); it("rejects missing plugin ids in entries", async () => { await withTempHome(async (home) => { process.env.CLAWDBOT_STATE_DIR = path.join(home, ".clawdbot"); vi.resetModules(); const { validateConfigObjectWithPlugins } = await import("./config.js"); const res = validateConfigObjectWithPlugins({ agents: { list: [{ id: "pi" }] }, plugins: { enabled: false, entries: { "missing-plugin": { enabled: true } } }, }); expect(res.ok).toBe(false); if (!res.ok) { expect(res.issues).toContainEqual({ path: "plugins.entries.missing-plugin", message: "plugin not found: missing-plugin", }); } }); }); it("rejects missing plugin ids in allow/deny/slots", async () => { await withTempHome(async (home) => { process.env.CLAWDBOT_STATE_DIR = path.join(home, ".clawdbot"); vi.resetModules(); const { validateConfigObjectWithPlugins } = await import("./config.js"); const res = validateConfigObjectWithPlugins({ agents: { list: [{ id: "pi" }] }, plugins: { enabled: false, allow: ["missing-allow"], deny: ["missing-deny"], slots: { memory: "missing-slot" }, }, }); expect(res.ok).toBe(false); if (!res.ok) { expect(res.issues).toEqual( expect.arrayContaining([ { path: "plugins.allow", message: "plugin not found: missing-allow" }, { path: "plugins.deny", message: "plugin not found: missing-deny" }, { path: "plugins.slots.memory", message: "plugin not found: missing-slot" }, ]), ); } }); }); it("surfaces plugin config diagnostics", async () => { await withTempHome(async (home) => { process.env.CLAWDBOT_STATE_DIR = path.join(home, ".clawdbot"); const pluginDir = path.join(home, "bad-plugin"); await writePluginFixture({ dir: pluginDir, id: "bad-plugin", schema: { type: "object", additionalProperties: false, properties: { value: { type: "boolean" }, }, required: ["value"], }, }); vi.resetModules(); const { validateConfigObjectWithPlugins } = await import("./config.js"); const res = validateConfigObjectWithPlugins({ agents: { list: [{ id: "pi" }] }, plugins: { enabled: true, load: { paths: [pluginDir] }, entries: { "bad-plugin": { config: { value: "nope" } } }, }, }); expect(res.ok).toBe(false); if (!res.ok) { const hasIssue = res.issues.some( (issue) => issue.path === "plugins.entries.bad-plugin.config" && issue.message.includes("invalid config"), ); expect(hasIssue).toBe(true); } }); }); it("accepts known plugin ids", async () => { await withTempHome(async (home) => { process.env.CLAWDBOT_STATE_DIR = path.join(home, ".clawdbot"); vi.resetModules(); const { validateConfigObjectWithPlugins } = await import("./config.js"); const res = validateConfigObjectWithPlugins({ agents: { list: [{ id: "pi" }] }, plugins: { enabled: false, entries: { discord: { enabled: true } } }, }); expect(res.ok).toBe(true); }); }); it("accepts plugin heartbeat targets", async () => { await withTempHome(async (home) => { process.env.CLAWDBOT_STATE_DIR = path.join(home, ".clawdbot"); const pluginDir = path.join(home, "bluebubbles-plugin"); await writePluginFixture({ dir: pluginDir, id: "bluebubbles-plugin", channels: ["bluebubbles"], schema: { type: "object" }, }); vi.resetModules(); const { validateConfigObjectWithPlugins } = await import("./config.js"); const res = validateConfigObjectWithPlugins({ agents: { defaults: { heartbeat: { target: "bluebubbles" } }, list: [{ id: "pi" }] }, plugins: { enabled: false, load: { paths: [pluginDir] } }, }); expect(res.ok).toBe(true); }); }); it("rejects unknown heartbeat targets", async () => { await withTempHome(async (home) => { process.env.CLAWDBOT_STATE_DIR = path.join(home, ".clawdbot"); vi.resetModules(); const { validateConfigObjectWithPlugins } = await import("./config.js"); const res = validateConfigObjectWithPlugins({ agents: { defaults: { heartbeat: { target: "not-a-channel" } }, list: [{ id: "pi" }] }, }); expect(res.ok).toBe(false); if (!res.ok) { expect(res.issues).toContainEqual({ path: "agents.defaults.heartbeat.target", message: "unknown heartbeat target: not-a-channel", }); } }); }); });