diff --git a/src/plugins/bundle-claude-inspect.test.ts b/src/plugins/bundle-claude-inspect.test.ts index 073b4159c25..e51254dcf4b 100644 --- a/src/plugins/bundle-claude-inspect.test.ts +++ b/src/plugins/bundle-claude-inspect.test.ts @@ -14,6 +14,71 @@ import { inspectBundleMcpRuntimeSupport } from "./bundle-mcp.js"; describe("Claude bundle plugin inspect integration", () => { let rootDir: string; + function writeFixtureText(relativePath: string, value: string) { + fs.mkdirSync(path.dirname(path.join(rootDir, relativePath)), { recursive: true }); + fs.writeFileSync(path.join(rootDir, relativePath), value, "utf-8"); + } + + function writeFixtureJson(relativePath: string, value: unknown) { + writeFixtureText(relativePath, JSON.stringify(value)); + } + + function setupClaudeInspectFixture() { + for (const relativeDir of [ + ".claude-plugin", + "skill-packs/demo", + "extra-commands/cmd", + "hooks", + "custom-hooks", + "agents", + "output-styles", + ]) { + fs.mkdirSync(path.join(rootDir, relativeDir), { recursive: true }); + } + + writeFixtureJson(".claude-plugin/plugin.json", { + name: "Test Claude Plugin", + description: "Integration test fixture for Claude bundle inspection", + version: "1.0.0", + skills: ["skill-packs"], + commands: "extra-commands", + agents: "agents", + hooks: "custom-hooks", + mcpServers: ".mcp.json", + lspServers: ".lsp.json", + outputStyles: "output-styles", + }); + writeFixtureText( + "skill-packs/demo/SKILL.md", + "---\nname: demo\ndescription: A demo skill\n---\nDo something useful.", + ); + writeFixtureText( + "extra-commands/cmd/SKILL.md", + "---\nname: cmd\ndescription: A command skill\n---\nRun a command.", + ); + writeFixtureText("hooks/hooks.json", '{"hooks":[]}'); + writeFixtureJson(".mcp.json", { + mcpServers: { + "test-stdio-server": { + command: "echo", + args: ["hello"], + }, + "test-sse-server": { + url: "http://localhost:3000/sse", + }, + }, + }); + writeFixtureJson("settings.json", { thinkingLevel: "high" }); + writeFixtureJson(".lsp.json", { + lspServers: { + "typescript-lsp": { + command: "typescript-language-server", + args: ["--stdio"], + }, + }, + }); + } + function expectLoadedClaudeManifest() { const result = loadBundleManifest({ rootDir, bundleFormat: "claude" }); expect(result.ok).toBe(true); @@ -38,96 +103,7 @@ describe("Claude bundle plugin inspect integration", () => { beforeAll(() => { rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-claude-bundle-")); - - // .claude-plugin/plugin.json - const manifestDir = path.join(rootDir, ".claude-plugin"); - fs.mkdirSync(manifestDir, { recursive: true }); - fs.writeFileSync( - path.join(manifestDir, "plugin.json"), - JSON.stringify({ - name: "Test Claude Plugin", - description: "Integration test fixture for Claude bundle inspection", - version: "1.0.0", - skills: ["skill-packs"], - commands: "extra-commands", - agents: "agents", - hooks: "custom-hooks", - mcpServers: ".mcp.json", - lspServers: ".lsp.json", - outputStyles: "output-styles", - }), - "utf-8", - ); - - // skills/demo/SKILL.md - const skillDir = path.join(rootDir, "skill-packs", "demo"); - fs.mkdirSync(skillDir, { recursive: true }); - fs.writeFileSync( - path.join(skillDir, "SKILL.md"), - "---\nname: demo\ndescription: A demo skill\n---\nDo something useful.", - "utf-8", - ); - - // commands/cmd/SKILL.md - const cmdDir = path.join(rootDir, "extra-commands", "cmd"); - fs.mkdirSync(cmdDir, { recursive: true }); - fs.writeFileSync( - path.join(cmdDir, "SKILL.md"), - "---\nname: cmd\ndescription: A command skill\n---\nRun a command.", - "utf-8", - ); - - // hooks/hooks.json (default hook path) - const hooksDir = path.join(rootDir, "hooks"); - fs.mkdirSync(hooksDir, { recursive: true }); - fs.writeFileSync(path.join(hooksDir, "hooks.json"), '{"hooks":[]}', "utf-8"); - - // custom-hooks/ (manifest-declared hook path) - fs.mkdirSync(path.join(rootDir, "custom-hooks"), { recursive: true }); - - // .mcp.json with a stdio MCP server - fs.writeFileSync( - path.join(rootDir, ".mcp.json"), - JSON.stringify({ - mcpServers: { - "test-stdio-server": { - command: "echo", - args: ["hello"], - }, - "test-sse-server": { - url: "http://localhost:3000/sse", - }, - }, - }), - "utf-8", - ); - - // settings.json - fs.writeFileSync( - path.join(rootDir, "settings.json"), - JSON.stringify({ thinkingLevel: "high" }), - "utf-8", - ); - - // agents/ directory - fs.mkdirSync(path.join(rootDir, "agents"), { recursive: true }); - - // .lsp.json with a stdio LSP server - fs.writeFileSync( - path.join(rootDir, ".lsp.json"), - JSON.stringify({ - lspServers: { - "typescript-lsp": { - command: "typescript-language-server", - args: ["--stdio"], - }, - }, - }), - "utf-8", - ); - - // output-styles/ directory - fs.mkdirSync(path.join(rootDir, "output-styles"), { recursive: true }); + setupClaudeInspectFixture(); }); afterAll(() => { diff --git a/src/plugins/bundle-commands.test.ts b/src/plugins/bundle-commands.test.ts index bb571e753fa..829dd326572 100644 --- a/src/plugins/bundle-commands.test.ts +++ b/src/plugins/bundle-commands.test.ts @@ -1,9 +1,8 @@ import fs from "node:fs/promises"; import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; -import { captureEnv } from "../test-utils/env.js"; import { loadEnabledClaudeBundleCommands } from "./bundle-commands.js"; -import { createBundleMcpTempHarness } from "./bundle-mcp.test-support.js"; +import { createBundleMcpTempHarness, withBundleHomeEnv } from "./bundle-mcp.test-support.js"; const tempHarness = createBundleMcpTempHarness(); @@ -11,24 +10,6 @@ afterEach(async () => { await tempHarness.cleanup(); }); -async function withBundleHomeEnv( - prefix: string, - run: (params: { homeDir: string; workspaceDir: string }) => Promise, -): Promise { - const env = captureEnv(["HOME", "USERPROFILE", "OPENCLAW_HOME", "OPENCLAW_STATE_DIR"]); - try { - const homeDir = await tempHarness.createTempDir(`${prefix}-home-`); - const workspaceDir = await tempHarness.createTempDir(`${prefix}-workspace-`); - process.env.HOME = homeDir; - process.env.USERPROFILE = homeDir; - delete process.env.OPENCLAW_HOME; - delete process.env.OPENCLAW_STATE_DIR; - return await run({ homeDir, workspaceDir }); - } finally { - env.restore(); - } -} - async function writeClaudeBundleCommandFixture(params: { homeDir: string; pluginId: string; @@ -57,65 +38,69 @@ async function writeClaudeBundleCommandFixture(params: { describe("loadEnabledClaudeBundleCommands", () => { it("loads enabled Claude bundle markdown commands and skips disabled-model-invocation entries", async () => { - await withBundleHomeEnv("openclaw-bundle-commands", async ({ homeDir, workspaceDir }) => { - await writeClaudeBundleCommandFixture({ - homeDir, - pluginId: "compound-bundle", - commands: [ - { - relativePath: "commands/office-hours.md", - contents: [ - "---", - "description: Help with scoping and architecture", - "---", - "Give direct engineering advice.", - ], - }, - { - relativePath: "commands/workflows/review.md", - contents: [ - "---", - "name: workflows:review", - "description: Run a structured review", - "---", - "Review the code. $ARGUMENTS", - ], - }, - { - relativePath: "commands/disabled.md", - contents: ["---", "disable-model-invocation: true", "---", "Do not load me."], - }, - ], - }); + await withBundleHomeEnv( + tempHarness, + "openclaw-bundle-commands", + async ({ homeDir, workspaceDir }) => { + await writeClaudeBundleCommandFixture({ + homeDir, + pluginId: "compound-bundle", + commands: [ + { + relativePath: "commands/office-hours.md", + contents: [ + "---", + "description: Help with scoping and architecture", + "---", + "Give direct engineering advice.", + ], + }, + { + relativePath: "commands/workflows/review.md", + contents: [ + "---", + "name: workflows:review", + "description: Run a structured review", + "---", + "Review the code. $ARGUMENTS", + ], + }, + { + relativePath: "commands/disabled.md", + contents: ["---", "disable-model-invocation: true", "---", "Do not load me."], + }, + ], + }); - const commands = loadEnabledClaudeBundleCommands({ - workspaceDir, - cfg: { - plugins: { - entries: { - "compound-bundle": { enabled: true }, + const commands = loadEnabledClaudeBundleCommands({ + workspaceDir, + cfg: { + plugins: { + entries: { + "compound-bundle": { enabled: true }, + }, }, }, - }, - }); + }); - expect(commands).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - pluginId: "compound-bundle", - rawName: "office-hours", - description: "Help with scoping and architecture", - promptTemplate: "Give direct engineering advice.", - }), - expect.objectContaining({ - pluginId: "compound-bundle", - rawName: "workflows:review", - description: "Run a structured review", - promptTemplate: "Review the code. $ARGUMENTS", - }), - ]), - ); - expect(commands.some((entry) => entry.rawName === "disabled")).toBe(false); - }); + expect(commands).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + pluginId: "compound-bundle", + rawName: "office-hours", + description: "Help with scoping and architecture", + promptTemplate: "Give direct engineering advice.", + }), + expect.objectContaining({ + pluginId: "compound-bundle", + rawName: "workflows:review", + description: "Run a structured review", + promptTemplate: "Review the code. $ARGUMENTS", + }), + ]), + ); + expect(commands.some((entry) => entry.rawName === "disabled")).toBe(false); + }, + ); }); }); diff --git a/src/plugins/bundle-manifest.test.ts b/src/plugins/bundle-manifest.test.ts index 51f9bebbd10..5f0fcf6e7e9 100644 --- a/src/plugins/bundle-manifest.test.ts +++ b/src/plugins/bundle-manifest.test.ts @@ -45,30 +45,68 @@ function writeJsonFile(rootDir: string, relativePath: string, value: unknown) { fs.writeFileSync(path.join(rootDir, relativePath), JSON.stringify(value), "utf-8"); } +function writeTextFile(rootDir: string, relativePath: string, value: string) { + mkdirSafe(path.dirname(path.join(rootDir, relativePath))); + fs.writeFileSync(path.join(rootDir, relativePath), value, "utf-8"); +} + +function setupBundleFixture(params: { + rootDir: string; + dirs?: readonly string[]; + jsonFiles?: Readonly>; + textFiles?: Readonly>; + manifestRelativePath?: string; + manifest?: Record; +}) { + for (const relativeDir of params.dirs ?? []) { + mkdirSafe(path.join(params.rootDir, relativeDir)); + } + for (const [relativePath, value] of Object.entries(params.jsonFiles ?? {})) { + writeJsonFile(params.rootDir, relativePath, value); + } + for (const [relativePath, value] of Object.entries(params.textFiles ?? {})) { + writeTextFile(params.rootDir, relativePath, value); + } + if (params.manifestRelativePath && params.manifest) { + writeBundleManifest(params.rootDir, params.manifestRelativePath, params.manifest); + } +} + function setupClaudeHookFixture( rootDir: string, kind: "default-hooks" | "custom-hooks" | "no-hooks", ) { - mkdirSafe(path.join(rootDir, ".claude-plugin")); if (kind === "default-hooks") { - mkdirSafe(path.join(rootDir, "hooks")); - writeJsonFile(rootDir, "hooks/hooks.json", { hooks: [] }); - writeBundleManifest(rootDir, CLAUDE_BUNDLE_MANIFEST_RELATIVE_PATH, { - name: "Hook Plugin", - description: "Claude hooks fixture", + setupBundleFixture({ + rootDir, + dirs: [".claude-plugin", "hooks"], + jsonFiles: { "hooks/hooks.json": { hooks: [] } }, + manifestRelativePath: CLAUDE_BUNDLE_MANIFEST_RELATIVE_PATH, + manifest: { + name: "Hook Plugin", + description: "Claude hooks fixture", + }, }); return; } if (kind === "custom-hooks") { - mkdirSafe(path.join(rootDir, "custom-hooks")); - writeBundleManifest(rootDir, CLAUDE_BUNDLE_MANIFEST_RELATIVE_PATH, { - name: "Custom Hook Plugin", - hooks: "custom-hooks", + setupBundleFixture({ + rootDir, + dirs: [".claude-plugin", "custom-hooks"], + manifestRelativePath: CLAUDE_BUNDLE_MANIFEST_RELATIVE_PATH, + manifest: { + name: "Custom Hook Plugin", + hooks: "custom-hooks", + }, }); return; } - mkdirSafe(path.join(rootDir, "skills")); - writeBundleManifest(rootDir, CLAUDE_BUNDLE_MANIFEST_RELATIVE_PATH, { name: "No Hooks" }); + setupBundleFixture({ + rootDir, + dirs: [".claude-plugin", "skills"], + manifestRelativePath: CLAUDE_BUNDLE_MANIFEST_RELATIVE_PATH, + manifest: { name: "No Hooks" }, + }); } afterEach(() => { @@ -81,23 +119,25 @@ describe("bundle manifest parsing", () => { name: "detects and loads Codex bundle manifests", bundleFormat: "codex" as const, setup: (rootDir: string) => { - mkdirSafe(path.join(rootDir, ".codex-plugin")); - mkdirSafe(path.join(rootDir, "skills")); - mkdirSafe(path.join(rootDir, "hooks")); - writeBundleManifest(rootDir, CODEX_BUNDLE_MANIFEST_RELATIVE_PATH, { - name: "Sample Bundle", - description: "Codex fixture", - skills: "skills", - hooks: "hooks", - mcpServers: { - sample: { - command: "node", - args: ["server.js"], + setupBundleFixture({ + rootDir, + dirs: [".codex-plugin", "skills", "hooks"], + manifestRelativePath: CODEX_BUNDLE_MANIFEST_RELATIVE_PATH, + manifest: { + name: "Sample Bundle", + description: "Codex fixture", + skills: "skills", + hooks: "hooks", + mcpServers: { + sample: { + command: "node", + args: ["server.js"], + }, }, - }, - apps: { - sample: { - title: "Sample App", + apps: { + sample: { + title: "Sample App", + }, }, }, }); @@ -116,35 +156,35 @@ describe("bundle manifest parsing", () => { name: "detects and loads Claude bundle manifests from the component layout", bundleFormat: "claude" as const, setup: (rootDir: string) => { - for (const relativeDir of [ - ".claude-plugin", - "skill-packs/starter", - "commands-pack", - "agents-pack", - "hooks-pack", - "mcp", - "lsp", - "styles", - "hooks", - ]) { - mkdirSafe(path.join(rootDir, relativeDir)); - } - fs.writeFileSync(path.join(rootDir, "hooks", "hooks.json"), '{"hooks":[]}', "utf-8"); - fs.writeFileSync( - path.join(rootDir, "settings.json"), - '{"hideThinkingBlock":true}', - "utf-8", - ); - writeBundleManifest(rootDir, CLAUDE_BUNDLE_MANIFEST_RELATIVE_PATH, { - name: "Claude Sample", - description: "Claude fixture", - skills: ["skill-packs/starter"], - commands: "commands-pack", - agents: "agents-pack", - hooks: "hooks-pack", - mcpServers: "mcp", - lspServers: "lsp", - outputStyles: "styles", + setupBundleFixture({ + rootDir, + dirs: [ + ".claude-plugin", + "skill-packs/starter", + "commands-pack", + "agents-pack", + "hooks-pack", + "mcp", + "lsp", + "styles", + "hooks", + ], + textFiles: { + "hooks/hooks.json": '{"hooks":[]}', + "settings.json": '{"hideThinkingBlock":true}', + }, + manifestRelativePath: CLAUDE_BUNDLE_MANIFEST_RELATIVE_PATH, + manifest: { + name: "Claude Sample", + description: "Claude fixture", + skills: ["skill-packs/starter"], + commands: "commands-pack", + agents: "agents-pack", + hooks: "hooks-pack", + mcpServers: "mcp", + lspServers: "lsp", + outputStyles: "styles", + }, }); }, expected: { @@ -171,22 +211,20 @@ describe("bundle manifest parsing", () => { name: "detects and loads Cursor bundle manifests", bundleFormat: "cursor" as const, setup: (rootDir: string) => { - for (const relativeDir of [ - ".cursor-plugin", - "skills", - ".cursor/commands", - ".cursor/rules", - ".cursor/agents", - ]) { - mkdirSafe(path.join(rootDir, relativeDir)); - } - fs.writeFileSync(path.join(rootDir, ".cursor", "hooks.json"), '{"hooks":[]}', "utf-8"); - writeBundleManifest(rootDir, CURSOR_BUNDLE_MANIFEST_RELATIVE_PATH, { - name: "Cursor Sample", - description: "Cursor fixture", - mcpServers: "./.mcp.json", + setupBundleFixture({ + rootDir, + dirs: [".cursor-plugin", "skills", ".cursor/commands", ".cursor/rules", ".cursor/agents"], + textFiles: { + ".cursor/hooks.json": '{"hooks":[]}', + ".mcp.json": '{"servers":{}}', + }, + manifestRelativePath: CURSOR_BUNDLE_MANIFEST_RELATIVE_PATH, + manifest: { + name: "Cursor Sample", + description: "Cursor fixture", + mcpServers: "./.mcp.json", + }, }); - fs.writeFileSync(path.join(rootDir, ".mcp.json"), '{"servers":{}}', "utf-8"); }, expected: { id: "cursor-sample", @@ -209,13 +247,13 @@ describe("bundle manifest parsing", () => { name: "detects manifestless Claude bundles from the default layout", bundleFormat: "claude" as const, setup: (rootDir: string) => { - mkdirSafe(path.join(rootDir, "commands")); - mkdirSafe(path.join(rootDir, "skills")); - fs.writeFileSync( - path.join(rootDir, "settings.json"), - '{"hideThinkingBlock":true}', - "utf-8", - ); + setupBundleFixture({ + rootDir, + dirs: ["commands", "skills"], + textFiles: { + "settings.json": '{"hideThinkingBlock":true}', + }, + }); }, expected: (rootDir: string) => ({ id: path.basename(rootDir).toLowerCase(), @@ -263,8 +301,11 @@ describe("bundle manifest parsing", () => { it("does not misclassify native index plugins as manifestless Claude bundles", () => { const rootDir = makeTempDir(); - mkdirSafe(path.join(rootDir, "commands")); - fs.writeFileSync(path.join(rootDir, "index.ts"), "export default {}", "utf-8"); + setupBundleFixture({ + rootDir, + dirs: ["commands"], + textFiles: { "index.ts": "export default {}" }, + }); expect(detectBundleManifestFormat(rootDir)).toBeNull(); }); diff --git a/src/plugins/bundle-mcp.test-support.ts b/src/plugins/bundle-mcp.test-support.ts index 009078f8f8a..892c2f0b5fa 100644 --- a/src/plugins/bundle-mcp.test-support.ts +++ b/src/plugins/bundle-mcp.test-support.ts @@ -1,6 +1,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; +import { captureEnv } from "../test-utils/env.js"; import { clearPluginDiscoveryCache } from "./discovery.js"; import { clearPluginManifestRegistryCache } from "./manifest-registry.js"; @@ -54,3 +55,22 @@ export async function createBundleProbePlugin(homeDir: string) { ); return { pluginRoot, serverPath }; } + +export async function withBundleHomeEnv( + tempHarness: { createTempDir: (prefix: string) => Promise }, + prefix: string, + run: (params: { homeDir: string; workspaceDir: string }) => Promise, +): Promise { + const env = captureEnv(["HOME", "USERPROFILE", "OPENCLAW_HOME", "OPENCLAW_STATE_DIR"]); + try { + const homeDir = await tempHarness.createTempDir(`${prefix}-home-`); + const workspaceDir = await tempHarness.createTempDir(`${prefix}-workspace-`); + process.env.HOME = homeDir; + process.env.USERPROFILE = homeDir; + delete process.env.OPENCLAW_HOME; + delete process.env.OPENCLAW_STATE_DIR; + return await run({ homeDir, workspaceDir }); + } finally { + env.restore(); + } +} diff --git a/src/plugins/bundle-mcp.test.ts b/src/plugins/bundle-mcp.test.ts index 207b9a98fc4..a9fcb7d9688 100644 --- a/src/plugins/bundle-mcp.test.ts +++ b/src/plugins/bundle-mcp.test.ts @@ -2,10 +2,13 @@ import fs from "node:fs/promises"; import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; -import { captureEnv } from "../test-utils/env.js"; import { isRecord } from "../utils.js"; import { loadEnabledBundleMcpConfig } from "./bundle-mcp.js"; -import { createBundleMcpTempHarness, createBundleProbePlugin } from "./bundle-mcp.test-support.js"; +import { + createBundleMcpTempHarness, + createBundleProbePlugin, + withBundleHomeEnv, +} from "./bundle-mcp.test-support.js"; function getServerArgs(value: unknown): unknown[] | undefined { return isRecord(value) && Array.isArray(value.args) ? value.args : undefined; @@ -38,24 +41,6 @@ afterEach(async () => { await tempHarness.cleanup(); }); -async function withBundleHomeEnv( - prefix: string, - run: (params: { homeDir: string; workspaceDir: string }) => Promise, -): Promise { - const env = captureEnv(["HOME", "USERPROFILE", "OPENCLAW_HOME", "OPENCLAW_STATE_DIR"]); - try { - const homeDir = await tempHarness.createTempDir(`${prefix}-home-`); - const workspaceDir = await tempHarness.createTempDir(`${prefix}-workspace-`); - process.env.HOME = homeDir; - process.env.USERPROFILE = homeDir; - delete process.env.OPENCLAW_HOME; - delete process.env.OPENCLAW_STATE_DIR; - return await run({ homeDir, workspaceDir }); - } finally { - env.restore(); - } -} - function createEnabledBundleConfig(pluginIds: string[]): OpenClawConfig { return { plugins: { @@ -81,89 +66,98 @@ async function writeInlineClaudeBundleManifest(params: { describe("loadEnabledBundleMcpConfig", () => { it("loads enabled Claude bundle MCP config and absolutizes relative args", async () => { - await withBundleHomeEnv("openclaw-bundle-mcp", async ({ homeDir, workspaceDir }) => { - const { pluginRoot, serverPath } = await createBundleProbePlugin(homeDir); + await withBundleHomeEnv( + tempHarness, + "openclaw-bundle-mcp", + async ({ homeDir, workspaceDir }) => { + const { pluginRoot, serverPath } = await createBundleProbePlugin(homeDir); - const config: OpenClawConfig = { - plugins: { - entries: { - "bundle-probe": { enabled: true }, + const config: OpenClawConfig = { + plugins: { + entries: { + "bundle-probe": { enabled: true }, + }, }, - }, - }; + }; - const loaded = loadEnabledBundleMcpConfig({ - workspaceDir, - cfg: config, - }); - const resolvedServerPath = await fs.realpath(serverPath); - const loadedServer = loaded.config.mcpServers.bundleProbe; - const loadedArgs = getServerArgs(loadedServer); - const loadedServerPath = typeof loadedArgs?.[0] === "string" ? loadedArgs[0] : undefined; - const resolvedPluginRoot = await fs.realpath(pluginRoot); + const loaded = loadEnabledBundleMcpConfig({ + workspaceDir, + cfg: config, + }); + const resolvedServerPath = await fs.realpath(serverPath); + const loadedServer = loaded.config.mcpServers.bundleProbe; + const loadedArgs = getServerArgs(loadedServer); + const loadedServerPath = typeof loadedArgs?.[0] === "string" ? loadedArgs[0] : undefined; + const resolvedPluginRoot = await fs.realpath(pluginRoot); - expectNoDiagnostics(loaded.diagnostics); - expect(isRecord(loadedServer) ? loadedServer.command : undefined).toBe("node"); - expect(loadedArgs).toHaveLength(1); - expect(loadedServerPath).toBeDefined(); - if (!loadedServerPath) { - throw new Error("expected bundled MCP args to include the server path"); - } - expect(normalizePathForAssertion(await fs.realpath(loadedServerPath))).toBe( - normalizePathForAssertion(resolvedServerPath), - ); - await expectResolvedPathEqual(loadedServer.cwd, resolvedPluginRoot); - }); + expectNoDiagnostics(loaded.diagnostics); + expect(isRecord(loadedServer) ? loadedServer.command : undefined).toBe("node"); + expect(loadedArgs).toHaveLength(1); + expect(loadedServerPath).toBeDefined(); + if (!loadedServerPath) { + throw new Error("expected bundled MCP args to include the server path"); + } + expect(normalizePathForAssertion(await fs.realpath(loadedServerPath))).toBe( + normalizePathForAssertion(resolvedServerPath), + ); + await expectResolvedPathEqual(loadedServer.cwd, resolvedPluginRoot); + }, + ); }); it("merges inline bundle MCP servers and skips disabled bundles", async () => { - await withBundleHomeEnv("openclaw-bundle-inline", async ({ homeDir, workspaceDir }) => { - await writeInlineClaudeBundleManifest({ - homeDir, - pluginId: "inline-enabled", - manifest: { - name: "inline-enabled", - mcpServers: { - enabledProbe: { - command: "node", - args: ["./enabled.mjs"], + await withBundleHomeEnv( + tempHarness, + "openclaw-bundle-inline", + async ({ homeDir, workspaceDir }) => { + await writeInlineClaudeBundleManifest({ + homeDir, + pluginId: "inline-enabled", + manifest: { + name: "inline-enabled", + mcpServers: { + enabledProbe: { + command: "node", + args: ["./enabled.mjs"], + }, }, }, - }, - }); - await writeInlineClaudeBundleManifest({ - homeDir, - pluginId: "inline-disabled", - manifest: { - name: "inline-disabled", - mcpServers: { - disabledProbe: { - command: "node", - args: ["./disabled.mjs"], + }); + await writeInlineClaudeBundleManifest({ + homeDir, + pluginId: "inline-disabled", + manifest: { + name: "inline-disabled", + mcpServers: { + disabledProbe: { + command: "node", + args: ["./disabled.mjs"], + }, }, }, - }, - }); + }); - const loaded = loadEnabledBundleMcpConfig({ - workspaceDir, - cfg: { - plugins: { - entries: { - ...createEnabledBundleConfig(["inline-enabled"]).plugins?.entries, - "inline-disabled": { enabled: false }, + const loaded = loadEnabledBundleMcpConfig({ + workspaceDir, + cfg: { + plugins: { + entries: { + ...createEnabledBundleConfig(["inline-enabled"]).plugins?.entries, + "inline-disabled": { enabled: false }, + }, }, }, - }, - }); + }); - expect(loaded.config.mcpServers.enabledProbe).toBeDefined(); - expect(loaded.config.mcpServers.disabledProbe).toBeUndefined(); - }); + expect(loaded.config.mcpServers.enabledProbe).toBeDefined(); + expect(loaded.config.mcpServers.disabledProbe).toBeUndefined(); + }, + ); }); it("resolves inline Claude MCP paths from the plugin root and expands CLAUDE_PLUGIN_ROOT", async () => { await withBundleHomeEnv( + tempHarness, "openclaw-bundle-inline-placeholder", async ({ homeDir, workspaceDir }) => { const pluginRoot = await writeInlineClaudeBundleManifest({ diff --git a/src/plugins/bundled-dir.test.ts b/src/plugins/bundled-dir.test.ts index 26aa20fdc4a..4f5a73aab51 100644 --- a/src/plugins/bundled-dir.test.ts +++ b/src/plugins/bundled-dir.test.ts @@ -72,6 +72,23 @@ function expectResolvedBundledDir(params: { ); } +function expectResolvedBundledDirFromRoot(params: { + repoRoot: string; + expectedRelativeDir: string; + argv1?: string; + bundledDirOverride?: string; + vitest?: string; + cwd?: string; +}) { + expectResolvedBundledDir({ + cwd: params.cwd ?? params.repoRoot, + expectedDir: path.join(params.repoRoot, params.expectedRelativeDir), + ...(params.argv1 ? { argv1: params.argv1 } : {}), + ...(params.bundledDirOverride ? { bundledDirOverride: params.bundledDirOverride } : {}), + ...(params.vitest !== undefined ? { vitest: params.vitest } : {}), + }); +} + afterEach(() => { vi.restoreAllMocks(); if (originalBundledDir === undefined) { @@ -142,10 +159,10 @@ describe("resolveBundledPluginsDir", () => { ], ] as const)("%s", (_name, layout, expectation) => { const repoRoot = createOpenClawRoot(layout); - expectResolvedBundledDir({ - cwd: repoRoot, - expectedDir: path.join(repoRoot, expectation.expectedRelativeDir), - vitest: "vitest" in expectation ? expectation.vitest : undefined, + expectResolvedBundledDirFromRoot({ + repoRoot, + expectedRelativeDir: expectation.expectedRelativeDir, + ...("vitest" in expectation ? { vitest: expectation.vitest } : {}), }); }); @@ -161,10 +178,11 @@ describe("resolveBundledPluginsDir", () => { hasGitCheckout: true, }); - expectResolvedBundledDir({ + expectResolvedBundledDirFromRoot({ + repoRoot: installedRoot, cwd: cwdRepoRoot, argv1: path.join(installedRoot, "openclaw.mjs"), - expectedDir: path.join(installedRoot, "dist", "extensions"), + expectedRelativeDir: path.join("dist", "extensions"), }); }); @@ -174,11 +192,12 @@ describe("resolveBundledPluginsDir", () => { hasDistExtensions: true, }); - expectResolvedBundledDir({ + expectResolvedBundledDirFromRoot({ + repoRoot: installedRoot, cwd: process.cwd(), argv1: path.join(installedRoot, "openclaw.mjs"), bundledDirOverride: path.join(installedRoot, "missing-extensions"), - expectedDir: path.join(installedRoot, "dist", "extensions"), + expectedRelativeDir: path.join("dist", "extensions"), }); }); }); diff --git a/src/plugins/bundled-sources.test.ts b/src/plugins/bundled-sources.test.ts index b6151101bba..8b3a3a78c92 100644 --- a/src/plugins/bundled-sources.test.ts +++ b/src/plugins/bundled-sources.test.ts @@ -53,6 +53,23 @@ function setBundledManifestIdsByRoot(manifestIds: Record) { ); } +function setBundledLookupFixture() { + setBundledDiscoveryCandidates([ + createBundledCandidate({ + rootDir: "/app/extensions/feishu", + packageName: "@openclaw/feishu", + }), + createBundledCandidate({ + rootDir: "/app/extensions/diffs", + packageName: "@openclaw/diffs", + }), + ]); + setBundledManifestIdsByRoot({ + "/app/extensions/feishu": "feishu", + "/app/extensions/diffs": "diffs", + }); +} + function expectBundledSourceLookup( lookup: Parameters[0]["lookup"], expected: @@ -134,28 +151,12 @@ describe("bundled plugin sources", () => { undefined, ], ] as const)("%s", (_name, lookup, expected) => { - setBundledDiscoveryCandidates([ - createBundledCandidate({ - rootDir: "/app/extensions/feishu", - packageName: "@openclaw/feishu", - }), - createBundledCandidate({ - rootDir: "/app/extensions/diffs", - packageName: "@openclaw/diffs", - }), - ]); - setBundledManifestIdsByRoot({ - "/app/extensions/feishu": "feishu", - "/app/extensions/diffs": "diffs", - }); + setBundledLookupFixture(); expectBundledSourceLookup(lookup, expected); }); it("forwards an explicit env to bundled discovery helpers", () => { - discoverOpenClawPluginsMock.mockReturnValue({ - candidates: [], - diagnostics: [], - }); + setBundledDiscoveryCandidates([]); const env = { HOME: "/tmp/openclaw-home" } as NodeJS.ProcessEnv; diff --git a/src/plugins/clawhub.test.ts b/src/plugins/clawhub.test.ts index dfcc8c0fbfa..88873e2f487 100644 --- a/src/plugins/clawhub.test.ts +++ b/src/plugins/clawhub.test.ts @@ -50,6 +50,52 @@ async function expectClawHubInstallError(params: { ); } +function createLoggerSpies() { + return { + info: vi.fn(), + warn: vi.fn(), + }; +} + +function expectClawHubInstallFlow(params: { + baseUrl: string; + version: string; + archivePath: string; +}) { + expect(fetchClawHubPackageDetailMock).toHaveBeenCalledWith( + expect.objectContaining({ + name: "demo", + baseUrl: params.baseUrl, + }), + ); + expect(fetchClawHubPackageVersionMock).toHaveBeenCalledWith( + expect.objectContaining({ + name: "demo", + version: params.version, + }), + ); + expect(installPluginFromArchiveMock).toHaveBeenCalledWith( + expect.objectContaining({ + archivePath: params.archivePath, + }), + ); +} + +function expectSuccessfulClawHubInstall(result: unknown) { + expect(result).toMatchObject({ + ok: true, + pluginId: "demo", + version: "2026.3.22", + clawhub: { + source: "clawhub", + clawhubPackage: "demo", + clawhubFamily: "code-plugin", + clawhubChannel: "official", + integrity: "sha256-demo", + }, + }); +} + describe("installPluginFromClawHub", () => { beforeEach(() => { parseClawHubPluginSpecMock.mockReset(); @@ -107,46 +153,24 @@ describe("installPluginFromClawHub", () => { }); it("installs a ClawHub code plugin through the archive installer", async () => { - const info = vi.fn(); - const warn = vi.fn(); + const logger = createLoggerSpies(); const result = await installPluginFromClawHub({ spec: "clawhub:demo", baseUrl: "https://clawhub.ai", - logger: { info, warn }, + logger, }); - expect(fetchClawHubPackageDetailMock).toHaveBeenCalledWith( - expect.objectContaining({ - name: "demo", - baseUrl: "https://clawhub.ai", - }), - ); - expect(fetchClawHubPackageVersionMock).toHaveBeenCalledWith( - expect.objectContaining({ - name: "demo", - version: "2026.3.22", - }), - ); - expect(installPluginFromArchiveMock).toHaveBeenCalledWith( - expect.objectContaining({ - archivePath: "/tmp/clawhub-demo/archive.zip", - }), - ); - expect(result).toMatchObject({ - ok: true, - pluginId: "demo", + expectClawHubInstallFlow({ + baseUrl: "https://clawhub.ai", version: "2026.3.22", - clawhub: { - source: "clawhub", - clawhubPackage: "demo", - clawhubFamily: "code-plugin", - clawhubChannel: "official", - integrity: "sha256-demo", - }, + archivePath: "/tmp/clawhub-demo/archive.zip", }); - expect(info).toHaveBeenCalledWith("ClawHub code-plugin demo@2026.3.22 channel=official"); - expect(info).toHaveBeenCalledWith("Compatibility: pluginApi=>=2026.3.22 minGateway=2026.3.0"); - expect(warn).not.toHaveBeenCalled(); + expectSuccessfulClawHubInstall(result); + expect(logger.info).toHaveBeenCalledWith("ClawHub code-plugin demo@2026.3.22 channel=official"); + expect(logger.info).toHaveBeenCalledWith( + "Compatibility: pluginApi=>=2026.3.22 minGateway=2026.3.0", + ); + expect(logger.warn).not.toHaveBeenCalled(); }); it.each([ diff --git a/src/plugins/config-schema.test.ts b/src/plugins/config-schema.test.ts index a1d9d6740a3..268ac18ef0e 100644 --- a/src/plugins/config-schema.test.ts +++ b/src/plugins/config-schema.test.ts @@ -7,9 +7,7 @@ function expectSafeParseCases( cases: ReadonlyArray, ) { expect(safeParse).toBeDefined(); - for (const [value, expected] of cases) { - expect(safeParse?.(value)).toEqual(expected); - } + expect(cases.map(([value]) => safeParse?.(value))).toEqual(cases.map(([, expected]) => expected)); } describe("buildPluginConfigSchema", () => { diff --git a/src/plugins/discovery.test.ts b/src/plugins/discovery.test.ts index c55e15cb550..bdc89bed81e 100644 --- a/src/plugins/discovery.test.ts +++ b/src/plugins/discovery.test.ts @@ -172,6 +172,42 @@ function expectEscapesPackageDiagnostic(diagnostics: Array<{ message: string }>) ); } +function expectCandidatePresence( + result: Awaited>, + params: { present?: readonly string[]; absent?: readonly string[] }, +) { + const ids = result.candidates.map((candidate) => candidate.idHint); + params.present?.forEach((pluginId) => { + expect(ids).toContain(pluginId); + }); + params.absent?.forEach((pluginId) => { + expect(ids).not.toContain(pluginId); + }); +} + +async function expectRejectedPackageExtensionEntry(params: { + stateDir: string; + setup: (stateDir: string) => boolean | void; + expectedDiagnostic?: "escapes" | "none"; + expectedId?: string; +}) { + if (params.setup(params.stateDir) === false) { + return; + } + const result = await discoverWithStateDir(params.stateDir, {}); + + if (params.expectedId) { + expectCandidatePresence(result, { absent: [params.expectedId] }); + } else { + expect(result.candidates).toHaveLength(0); + } + if (params.expectedDiagnostic === "escapes") { + expectEscapesPackageDiagnostic(result.diagnostics); + return; + } + expect(result.diagnostics).toEqual([]); +} + afterEach(() => { clearPluginDiscoveryCache(); cleanupTrackedTempDirs(tempDirs); @@ -475,99 +511,96 @@ describe("discoverOpenClawPlugins", () => { expect(hasDiagnosticSourceSuffix(result.diagnostics, bundleMarker)).toBe(true); }); - it("blocks extension entries that escape package directory", async () => { + it.each([ + { + name: "blocks extension entries that escape package directory", + expectedDiagnostic: "escapes" as const, + setup: (stateDir: string) => { + const globalExt = path.join(stateDir, "extensions", "escape-pack"); + const outside = path.join(stateDir, "outside.js"); + mkdirSafe(globalExt); + writePluginPackageManifest({ + packageDir: globalExt, + packageName: "@openclaw/escape-pack", + extensions: ["../../outside.js"], + }); + fs.writeFileSync(outside, "export default function () {}", "utf-8"); + }, + }, + { + name: "skips missing package extension entries without escape diagnostics", + expectedDiagnostic: "none" as const, + setup: (stateDir: string) => { + const globalExt = path.join(stateDir, "extensions", "missing-entry-pack"); + mkdirSafe(globalExt); + writePluginPackageManifest({ + packageDir: globalExt, + packageName: "@openclaw/missing-entry-pack", + extensions: ["./missing.ts"], + }); + }, + }, + { + name: "rejects package extension entries that escape via symlink", + expectedDiagnostic: "escapes" as const, + expectedId: "pack", + setup: (stateDir: string) => { + const globalExt = path.join(stateDir, "extensions", "pack"); + const outsideDir = path.join(stateDir, "outside"); + const linkedDir = path.join(globalExt, "linked"); + mkdirSafe(globalExt); + mkdirSafe(outsideDir); + fs.writeFileSync(path.join(outsideDir, "escape.ts"), "export default {}", "utf-8"); + try { + fs.symlinkSync(outsideDir, linkedDir, process.platform === "win32" ? "junction" : "dir"); + } catch { + return false; + } + writePluginPackageManifest({ + packageDir: globalExt, + packageName: "@openclaw/pack", + extensions: ["./linked/escape.ts"], + }); + }, + }, + { + name: "rejects package extension entries that are hardlinked aliases", + expectedDiagnostic: "escapes" as const, + expectedId: "pack", + setup: (stateDir: string) => { + if (process.platform === "win32") { + return false; + } + const globalExt = path.join(stateDir, "extensions", "pack"); + const outsideDir = path.join(stateDir, "outside"); + const outsideFile = path.join(outsideDir, "escape.ts"); + const linkedFile = path.join(globalExt, "escape.ts"); + mkdirSafe(globalExt); + mkdirSafe(outsideDir); + fs.writeFileSync(outsideFile, "export default {}", "utf-8"); + try { + fs.linkSync(outsideFile, linkedFile); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === "EXDEV") { + return false; + } + throw err; + } + writePluginPackageManifest({ + packageDir: globalExt, + packageName: "@openclaw/pack", + extensions: ["./escape.ts"], + }); + }, + }, + ] as const)("$name", async ({ setup, expectedDiagnostic, expectedId }) => { const stateDir = makeTempDir(); - const globalExt = path.join(stateDir, "extensions", "escape-pack"); - const outside = path.join(stateDir, "outside.js"); - mkdirSafe(globalExt); - - writePluginPackageManifest({ - packageDir: globalExt, - packageName: "@openclaw/escape-pack", - extensions: ["../../outside.js"], + await expectRejectedPackageExtensionEntry({ + stateDir, + setup, + expectedDiagnostic, + ...(expectedId ? { expectedId } : {}), }); - fs.writeFileSync(outside, "export default function () {}", "utf-8"); - - const result = await discoverWithStateDir(stateDir, {}); - - expect(result.candidates).toHaveLength(0); - expectEscapesPackageDiagnostic(result.diagnostics); - }); - - it("skips missing package extension entries without escape diagnostics", async () => { - const stateDir = makeTempDir(); - const globalExt = path.join(stateDir, "extensions", "missing-entry-pack"); - mkdirSafe(globalExt); - - writePluginPackageManifest({ - packageDir: globalExt, - packageName: "@openclaw/missing-entry-pack", - extensions: ["./missing.ts"], - }); - - const result = await discoverWithStateDir(stateDir, {}); - - expect(result.candidates).toHaveLength(0); - expect(result.diagnostics).toEqual([]); - }); - - it("rejects package extension entries that escape via symlink", async () => { - const stateDir = makeTempDir(); - const globalExt = path.join(stateDir, "extensions", "pack"); - const outsideDir = path.join(stateDir, "outside"); - const linkedDir = path.join(globalExt, "linked"); - mkdirSafe(globalExt); - mkdirSafe(outsideDir); - fs.writeFileSync(path.join(outsideDir, "escape.ts"), "export default {}", "utf-8"); - try { - fs.symlinkSync(outsideDir, linkedDir, process.platform === "win32" ? "junction" : "dir"); - } catch { - return; - } - - writePluginPackageManifest({ - packageDir: globalExt, - packageName: "@openclaw/pack", - extensions: ["./linked/escape.ts"], - }); - - const { candidates, diagnostics } = await discoverWithStateDir(stateDir, {}); - - expect(candidates.some((candidate) => candidate.idHint === "pack")).toBe(false); - expectEscapesPackageDiagnostic(diagnostics); - }); - - it("rejects package extension entries that are hardlinked aliases", async () => { - if (process.platform === "win32") { - return; - } - const stateDir = makeTempDir(); - const globalExt = path.join(stateDir, "extensions", "pack"); - const outsideDir = path.join(stateDir, "outside"); - const outsideFile = path.join(outsideDir, "escape.ts"); - const linkedFile = path.join(globalExt, "escape.ts"); - mkdirSafe(globalExt); - mkdirSafe(outsideDir); - fs.writeFileSync(outsideFile, "export default {}", "utf-8"); - try { - fs.linkSync(outsideFile, linkedFile); - } catch (err) { - if ((err as NodeJS.ErrnoException).code === "EXDEV") { - return; - } - throw err; - } - - writePluginPackageManifest({ - packageDir: globalExt, - packageName: "@openclaw/pack", - extensions: ["./escape.ts"], - }); - - const { candidates, diagnostics } = await discoverWithStateDir(stateDir, {}); - - expect(candidates.some((candidate) => candidate.idHint === "pack")).toBe(false); - expectEscapesPackageDiagnostic(diagnostics); }); it("ignores package manifests that are hardlinked aliases", async () => { @@ -692,41 +725,59 @@ describe("discoverOpenClawPlugins", () => { expect(third.candidates.some((candidate) => candidate.idHint === "cached")).toBe(false); }); - it("does not reuse discovery results across env root changes", () => { - const stateDirA = makeTempDir(); - const stateDirB = makeTempDir(); - writeStandalonePlugin(path.join(stateDirA, "extensions", "alpha.ts")); - writeStandalonePlugin(path.join(stateDirB, "extensions", "beta.ts")); - - const first = discoverWithCachedEnv({ env: buildCachedDiscoveryEnv(stateDirA) }); - const second = discoverWithCachedEnv({ env: buildCachedDiscoveryEnv(stateDirB) }); - - expect(first.candidates.some((candidate) => candidate.idHint === "alpha")).toBe(true); - expect(first.candidates.some((candidate) => candidate.idHint === "beta")).toBe(false); - expect(second.candidates.some((candidate) => candidate.idHint === "alpha")).toBe(false); - expect(second.candidates.some((candidate) => candidate.idHint === "beta")).toBe(true); - }); - - it("does not reuse extra-path discovery across env home changes", () => { - const stateDir = makeTempDir(); - const homeA = makeTempDir(); - const homeB = makeTempDir(); - const pluginA = path.join(homeA, "plugins", "demo.ts"); - const pluginB = path.join(homeB, "plugins", "demo.ts"); - writeStandalonePlugin(pluginA, "export default {}"); - writeStandalonePlugin(pluginB, "export default {}"); - - const first = discoverWithCachedEnv({ - extraPaths: ["~/plugins/demo.ts"], - env: buildCachedDiscoveryEnv(stateDir, { HOME: homeA }), - }); - const second = discoverWithCachedEnv({ - extraPaths: ["~/plugins/demo.ts"], - env: buildCachedDiscoveryEnv(stateDir, { HOME: homeB }), - }); - - expectCandidateSource(first.candidates, "demo", pluginA); - expectCandidateSource(second.candidates, "demo", pluginB); + it.each([ + { + name: "does not reuse discovery results across env root changes", + setup: () => { + const stateDirA = makeTempDir(); + const stateDirB = makeTempDir(); + writeStandalonePlugin(path.join(stateDirA, "extensions", "alpha.ts")); + writeStandalonePlugin(path.join(stateDirB, "extensions", "beta.ts")); + return { + first: discoverWithCachedEnv({ env: buildCachedDiscoveryEnv(stateDirA) }), + second: discoverWithCachedEnv({ env: buildCachedDiscoveryEnv(stateDirB) }), + assert: ( + first: ReturnType, + second: ReturnType, + ) => { + expectCandidatePresence(first, { present: ["alpha"], absent: ["beta"] }); + expectCandidatePresence(second, { present: ["beta"], absent: ["alpha"] }); + }, + }; + }, + }, + { + name: "does not reuse extra-path discovery across env home changes", + setup: () => { + const stateDir = makeTempDir(); + const homeA = makeTempDir(); + const homeB = makeTempDir(); + const pluginA = path.join(homeA, "plugins", "demo.ts"); + const pluginB = path.join(homeB, "plugins", "demo.ts"); + writeStandalonePlugin(pluginA, "export default {}"); + writeStandalonePlugin(pluginB, "export default {}"); + return { + first: discoverWithCachedEnv({ + extraPaths: ["~/plugins/demo.ts"], + env: buildCachedDiscoveryEnv(stateDir, { HOME: homeA }), + }), + second: discoverWithCachedEnv({ + extraPaths: ["~/plugins/demo.ts"], + env: buildCachedDiscoveryEnv(stateDir, { HOME: homeB }), + }), + assert: ( + first: ReturnType, + second: ReturnType, + ) => { + expectCandidateSource(first.candidates, "demo", pluginA); + expectCandidateSource(second.candidates, "demo", pluginB); + }, + }; + }, + }, + ] as const)("$name", ({ setup }) => { + const { first, second, assert } = setup(); + assert(first, second); }); it("treats configured load-path order as cache-significant", () => { diff --git a/src/plugins/marketplace.test.ts b/src/plugins/marketplace.test.ts index c1d386346b6..01cbb01d5f3 100644 --- a/src/plugins/marketplace.test.ts +++ b/src/plugins/marketplace.test.ts @@ -31,21 +31,36 @@ async function writeMarketplaceManifest(rootDir: string, manifest: unknown): Pro return manifestPath; } -function mockRemoteMarketplaceClone(manifest: unknown) { +async function writeRemoteMarketplaceFixture(params: { + repoDir: string; + manifest: unknown; + pluginDir?: string; +}) { + await fs.mkdir(path.join(params.repoDir, ".claude-plugin"), { recursive: true }); + if (params.pluginDir) { + await fs.mkdir(path.join(params.repoDir, params.pluginDir), { recursive: true }); + } + await fs.writeFile( + path.join(params.repoDir, ".claude-plugin", "marketplace.json"), + JSON.stringify(params.manifest), + ); +} + +function mockRemoteMarketplaceClone(params: { manifest: unknown; pluginDir?: string }) { runCommandWithTimeoutMock.mockImplementationOnce(async (argv: string[]) => { const repoDir = argv.at(-1); expect(typeof repoDir).toBe("string"); - await fs.mkdir(path.join(repoDir as string, ".claude-plugin"), { recursive: true }); - await fs.writeFile( - path.join(repoDir as string, ".claude-plugin", "marketplace.json"), - JSON.stringify(manifest), - ); + await writeRemoteMarketplaceFixture({ + repoDir: repoDir as string, + manifest: params.manifest, + ...(params.pluginDir ? { pluginDir: params.pluginDir } : {}), + }); return { code: 0, stdout: "", stderr: "", killed: false }; }); } async function expectRemoteMarketplaceError(params: { manifest: unknown; expectedError: string }) { - mockRemoteMarketplaceClone(params.manifest); + mockRemoteMarketplaceClone({ manifest: params.manifest }); const { listMarketplacePlugins } = await import("./marketplace.js"); const result = await listMarketplacePlugins({ marketplace: "owner/repo" }); @@ -175,25 +190,16 @@ describe("marketplace plugins", () => { }); it("installs remote marketplace plugins from relative paths inside the cloned repo", async () => { - runCommandWithTimeoutMock.mockImplementationOnce(async (argv: string[]) => { - const repoDir = argv.at(-1); - expect(typeof repoDir).toBe("string"); - await fs.mkdir(path.join(repoDir as string, ".claude-plugin"), { recursive: true }); - await fs.mkdir(path.join(repoDir as string, "plugins", "frontend-design"), { - recursive: true, - }); - await fs.writeFile( - path.join(repoDir as string, ".claude-plugin", "marketplace.json"), - JSON.stringify({ - plugins: [ - { - name: "frontend-design", - source: "./plugins/frontend-design", - }, - ], - }), - ); - return { code: 0, stdout: "", stderr: "", killed: false }; + mockRemoteMarketplaceClone({ + pluginDir: path.join("plugins", "frontend-design"), + manifest: { + plugins: [ + { + name: "frontend-design", + source: "./plugins/frontend-design", + }, + ], + }, }); installPluginFromPathMock.mockResolvedValue({ ok: true, diff --git a/src/plugins/stage-bundled-plugin-runtime.test.ts b/src/plugins/stage-bundled-plugin-runtime.test.ts index c79755db5c3..54b643ab140 100644 --- a/src/plugins/stage-bundled-plugin-runtime.test.ts +++ b/src/plugins/stage-bundled-plugin-runtime.test.ts @@ -21,6 +21,18 @@ function createDistPluginDir(repoRoot: string, pluginId: string) { return distPluginDir; } +function writeRepoFile(repoRoot: string, relativePath: string, value: string) { + const fullPath = path.join(repoRoot, relativePath); + fs.mkdirSync(path.dirname(fullPath), { recursive: true }); + fs.writeFileSync(fullPath, value, "utf8"); +} + +function setupRepoFiles(repoRoot: string, files: Readonly>) { + for (const [relativePath, value] of Object.entries(files)) { + writeRepoFile(repoRoot, relativePath, value); + } +} + function expectRuntimePluginWrapperContains(params: { repoRoot: string; pluginId: string; @@ -38,6 +50,24 @@ function expectRuntimePluginWrapperContains(params: { expect(fs.readFileSync(runtimePath, "utf8")).toContain(params.expectedImport); } +function expectRuntimeArtifactText(params: { + repoRoot: string; + pluginId: string; + relativePath: string; + expectedText: string; + symbolicLink: boolean; +}) { + const runtimePath = path.join( + params.repoRoot, + "dist-runtime", + "extensions", + params.pluginId, + params.relativePath, + ); + expect(fs.lstatSync(runtimePath).isSymbolicLink()).toBe(params.symbolicLink); + expect(fs.readFileSync(runtimePath, "utf8")).toBe(params.expectedText); +} + afterEach(() => { for (const dir of tempDirs.splice(0, tempDirs.length)) { fs.rmSync(dir, { recursive: true, force: true }); @@ -52,12 +82,10 @@ describe("stageBundledPluginRuntime", () => { fs.mkdirSync(path.join(distPluginDir, "node_modules", "@pierre", "diffs"), { recursive: true, }); - fs.writeFileSync(path.join(distPluginDir, "index.js"), "export default {}\n", "utf8"); - fs.writeFileSync( - path.join(distPluginDir, "node_modules", "@pierre", "diffs", "index.js"), - "export default {}\n", - "utf8", - ); + setupRepoFiles(repoRoot, { + "dist/extensions/diffs/index.js": "export default {}\n", + "dist/extensions/diffs/node_modules/@pierre/diffs/index.js": "export default {}\n", + }); stageBundledPluginRuntime({ repoRoot }); @@ -77,16 +105,10 @@ describe("stageBundledPluginRuntime", () => { it("writes wrappers that forward plugin entry imports into canonical dist files", async () => { const repoRoot = makeRepoRoot("openclaw-stage-bundled-runtime-chunks-"); createDistPluginDir(repoRoot, "diffs"); - fs.writeFileSync( - path.join(repoRoot, "dist", "chunk-abc.js"), - "export const value = 1;\n", - "utf8", - ); - fs.writeFileSync( - path.join(repoRoot, "dist", "extensions", "diffs", "index.js"), - "export { value } from '../../chunk-abc.js';\n", - "utf8", - ); + setupRepoFiles(repoRoot, { + "dist/chunk-abc.js": "export const value = 1;\n", + "dist/extensions/diffs/index.js": "export { value } from '../../chunk-abc.js';\n", + }); stageBundledPluginRuntime({ repoRoot }); @@ -104,18 +126,12 @@ describe("stageBundledPluginRuntime", () => { it("stages root runtime sidecars that bundled plugin boundaries resolve directly", () => { const repoRoot = makeRepoRoot("openclaw-stage-bundled-runtime-sidecars-"); - const distPluginDir = createDistPluginDir(repoRoot, "whatsapp"); - fs.writeFileSync(path.join(distPluginDir, "index.js"), "export default {};\n", "utf8"); - fs.writeFileSync( - path.join(distPluginDir, "light-runtime-api.js"), - "export const light = true;\n", - "utf8", - ); - fs.writeFileSync( - path.join(distPluginDir, "runtime-api.js"), - "export const heavy = true;\n", - "utf8", - ); + createDistPluginDir(repoRoot, "whatsapp"); + setupRepoFiles(repoRoot, { + "dist/extensions/whatsapp/index.js": "export default {};\n", + "dist/extensions/whatsapp/light-runtime-api.js": "export const light = true;\n", + "dist/extensions/whatsapp/runtime-api.js": "export const heavy = true;\n", + }); stageBundledPluginRuntime({ repoRoot }); @@ -242,22 +258,33 @@ describe("stageBundledPluginRuntime", () => { it("copies package metadata files but symlinks other non-js plugin artifacts into the runtime overlay", () => { const repoRoot = makeRepoRoot("openclaw-stage-bundled-runtime-assets-"); - const distPluginDir = path.join(repoRoot, "dist", "extensions", "diffs"); - fs.mkdirSync(path.join(distPluginDir, "assets"), { recursive: true }); - fs.writeFileSync( - path.join(distPluginDir, "package.json"), - JSON.stringify( + createDistPluginDir(repoRoot, "diffs"); + setupRepoFiles(repoRoot, { + "dist/extensions/diffs/package.json": JSON.stringify( { name: "@openclaw/diffs", openclaw: { extensions: ["./index.js"] } }, null, 2, ), - "utf8", - ); - fs.writeFileSync(path.join(distPluginDir, "openclaw.plugin.json"), "{}\n", "utf8"); - fs.writeFileSync(path.join(distPluginDir, "assets", "info.txt"), "ok\n", "utf8"); + "dist/extensions/diffs/openclaw.plugin.json": "{}\n", + "dist/extensions/diffs/assets/info.txt": "ok\n", + }); stageBundledPluginRuntime({ repoRoot }); + expectRuntimeArtifactText({ + repoRoot, + pluginId: "diffs", + relativePath: "openclaw.plugin.json", + expectedText: "{}\n", + symbolicLink: false, + }); + expectRuntimeArtifactText({ + repoRoot, + pluginId: "diffs", + relativePath: "assets/info.txt", + expectedText: "ok\n", + symbolicLink: true, + }); const runtimePackagePath = path.join( repoRoot, "dist-runtime", @@ -265,38 +292,16 @@ describe("stageBundledPluginRuntime", () => { "diffs", "package.json", ); - const runtimeManifestPath = path.join( - repoRoot, - "dist-runtime", - "extensions", - "diffs", - "openclaw.plugin.json", - ); - const runtimeAssetPath = path.join( - repoRoot, - "dist-runtime", - "extensions", - "diffs", - "assets", - "info.txt", - ); - expect(fs.lstatSync(runtimePackagePath).isSymbolicLink()).toBe(false); expect(fs.readFileSync(runtimePackagePath, "utf8")).toContain('"extensions": ['); - expect(fs.lstatSync(runtimeManifestPath).isSymbolicLink()).toBe(false); - expect(fs.readFileSync(runtimeManifestPath, "utf8")).toBe("{}\n"); - expect(fs.lstatSync(runtimeAssetPath).isSymbolicLink()).toBe(true); - expect(fs.readFileSync(runtimeAssetPath, "utf8")).toBe("ok\n"); }); it("preserves package metadata needed for bundled plugin discovery from dist-runtime", () => { const repoRoot = makeRepoRoot("openclaw-stage-bundled-runtime-discovery-"); - const distPluginDir = path.join(repoRoot, "dist", "extensions", "demo"); const runtimeExtensionsDir = path.join(repoRoot, "dist-runtime", "extensions"); - fs.mkdirSync(distPluginDir, { recursive: true }); - fs.writeFileSync( - path.join(distPluginDir, "package.json"), - JSON.stringify( + createDistPluginDir(repoRoot, "demo"); + setupRepoFiles(repoRoot, { + "dist/extensions/demo/package.json": JSON.stringify( { name: "@openclaw/demo", openclaw: { @@ -310,11 +315,7 @@ describe("stageBundledPluginRuntime", () => { null, 2, ), - "utf8", - ); - fs.writeFileSync( - path.join(distPluginDir, "openclaw.plugin.json"), - JSON.stringify( + "dist/extensions/demo/openclaw.plugin.json": JSON.stringify( { id: "demo", channels: ["demo"], @@ -323,10 +324,9 @@ describe("stageBundledPluginRuntime", () => { null, 2, ), - "utf8", - ); - fs.writeFileSync(path.join(distPluginDir, "main.js"), "export default {};\n", "utf8"); - fs.writeFileSync(path.join(distPluginDir, "setup.js"), "export default {};\n", "utf8"); + "dist/extensions/demo/main.js": "export default {};\n", + "dist/extensions/demo/setup.js": "export default {};\n", + }); stageBundledPluginRuntime({ repoRoot }); @@ -388,11 +388,11 @@ describe("stageBundledPluginRuntime", () => { it("tolerates EEXIST when an identical runtime symlink is materialized concurrently", () => { const repoRoot = makeRepoRoot("openclaw-stage-bundled-runtime-eexist-"); - const distPluginDir = path.join(repoRoot, "dist", "extensions", "feishu"); - const distSkillDir = path.join(distPluginDir, "skills", "feishu-doc"); - fs.mkdirSync(distSkillDir, { recursive: true }); - fs.writeFileSync(path.join(distPluginDir, "index.js"), "export default {}\n", "utf8"); - fs.writeFileSync(path.join(distSkillDir, "SKILL.md"), "# Feishu Doc\n", "utf8"); + createDistPluginDir(repoRoot, "feishu"); + setupRepoFiles(repoRoot, { + "dist/extensions/feishu/index.js": "export default {}\n", + "dist/extensions/feishu/skills/feishu-doc/SKILL.md": "# Feishu Doc\n", + }); const realSymlinkSync = fs.symlinkSync.bind(fs); const symlinkSpy = vi.spyOn(fs, "symlinkSync").mockImplementation(((target, link, type) => {