From f13ae50ff8e995a0b5c1cc492f6d7a4b0ccc9806 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 12 Jan 2026 01:16:42 +0000 Subject: [PATCH] test: plugin install + docker e2e --- scripts/e2e/plugins-docker.sh | 39 ++++++++ src/config/schema.test.ts | 28 ++++++ src/plugins/discovery.test.ts | 28 ++++++ src/plugins/install.test.ts | 162 ++++++++++++++++++++++++++++++++++ 4 files changed, 257 insertions(+) create mode 100644 src/plugins/install.test.ts diff --git a/scripts/e2e/plugins-docker.sh b/scripts/e2e/plugins-docker.sh index fabb5bb75..12dba5ea6 100755 --- a/scripts/e2e/plugins-docker.sh +++ b/scripts/e2e/plugins-docker.sh @@ -57,6 +57,45 @@ if (diagErrors.length > 0) { throw new Error(`diagnostics errors: ${diagErrors.map((diag) => diag.message).join("; ")}`); } +console.log("ok"); +NODE + + echo "Testing tgz install flow..." + pack_dir="$(mktemp -d "/tmp/clawdbot-plugin-pack.XXXXXX")" + mkdir -p "$pack_dir/package" + cat > "$pack_dir/package/package.json" <<'"'"'JSON'"'"' +{ + "name": "@clawdbot/demo-plugin-tgz", + "version": "0.0.1", + "clawdbot": { "extensions": ["./index.js"] } +} +JSON + cat > "$pack_dir/package/index.js" <<'"'"'JS'"'"' +module.exports = { + id: "demo-plugin-tgz", + name: "Demo Plugin TGZ", + register(api) { + api.registerGatewayMethod("demo.tgz", async () => ({ ok: true })); + }, +}; +JS + tar -czf /tmp/demo-plugin-tgz.tgz -C "$pack_dir" package + + node dist/index.js plugins install /tmp/demo-plugin-tgz.tgz + node dist/index.js plugins list --json > /tmp/plugins2.json + + node - <<'"'"'NODE'"'"' +const fs = require("node:fs"); + +const data = JSON.parse(fs.readFileSync("/tmp/plugins2.json", "utf8")); +const plugin = (data.plugins || []).find((entry) => entry.id === "demo-plugin-tgz"); +if (!plugin) throw new Error("tgz plugin not found"); +if (plugin.status !== "loaded") { + throw new Error(`unexpected status: ${plugin.status}`); +} +if (!Array.isArray(plugin.gatewayMethods) || !plugin.gatewayMethods.includes("demo.tgz")) { + throw new Error("expected gateway method demo.tgz"); +} console.log("ok"); NODE ' diff --git a/src/config/schema.test.ts b/src/config/schema.test.ts index 782ea2b0e..d0f9d6261 100644 --- a/src/config/schema.test.ts +++ b/src/config/schema.test.ts @@ -13,4 +13,32 @@ describe("config schema", () => { expect(res.version).toBeTruthy(); expect(res.generatedAt).toBeTruthy(); }); + + it("merges plugin ui hints", () => { + const res = buildConfigSchema({ + plugins: [ + { + id: "voice-call", + name: "Voice Call", + description: "Outbound voice calls", + configUiHints: { + provider: { label: "Provider" }, + "twilio.authToken": { label: "Auth Token", sensitive: true }, + }, + }, + ], + }); + + expect(res.uiHints["plugins.entries.voice-call"]?.label).toBe("Voice Call"); + expect(res.uiHints["plugins.entries.voice-call.config"]?.label).toBe( + "Voice Call Config", + ); + expect( + res.uiHints["plugins.entries.voice-call.config.twilio.authToken"]?.label, + ).toBe("Auth Token"); + expect( + res.uiHints["plugins.entries.voice-call.config.twilio.authToken"] + ?.sensitive, + ).toBe(true); + }); }); diff --git a/src/plugins/discovery.test.ts b/src/plugins/discovery.test.ts index 4742e296a..aa6e536c8 100644 --- a/src/plugins/discovery.test.ts +++ b/src/plugins/discovery.test.ts @@ -103,4 +103,32 @@ describe("discoverClawdbotPlugins", () => { expect(ids).toContain("pack/one"); expect(ids).toContain("pack/two"); }); + + it("derives unscoped ids for scoped packages", async () => { + const stateDir = makeTempDir(); + const globalExt = path.join(stateDir, "extensions", "voice-call-pack"); + fs.mkdirSync(path.join(globalExt, "src"), { recursive: true }); + + fs.writeFileSync( + path.join(globalExt, "package.json"), + JSON.stringify({ + name: "@clawdbot/voice-call", + clawdbot: { extensions: ["./src/index.ts"] }, + }), + "utf-8", + ); + fs.writeFileSync( + path.join(globalExt, "src", "index.ts"), + "export default function () {}", + "utf-8", + ); + + const { candidates } = await withStateDir(stateDir, async () => { + const { discoverClawdbotPlugins } = await import("./discovery.js"); + return discoverClawdbotPlugins({}); + }); + + const ids = candidates.map((c) => c.idHint); + expect(ids).toContain("voice-call"); + }); }); diff --git a/src/plugins/install.test.ts b/src/plugins/install.test.ts new file mode 100644 index 000000000..60f12d411 --- /dev/null +++ b/src/plugins/install.test.ts @@ -0,0 +1,162 @@ +import { spawnSync } from "node:child_process"; +import { randomUUID } from "node:crypto"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +const tempDirs: string[] = []; + +function makeTempDir() { + const dir = path.join(os.tmpdir(), `clawdbot-plugin-install-${randomUUID()}`); + fs.mkdirSync(dir, { recursive: true }); + tempDirs.push(dir); + return dir; +} + +async function withStateDir(stateDir: string, fn: () => Promise) { + const prev = process.env.CLAWDBOT_STATE_DIR; + process.env.CLAWDBOT_STATE_DIR = stateDir; + vi.resetModules(); + try { + return await fn(); + } finally { + if (prev === undefined) { + delete process.env.CLAWDBOT_STATE_DIR; + } else { + process.env.CLAWDBOT_STATE_DIR = prev; + } + vi.resetModules(); + } +} + +afterEach(() => { + for (const dir of tempDirs.splice(0)) { + try { + fs.rmSync(dir, { recursive: true, force: true }); + } catch { + // ignore cleanup failures + } + } +}); + +describe("installPluginFromArchive", () => { + it("installs into ~/.clawdbot/extensions and uses unscoped id", async () => { + const stateDir = makeTempDir(); + const workDir = makeTempDir(); + const pkgDir = path.join(workDir, "package"); + fs.mkdirSync(path.join(pkgDir, "dist"), { recursive: true }); + fs.writeFileSync( + path.join(pkgDir, "package.json"), + JSON.stringify({ + name: "@clawdbot/voice-call", + version: "0.0.1", + clawdbot: { extensions: ["./dist/index.js"] }, + }), + "utf-8", + ); + fs.writeFileSync( + path.join(pkgDir, "dist", "index.js"), + "export {};", + "utf-8", + ); + + const archivePath = path.join(workDir, "plugin.tgz"); + const tar = spawnSync( + "tar", + ["-czf", archivePath, "-C", workDir, "package"], + { + encoding: "utf-8", + }, + ); + expect(tar.status).toBe(0); + + const result = await withStateDir(stateDir, async () => { + const { installPluginFromArchive } = await import("./install.js"); + return await installPluginFromArchive({ archivePath }); + }); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.pluginId).toBe("voice-call"); + expect(result.targetDir).toBe( + path.join(stateDir, "extensions", "voice-call"), + ); + expect(fs.existsSync(path.join(result.targetDir, "package.json"))).toBe( + true, + ); + expect(fs.existsSync(path.join(result.targetDir, "dist", "index.js"))).toBe( + true, + ); + }); + + it("rejects installing when plugin already exists", async () => { + const stateDir = makeTempDir(); + const workDir = makeTempDir(); + const pkgDir = path.join(workDir, "package"); + fs.mkdirSync(path.join(pkgDir, "dist"), { recursive: true }); + fs.writeFileSync( + path.join(pkgDir, "package.json"), + JSON.stringify({ + name: "@clawdbot/voice-call", + version: "0.0.1", + clawdbot: { extensions: ["./dist/index.js"] }, + }), + "utf-8", + ); + fs.writeFileSync( + path.join(pkgDir, "dist", "index.js"), + "export {};", + "utf-8", + ); + + const archivePath = path.join(workDir, "plugin.tgz"); + const tar = spawnSync( + "tar", + ["-czf", archivePath, "-C", workDir, "package"], + { encoding: "utf-8" }, + ); + expect(tar.status).toBe(0); + + const { first, second } = await withStateDir(stateDir, async () => { + const { installPluginFromArchive } = await import("./install.js"); + const first = await installPluginFromArchive({ archivePath }); + const second = await installPluginFromArchive({ archivePath }); + return { first, second }; + }); + + expect(first.ok).toBe(true); + expect(second.ok).toBe(false); + if (second.ok) return; + expect(second.error).toContain("already exists"); + }); + + it("rejects packages without clawdbot.extensions", async () => { + const stateDir = makeTempDir(); + const workDir = makeTempDir(); + const pkgDir = path.join(workDir, "package"); + fs.mkdirSync(pkgDir, { recursive: true }); + fs.writeFileSync( + path.join(pkgDir, "package.json"), + JSON.stringify({ name: "@clawdbot/nope", version: "0.0.1" }), + "utf-8", + ); + + const archivePath = path.join(workDir, "bad.tgz"); + const tar = spawnSync( + "tar", + ["-czf", archivePath, "-C", workDir, "package"], + { + encoding: "utf-8", + }, + ); + expect(tar.status).toBe(0); + + const result = await withStateDir(stateDir, async () => { + const { installPluginFromArchive } = await import("./install.js"); + return await installPluginFromArchive({ archivePath }); + }); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error).toContain("clawdbot.extensions"); + }); +});