diff --git a/src/plugins/bundle-claude-inspect.test.ts b/src/plugins/bundle-claude-inspect.test.ts index cc81d990cab..aad58aef884 100644 --- a/src/plugins/bundle-claude-inspect.test.ts +++ b/src/plugins/bundle-claude-inspect.test.ts @@ -23,6 +23,18 @@ describe("Claude bundle plugin inspect integration", () => { writeFixtureText(relativePath, JSON.stringify(value)); } + function writeFixtureEntries( + entries: Readonly>>, + ) { + Object.entries(entries).forEach(([relativePath, value]) => { + if (typeof value === "string") { + writeFixtureText(relativePath, value); + return; + } + writeFixtureJson(relativePath, value); + }); + } + function setupClaudeInspectFixture() { for (const relativeDir of [ ".claude-plugin", @@ -36,44 +48,42 @@ describe("Claude bundle plugin inspect integration", () => { 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", + writeFixtureEntries({ + ".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", + }, + "skill-packs/demo/SKILL.md": + "---\nname: demo\ndescription: A demo skill\n---\nDo something useful.", + "extra-commands/cmd/SKILL.md": + "---\nname: cmd\ndescription: A command skill\n---\nRun a command.", + "hooks/hooks.json": '{"hooks":[]}', + ".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"], + "settings.json": { thinkingLevel: "high" }, + ".lsp.json": { + lspServers: { + "typescript-lsp": { + command: "typescript-language-server", + args: ["--stdio"], + }, }, }, }); @@ -119,6 +129,27 @@ describe("Claude bundle plugin inspect integration", () => { expectNoDiagnostics(params.actual.diagnostics); } + function inspectClaudeBundleRuntimeSupport(kind: "mcp" | "lsp"): { + supportedServerNames: string[]; + unsupportedServerNames: string[]; + diagnostics: unknown[]; + hasSupportedStdioServer?: boolean; + hasStdioServer?: boolean; + } { + if (kind === "mcp") { + return inspectBundleMcpRuntimeSupport({ + pluginId: "test-claude-plugin", + rootDir, + bundleFormat: "claude", + }); + } + return inspectBundleLspRuntimeSupport({ + pluginId: "test-claude-plugin", + rootDir, + bundleFormat: "claude", + }); + } + beforeAll(() => { rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-claude-bundle-")); setupClaudeInspectFixture(); @@ -130,10 +161,12 @@ describe("Claude bundle plugin inspect integration", () => { it("loads the full Claude bundle manifest with all capabilities", () => { const m = expectLoadedClaudeManifest(); - expect(m.name).toBe("Test Claude Plugin"); - expect(m.description).toBe("Integration test fixture for Claude bundle inspection"); - expect(m.version).toBe("1.0.0"); - expect(m.bundleFormat).toBe("claude"); + expect(m).toMatchObject({ + name: "Test Claude Plugin", + description: "Integration test fixture for Claude bundle inspection", + version: "1.0.0", + bundleFormat: "claude", + }); }); it.each([ @@ -170,33 +203,27 @@ describe("Claude bundle plugin inspect integration", () => { expectClaudeManifestField({ field, includes }); }); - it("inspects MCP runtime support with supported and unsupported servers", () => { - const mcp = inspectBundleMcpRuntimeSupport({ - pluginId: "test-claude-plugin", - rootDir, - bundleFormat: "claude", - }); - - expectBundleRuntimeSupport({ - actual: mcp, + it.each([ + { + name: "inspects MCP runtime support with supported and unsupported servers", + kind: "mcp" as const, supportedServerNames: ["test-stdio-server"], unsupportedServerNames: ["test-sse-server"], - hasSupportedKey: "hasSupportedStdioServer", - }); - }); - - it("inspects LSP runtime support with stdio server", () => { - const lsp = inspectBundleLspRuntimeSupport({ - pluginId: "test-claude-plugin", - rootDir, - bundleFormat: "claude", - }); - - expectBundleRuntimeSupport({ - actual: lsp, + hasSupportedKey: "hasSupportedStdioServer" as const, + }, + { + name: "inspects LSP runtime support with stdio server", + kind: "lsp" as const, supportedServerNames: ["typescript-lsp"], unsupportedServerNames: [], - hasSupportedKey: "hasStdioServer", + hasSupportedKey: "hasStdioServer" as const, + }, + ])("$name", ({ kind, supportedServerNames, unsupportedServerNames, hasSupportedKey }) => { + expectBundleRuntimeSupport({ + actual: inspectClaudeBundleRuntimeSupport(kind), + supportedServerNames, + unsupportedServerNames, + hasSupportedKey, }); }); }); diff --git a/src/plugins/bundle-commands.test.ts b/src/plugins/bundle-commands.test.ts index 829dd326572..09fb9668d70 100644 --- a/src/plugins/bundle-commands.test.ts +++ b/src/plugins/bundle-commands.test.ts @@ -1,8 +1,12 @@ -import fs from "node:fs/promises"; -import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import { loadEnabledClaudeBundleCommands } from "./bundle-commands.js"; -import { createBundleMcpTempHarness, withBundleHomeEnv } from "./bundle-mcp.test-support.js"; +import { + createEnabledPluginEntries, + createBundleMcpTempHarness, + withBundleHomeEnv, + writeBundleTextFiles, + writeClaudeBundleManifest, +} from "./bundle-mcp.test-support.js"; const tempHarness = createBundleMcpTempHarness(); @@ -15,24 +19,33 @@ async function writeClaudeBundleCommandFixture(params: { pluginId: string; commands: Array<{ relativePath: string; contents: string[] }>; }) { - const pluginRoot = path.join(params.homeDir, ".openclaw", "extensions", params.pluginId); - await fs.mkdir(path.join(pluginRoot, ".claude-plugin"), { recursive: true }); - await fs.writeFile( - path.join(pluginRoot, ".claude-plugin", "plugin.json"), - `${JSON.stringify({ name: params.pluginId }, null, 2)}\n`, - "utf-8", - ); - await Promise.all( - params.commands.map(async (command) => { - await fs.mkdir(path.dirname(path.join(pluginRoot, command.relativePath)), { - recursive: true, - }); - await fs.writeFile( - path.join(pluginRoot, command.relativePath), + const pluginRoot = await writeClaudeBundleManifest({ + homeDir: params.homeDir, + pluginId: params.pluginId, + manifest: { name: params.pluginId }, + }); + await writeBundleTextFiles( + pluginRoot, + Object.fromEntries( + params.commands.map((command) => [ + command.relativePath, [...command.contents, ""].join("\n"), - "utf-8", - ); - }), + ]), + ), + ); +} + +function expectEnabledClaudeBundleCommands( + commands: ReturnType, + expected: Array<{ + pluginId: string; + rawName: string; + description: string; + promptTemplate: string; + }>, +) { + expect(commands).toEqual( + expect.arrayContaining(expected.map((entry) => expect.objectContaining(entry))), ); } @@ -76,29 +89,25 @@ describe("loadEnabledClaudeBundleCommands", () => { workspaceDir, cfg: { plugins: { - entries: { - "compound-bundle": { enabled: true }, - }, + entries: createEnabledPluginEntries(["compound-bundle"]), }, }, }); - 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", - }), - ]), - ); + expectEnabledClaudeBundleCommands(commands, [ + { + pluginId: "compound-bundle", + rawName: "office-hours", + description: "Help with scoping and architecture", + promptTemplate: "Give direct engineering advice.", + }, + { + 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 5f0fcf6e7e9..542161138cb 100644 --- a/src/plugins/bundle-manifest.test.ts +++ b/src/plugins/bundle-manifest.test.ts @@ -36,18 +36,22 @@ function writeBundleManifest( relativePath: string, manifest: Record, ) { - mkdirSafe(path.dirname(path.join(rootDir, relativePath))); - fs.writeFileSync(path.join(rootDir, relativePath), JSON.stringify(manifest), "utf-8"); + writeBundleFixtureFile(rootDir, relativePath, manifest); } -function writeJsonFile(rootDir: string, relativePath: string, value: unknown) { +function writeBundleFixtureFile(rootDir: string, relativePath: string, value: unknown) { mkdirSafe(path.dirname(path.join(rootDir, relativePath))); - fs.writeFileSync(path.join(rootDir, relativePath), JSON.stringify(value), "utf-8"); + fs.writeFileSync( + path.join(rootDir, relativePath), + typeof value === "string" ? value : 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 writeBundleFixtureFiles(rootDir: string, files: Readonly>) { + Object.entries(files).forEach(([relativePath, value]) => { + writeBundleFixtureFile(rootDir, relativePath, value); + }); } function setupBundleFixture(params: { @@ -61,12 +65,8 @@ function setupBundleFixture(params: { 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); - } + writeBundleFixtureFiles(params.rootDir, params.jsonFiles ?? {}); + writeBundleFixtureFiles(params.rootDir, params.textFiles ?? {}); if (params.manifestRelativePath && params.manifest) { writeBundleManifest(params.rootDir, params.manifestRelativePath, params.manifest); } @@ -109,6 +109,25 @@ function setupClaudeHookFixture( }); } +function expectBundleManifest(params: { + rootDir: string; + bundleFormat: "codex" | "claude" | "cursor"; + expected: Record; +}) { + expect(detectBundleManifestFormat(params.rootDir)).toBe(params.bundleFormat); + expect(expectLoadedManifest(params.rootDir, params.bundleFormat)).toMatchObject(params.expected); +} + +function expectClaudeHookResolution(params: { + rootDir: string; + expectedHooks: readonly string[]; + hasHooksCapability: boolean; +}) { + const manifest = expectLoadedManifest(params.rootDir, "claude"); + expect(manifest.hooks).toEqual(params.expectedHooks); + expect(manifest.capabilities.includes("hooks")).toBe(params.hasHooksCapability); +} + afterEach(() => { cleanupTrackedTempDirs(tempDirs); }); @@ -266,10 +285,11 @@ describe("bundle manifest parsing", () => { const rootDir = makeTempDir(); setup(rootDir); - expect(detectBundleManifestFormat(rootDir)).toBe(bundleFormat); - expect(expectLoadedManifest(rootDir, bundleFormat)).toMatchObject( - typeof expected === "function" ? expected(rootDir) : expected, - ); + expectBundleManifest({ + rootDir, + bundleFormat, + expected: typeof expected === "function" ? expected(rootDir) : expected, + }); }); it.each([ @@ -294,9 +314,11 @@ describe("bundle manifest parsing", () => { ] as const)("$name", ({ setupKind, expectedHooks, hasHooksCapability }) => { const rootDir = makeTempDir(); setupClaudeHookFixture(rootDir, setupKind); - const manifest = expectLoadedManifest(rootDir, "claude"); - expect(manifest.hooks).toEqual(expectedHooks); - expect(manifest.capabilities.includes("hooks")).toBe(hasHooksCapability); + expectClaudeHookResolution({ + rootDir, + expectedHooks, + hasHooksCapability, + }); }); it("does not misclassify native index plugins as manifestless Claude bundles", () => { diff --git a/src/plugins/bundle-mcp.test-support.ts b/src/plugins/bundle-mcp.test-support.ts index 892c2f0b5fa..c9782fcce74 100644 --- a/src/plugins/bundle-mcp.test-support.ts +++ b/src/plugins/bundle-mcp.test-support.ts @@ -26,17 +26,52 @@ export function createBundleMcpTempHarness() { }; } -export async function createBundleProbePlugin(homeDir: string) { - const pluginRoot = path.join(homeDir, ".openclaw", "extensions", "bundle-probe"); - const serverPath = path.join(pluginRoot, "servers", "probe.mjs"); +export function resolveBundlePluginRoot(homeDir: string, pluginId: string) { + return path.join(homeDir, ".openclaw", "extensions", pluginId); +} + +export async function writeClaudeBundleManifest(params: { + homeDir: string; + pluginId: string; + manifest: Record; +}) { + const pluginRoot = resolveBundlePluginRoot(params.homeDir, params.pluginId); await fs.mkdir(path.join(pluginRoot, ".claude-plugin"), { recursive: true }); - await fs.mkdir(path.dirname(serverPath), { recursive: true }); - await fs.writeFile(serverPath, "export {};\n", "utf-8"); await fs.writeFile( path.join(pluginRoot, ".claude-plugin", "plugin.json"), - `${JSON.stringify({ name: "bundle-probe" }, null, 2)}\n`, + `${JSON.stringify(params.manifest, null, 2)}\n`, "utf-8", ); + return pluginRoot; +} + +export async function writeBundleTextFiles( + rootDir: string, + files: Readonly>, +) { + await Promise.all( + Object.entries(files).map(async ([relativePath, contents]) => { + const filePath = path.join(rootDir, relativePath); + await fs.mkdir(path.dirname(filePath), { recursive: true }); + await fs.writeFile(filePath, contents, "utf-8"); + }), + ); +} + +export function createEnabledPluginEntries(pluginIds: readonly string[]) { + return Object.fromEntries(pluginIds.map((pluginId) => [pluginId, { enabled: true }])); +} + +export async function createBundleProbePlugin(homeDir: string) { + const pluginRoot = resolveBundlePluginRoot(homeDir, "bundle-probe"); + const serverPath = path.join(pluginRoot, "servers", "probe.mjs"); + await fs.mkdir(path.dirname(serverPath), { recursive: true }); + await fs.writeFile(serverPath, "export {};\n", "utf-8"); + await writeClaudeBundleManifest({ + homeDir, + pluginId: "bundle-probe", + manifest: { name: "bundle-probe" }, + }); await fs.writeFile( path.join(pluginRoot, ".mcp.json"), `${JSON.stringify( diff --git a/src/plugins/bundle-mcp.test.ts b/src/plugins/bundle-mcp.test.ts index a9fcb7d9688..42971d3ae49 100644 --- a/src/plugins/bundle-mcp.test.ts +++ b/src/plugins/bundle-mcp.test.ts @@ -5,9 +5,11 @@ import type { OpenClawConfig } from "../config/config.js"; import { isRecord } from "../utils.js"; import { loadEnabledBundleMcpConfig } from "./bundle-mcp.js"; import { + createEnabledPluginEntries, createBundleMcpTempHarness, createBundleProbePlugin, withBundleHomeEnv, + writeClaudeBundleManifest, } from "./bundle-mcp.test-support.js"; function getServerArgs(value: unknown): unknown[] | undefined { @@ -44,24 +46,43 @@ afterEach(async () => { function createEnabledBundleConfig(pluginIds: string[]): OpenClawConfig { return { plugins: { - entries: Object.fromEntries(pluginIds.map((pluginId) => [pluginId, { enabled: true }])), + entries: createEnabledPluginEntries(pluginIds), }, }; } -async function writeInlineClaudeBundleManifest(params: { - homeDir: string; - pluginId: string; - manifest: Record; +async function expectInlineBundleMcpServer(params: { + loadedServer: unknown; + pluginRoot: string; + commandRelativePath: string; + argRelativePaths: readonly string[]; }) { - const pluginRoot = path.join(params.homeDir, ".openclaw", "extensions", params.pluginId); - await fs.mkdir(path.join(pluginRoot, ".claude-plugin"), { recursive: true }); - await fs.writeFile( - path.join(pluginRoot, ".claude-plugin", "plugin.json"), - `${JSON.stringify(params.manifest, null, 2)}\n`, - "utf-8", + const loadedArgs = getServerArgs(params.loadedServer); + const loadedCommand = isRecord(params.loadedServer) ? params.loadedServer.command : undefined; + const loadedCwd = isRecord(params.loadedServer) ? params.loadedServer.cwd : undefined; + const loadedEnv = + isRecord(params.loadedServer) && isRecord(params.loadedServer.env) + ? params.loadedServer.env + : {}; + + await expectResolvedPathEqual(loadedCwd, params.pluginRoot); + expect(typeof loadedCommand).toBe("string"); + expect(loadedArgs).toHaveLength(params.argRelativePaths.length); + expect(typeof loadedEnv.PLUGIN_ROOT).toBe("string"); + if (typeof loadedCommand !== "string" || typeof loadedCwd !== "string") { + throw new Error("expected inline bundled MCP server to expose command and cwd"); + } + expect(normalizePathForAssertion(path.relative(loadedCwd, loadedCommand))).toBe( + normalizePathForAssertion(params.commandRelativePath), ); - return pluginRoot; + expect( + loadedArgs?.map((entry) => + typeof entry === "string" + ? normalizePathForAssertion(path.relative(loadedCwd, entry)) + : entry, + ), + ).toEqual([...params.argRelativePaths]); + await expectResolvedPathEqual(loadedEnv.PLUGIN_ROOT, params.pluginRoot); } describe("loadEnabledBundleMcpConfig", () => { @@ -110,7 +131,7 @@ describe("loadEnabledBundleMcpConfig", () => { tempHarness, "openclaw-bundle-inline", async ({ homeDir, workspaceDir }) => { - await writeInlineClaudeBundleManifest({ + await writeClaudeBundleManifest({ homeDir, pluginId: "inline-enabled", manifest: { @@ -123,7 +144,7 @@ describe("loadEnabledBundleMcpConfig", () => { }, }, }); - await writeInlineClaudeBundleManifest({ + await writeClaudeBundleManifest({ homeDir, pluginId: "inline-disabled", manifest: { @@ -142,7 +163,7 @@ describe("loadEnabledBundleMcpConfig", () => { cfg: { plugins: { entries: { - ...createEnabledBundleConfig(["inline-enabled"]).plugins?.entries, + ...createEnabledPluginEntries(["inline-enabled"]), "inline-disabled": { enabled: false }, }, }, @@ -160,7 +181,7 @@ describe("loadEnabledBundleMcpConfig", () => { tempHarness, "openclaw-bundle-inline-placeholder", async ({ homeDir, workspaceDir }) => { - const pluginRoot = await writeInlineClaudeBundleManifest({ + const pluginRoot = await writeClaudeBundleManifest({ homeDir, pluginId: "inline-claude", manifest: { @@ -183,34 +204,17 @@ describe("loadEnabledBundleMcpConfig", () => { cfg: createEnabledBundleConfig(["inline-claude"]), }); const loadedServer = loaded.config.mcpServers.inlineProbe; - const loadedArgs = getServerArgs(loadedServer); - const loadedCommand = isRecord(loadedServer) ? loadedServer.command : undefined; - const loadedCwd = isRecord(loadedServer) ? loadedServer.cwd : undefined; - const loadedEnv = - isRecord(loadedServer) && isRecord(loadedServer.env) ? loadedServer.env : {}; expectNoDiagnostics(loaded.diagnostics); - await expectResolvedPathEqual(loadedCwd, pluginRoot); - expect(typeof loadedCommand).toBe("string"); - expect(loadedArgs).toHaveLength(2); - expect(typeof loadedEnv.PLUGIN_ROOT).toBe("string"); - if (typeof loadedCommand !== "string" || typeof loadedCwd !== "string") { - throw new Error("expected inline bundled MCP server to expose command and cwd"); - } - expect(normalizePathForAssertion(path.relative(loadedCwd, loadedCommand))).toBe( - normalizePathForAssertion(path.join("bin", "server.sh")), - ); - expect( - loadedArgs?.map((entry) => - typeof entry === "string" - ? normalizePathForAssertion(path.relative(loadedCwd, entry)) - : entry, - ), - ).toEqual([ - normalizePathForAssertion(path.join("servers", "probe.mjs")), - normalizePathForAssertion("local-probe.mjs"), - ]); - await expectResolvedPathEqual(loadedEnv.PLUGIN_ROOT, pluginRoot); + await expectInlineBundleMcpServer({ + loadedServer, + pluginRoot, + commandRelativePath: path.join("bin", "server.sh"), + argRelativePaths: [ + normalizePathForAssertion(path.join("servers", "probe.mjs"))!, + normalizePathForAssertion("local-probe.mjs")!, + ], + }); }, ); }); diff --git a/src/plugins/bundled-dir.test.ts b/src/plugins/bundled-dir.test.ts index cc1a35df397..473c1484837 100644 --- a/src/plugins/bundled-dir.test.ts +++ b/src/plugins/bundled-dir.test.ts @@ -104,6 +104,17 @@ function expectInstalledBundledDirScenario(params: { }); } +function expectInstalledBundledDirScenarioCase( + createScenario: () => { + installedRoot: string; + cwd?: string; + argv1?: string; + bundledDirOverride?: string; + }, +) { + expectInstalledBundledDirScenario(createScenario()); +} + afterEach(() => { vi.restoreAllMocks(); if (originalBundledDir === undefined) { @@ -181,34 +192,42 @@ describe("resolveBundledPluginsDir", () => { }); }); - it("prefers the running CLI package root over an unrelated cwd checkout", () => { - const installedRoot = createOpenClawRoot({ - prefix: "openclaw-bundled-dir-installed-", - hasDistExtensions: true, - }); - const cwdRepoRoot = createOpenClawRoot({ - prefix: "openclaw-bundled-dir-cwd-", - hasExtensions: true, - hasSrc: true, - hasGitCheckout: true, - }); - - expectInstalledBundledDirScenario({ - installedRoot, - cwd: cwdRepoRoot, - argv1: path.join(installedRoot, "openclaw.mjs"), - }); - }); - - it("falls back to the running installed package when the override path is stale", () => { - const installedRoot = createOpenClawRoot({ - prefix: "openclaw-bundled-dir-override-", - hasDistExtensions: true, - }); - expectInstalledBundledDirScenario({ - installedRoot, - argv1: path.join(installedRoot, "openclaw.mjs"), - bundledDirOverride: path.join(installedRoot, "missing-extensions"), - }); + it.each([ + { + name: "prefers the running CLI package root over an unrelated cwd checkout", + createScenario: () => { + const installedRoot = createOpenClawRoot({ + prefix: "openclaw-bundled-dir-installed-", + hasDistExtensions: true, + }); + const cwdRepoRoot = createOpenClawRoot({ + prefix: "openclaw-bundled-dir-cwd-", + hasExtensions: true, + hasSrc: true, + hasGitCheckout: true, + }); + return { + installedRoot, + cwd: cwdRepoRoot, + argv1: path.join(installedRoot, "openclaw.mjs"), + }; + }, + }, + { + name: "falls back to the running installed package when the override path is stale", + createScenario: () => { + const installedRoot = createOpenClawRoot({ + prefix: "openclaw-bundled-dir-override-", + hasDistExtensions: true, + }); + return { + installedRoot, + argv1: path.join(installedRoot, "openclaw.mjs"), + bundledDirOverride: path.join(installedRoot, "missing-extensions"), + }; + }, + }, + ] as const)("$name", ({ createScenario }) => { + expectInstalledBundledDirScenarioCase(createScenario); }); }); diff --git a/src/plugins/bundled-plugin-metadata.test.ts b/src/plugins/bundled-plugin-metadata.test.ts index 83dce8b6665..f47dabfe46e 100644 --- a/src/plugins/bundled-plugin-metadata.test.ts +++ b/src/plugins/bundled-plugin-metadata.test.ts @@ -65,6 +65,19 @@ async function writeGeneratedMetadataModule(params: { }); } +async function expectGeneratedMetadataModuleState(params: { + repoRoot: string; + check?: boolean; + expected: { changed?: boolean; wrote?: boolean }; +}) { + const result = await writeGeneratedMetadataModule({ + repoRoot: params.repoRoot, + ...(params.check ? { check: true } : {}), + }); + expect(result).toEqual(expect.objectContaining(params.expected)); + return result; +} + describe("bundled plugin metadata", () => { it( "matches the generated metadata snapshot", @@ -127,12 +140,16 @@ describe("bundled plugin metadata", () => { configSchema: { type: "object" }, }); - const initial = await writeGeneratedMetadataModule({ repoRoot: tempRoot }); - expect(initial.wrote).toBe(true); + await expectGeneratedMetadataModuleState({ + repoRoot: tempRoot, + expected: { wrote: true }, + }); - const current = await writeGeneratedMetadataModule({ repoRoot: tempRoot, check: true }); - expect(current.changed).toBe(false); - expect(current.wrote).toBe(false); + await expectGeneratedMetadataModuleState({ + repoRoot: tempRoot, + check: true, + expected: { changed: false, wrote: false }, + }); fs.writeFileSync( path.join(tempRoot, "src/plugins/bundled-plugin-metadata.generated.ts"), @@ -140,9 +157,11 @@ describe("bundled plugin metadata", () => { "utf8", ); - const stale = await writeGeneratedMetadataModule({ repoRoot: tempRoot, check: true }); - expect(stale.changed).toBe(true); - expect(stale.wrote).toBe(false); + await expectGeneratedMetadataModuleState({ + repoRoot: tempRoot, + check: true, + expected: { changed: true, wrote: false }, + }); }); it("merges generated channel schema metadata with manifest-owned channel config fields", async () => { diff --git a/src/plugins/bundled-plugin-naming.test.ts b/src/plugins/bundled-plugin-naming.test.ts index 97880df16b9..0e075bc61d8 100644 --- a/src/plugins/bundled-plugin-naming.test.ts +++ b/src/plugins/bundled-plugin-naming.test.ts @@ -88,11 +88,17 @@ function resolveAllowedPackageNamesForId(pluginId: string): string[] { return ALLOWED_PACKAGE_SUFFIXES.map((suffix) => `@openclaw/${pluginId}${suffix}`); } +function resolveBundledPluginMismatches( + collectMismatches: (records: BundledPluginRecord[]) => string[], +) { + return collectMismatches(readBundledPluginRecords()); +} + function expectNoBundledPluginNamingMismatches(params: { message: string; collectMismatches: (records: BundledPluginRecord[]) => string[]; }) { - const mismatches = params.collectMismatches(readBundledPluginRecords()); + const mismatches = resolveBundledPluginMismatches(params.collectMismatches); expect(mismatches, `${params.message}\nFound: ${mismatches.join(", ") || ""}`).toEqual([]); } diff --git a/src/plugins/bundled-provider-auth-env-vars.test.ts b/src/plugins/bundled-provider-auth-env-vars.test.ts index 2fc94e49615..5743a5fdca4 100644 --- a/src/plugins/bundled-provider-auth-env-vars.test.ts +++ b/src/plugins/bundled-provider-auth-env-vars.test.ts @@ -29,6 +29,14 @@ function expectGeneratedAuthEnvVarModuleState(params: { expect(result.wrote).toBe(params.expectedWrote); } +function expectGeneratedAuthEnvVarCheckMode(tempRoot: string) { + expectGeneratedAuthEnvVarModuleState({ + tempRoot, + expectedChanged: false, + expectedWrote: false, + }); +} + function expectBundledProviderEnvVars(expected: Record) { expect( Object.fromEntries( @@ -42,6 +50,12 @@ function expectBundledProviderEnvVars(expected: Record { + expect(providerId in BUNDLED_PROVIDER_AUTH_ENV_VAR_CANDIDATES).toBe(false); + }); +} + describe("bundled provider auth env vars", () => { it("matches the generated manifest snapshot", () => { expect(BUNDLED_PROVIDER_AUTH_ENV_VAR_CANDIDATES).toEqual( @@ -60,7 +74,7 @@ describe("bundled provider auth env vars", () => { openai: ["OPENAI_API_KEY"], fal: ["FAL_KEY"], }); - expect("openai-codex" in BUNDLED_PROVIDER_AUTH_ENV_VAR_CANDIDATES).toBe(false); + expectMissingBundledProviderEnvVars(["openai-codex"]); }); it("supports check mode for stale generated artifacts", () => { @@ -79,11 +93,7 @@ describe("bundled provider auth env vars", () => { }); expect(initial.wrote).toBe(true); - expectGeneratedAuthEnvVarModuleState({ - tempRoot, - expectedChanged: false, - expectedWrote: false, - }); + expectGeneratedAuthEnvVarCheckMode(tempRoot); fs.writeFileSync( path.join(tempRoot, "src/plugins/bundled-provider-auth-env-vars.generated.ts"), diff --git a/src/plugins/bundled-sources.test.ts b/src/plugins/bundled-sources.test.ts index 8b3a3a78c92..75f3c284223 100644 --- a/src/plugins/bundled-sources.test.ts +++ b/src/plugins/bundled-sources.test.ts @@ -70,6 +70,18 @@ function setBundledLookupFixture() { }); } +function createResolvedBundledSource(params: { + pluginId: string; + localPath: string; + npmSpec?: string; +}) { + return { + pluginId: params.pluginId, + localPath: params.localPath, + npmSpec: params.npmSpec ?? `@openclaw/${params.pluginId}`, + }; +} + function expectBundledSourceLookup( lookup: Parameters[0]["lookup"], expected: @@ -88,6 +100,19 @@ function expectBundledSourceLookup( expect(resolved?.localPath).toBe(expected.localPath); } +function expectBundledSourceLookupCase(params: { + lookup: Parameters[0]["lookup"]; + expected: + | { + pluginId: string; + localPath: string; + } + | undefined; +}) { + setBundledLookupFixture(); + expectBundledSourceLookup(params.lookup, params.expected); +} + describe("bundled plugin sources", () => { beforeEach(() => { discoverOpenClawPluginsMock.mockReset(); @@ -122,11 +147,12 @@ describe("bundled plugin sources", () => { const map = resolveBundledPluginSources({}); expect(Array.from(map.keys())).toEqual(["feishu", "msteams"]); - expect(map.get("feishu")).toEqual({ - pluginId: "feishu", - localPath: "/app/extensions/feishu", - npmSpec: "@openclaw/feishu", - }); + expect(map.get("feishu")).toEqual( + createResolvedBundledSource({ + pluginId: "feishu", + localPath: "/app/extensions/feishu", + }), + ); }); it.each([ @@ -151,8 +177,7 @@ describe("bundled plugin sources", () => { undefined, ], ] as const)("%s", (_name, lookup, expected) => { - setBundledLookupFixture(); - expectBundledSourceLookup(lookup, expected); + expectBundledSourceLookupCase({ lookup, expected }); }); it("forwards an explicit env to bundled discovery helpers", () => { @@ -184,11 +209,10 @@ describe("bundled plugin sources", () => { const bundled = new Map([ [ "feishu", - { + createResolvedBundledSource({ pluginId: "feishu", localPath: "/app/extensions/feishu", - npmSpec: "@openclaw/feishu", - }, + }), ], ]); @@ -197,11 +221,12 @@ describe("bundled plugin sources", () => { bundled, lookup: { kind: "pluginId", value: "feishu" }, }), - ).toEqual({ - pluginId: "feishu", - localPath: "/app/extensions/feishu", - npmSpec: "@openclaw/feishu", - }); + ).toEqual( + createResolvedBundledSource({ + pluginId: "feishu", + localPath: "/app/extensions/feishu", + }), + ); expect( findBundledPluginSourceInMap({ bundled, diff --git a/src/plugins/bundled-web-search.test.ts b/src/plugins/bundled-web-search.test.ts index f7fe41e5ed7..a15a772b069 100644 --- a/src/plugins/bundled-web-search.test.ts +++ b/src/plugins/bundled-web-search.test.ts @@ -27,6 +27,13 @@ function expectBundledWebSearchIds(actual: readonly string[], expected: readonly expect(actual).toEqual(expected); } +function expectBundledWebSearchAlignment(params: { + actual: readonly string[]; + expected: readonly string[]; +}) { + expectBundledWebSearchIds(params.actual, params.expected); +} + describe("bundled web search metadata", () => { it.each([ [ @@ -40,6 +47,6 @@ describe("bundled web search metadata", () => { resolveRegistryBundledWebSearchPluginIds(), ], ] as const)("%s", (_name, actual, expected) => { - expectBundledWebSearchIds(actual, expected); + expectBundledWebSearchAlignment({ actual, expected }); }); }); diff --git a/src/plugins/commands.test.ts b/src/plugins/commands.test.ts index bea22624d25..da2d2746ff8 100644 --- a/src/plugins/commands.test.ts +++ b/src/plugins/commands.test.ts @@ -28,6 +28,12 @@ function createVoiceCommand(overrides: Partial[1]> = {}, +) { + return registerPluginCommand("demo-plugin", createVoiceCommand(overrides)); +} + function resolveBindingConversationFromCommand( params: Parameters[0], ) { @@ -47,6 +53,50 @@ function expectCommandMatch( }); } +function expectProviderCommandSpecs( + provider: Parameters[0], + expectedNames: readonly string[], +) { + expect(getPluginCommandSpecs(provider)).toEqual( + expectedNames.map((name) => ({ + name, + description: "Demo command", + acceptsArgs: false, + })), + ); +} + +function expectProviderCommandSpecCases( + cases: ReadonlyArray<{ + provider: Parameters[0]; + expectedNames: readonly string[]; + }>, +) { + cases.forEach(({ provider, expectedNames }) => { + expectProviderCommandSpecs(provider, expectedNames); + }); +} + +function expectUnsupportedBindingApiResult(result: { text?: string }) { + expect(result.text).toBe( + JSON.stringify({ + requested: { + status: "error", + message: "This command cannot bind the current conversation.", + }, + current: null, + detached: { removed: false }, + }), + ); +} + +function expectBindingConversationCase( + params: Parameters[0], + expected: ReturnType, +) { + expect(resolveBindingConversationFromCommand(params)).toEqual(expected); +} + beforeEach(() => { setActivePluginRegistry(createTestRegistry([])); }); @@ -110,40 +160,21 @@ describe("registerPluginCommand", () => { }); it("supports provider-specific native command aliases", () => { - const result = registerPluginCommand( - "demo-plugin", - createVoiceCommand({ - nativeNames: { - default: "talkvoice", - discord: "discordvoice", - }, - description: "Demo command", - }), - ); + const result = registerVoiceCommandForTest({ + nativeNames: { + default: "talkvoice", + discord: "discordvoice", + }, + description: "Demo command", + }); expect(result).toEqual({ ok: true }); - expect(getPluginCommandSpecs()).toEqual([ - { - name: "talkvoice", - description: "Demo command", - acceptsArgs: false, - }, + expectProviderCommandSpecCases([ + { provider: undefined, expectedNames: ["talkvoice"] }, + { provider: "discord", expectedNames: ["discordvoice"] }, + { provider: "telegram", expectedNames: ["talkvoice"] }, + { provider: "slack", expectedNames: [] }, ]); - expect(getPluginCommandSpecs("discord")).toEqual([ - { - name: "discordvoice", - description: "Demo command", - acceptsArgs: false, - }, - ]); - expect(getPluginCommandSpecs("telegram")).toEqual([ - { - name: "talkvoice", - description: "Demo command", - acceptsArgs: false, - }, - ]); - expect(getPluginCommandSpecs("slack")).toEqual([]); }); it("shares plugin commands across duplicate module instances", async () => { @@ -183,17 +214,14 @@ describe("registerPluginCommand", () => { it.each(["/talkvoice now", "/discordvoice now"] as const)( "matches provider-specific native alias %s back to the canonical command", (commandBody) => { - const result = registerPluginCommand( - "demo-plugin", - createVoiceCommand({ - nativeNames: { - default: "talkvoice", - discord: "discordvoice", - }, - description: "Demo command", - acceptsArgs: true, - }), - ); + const result = registerVoiceCommandForTest({ + nativeNames: { + default: "talkvoice", + discord: "discordvoice", + }, + description: "Demo command", + acceptsArgs: true, + }); expect(result).toEqual({ ok: true }); expectCommandMatch(commandBody, { @@ -349,7 +377,7 @@ describe("registerPluginCommand", () => { expected: null, }, ] as const)("$name", ({ params, expected }) => { - expect(resolveBindingConversationFromCommand(params)).toEqual(expected); + expectBindingConversationCase(params, expected); }); it("does not expose binding APIs to plugin commands on unsupported channels", async () => { @@ -401,15 +429,6 @@ describe("registerPluginCommand", () => { accountId: "default", }); - expect(result.text).toBe( - JSON.stringify({ - requested: { - status: "error", - message: "This command cannot bind the current conversation.", - }, - current: null, - detached: { removed: false }, - }), - ); + expectUnsupportedBindingApiResult(result); }); }); diff --git a/src/plugins/config-schema.test.ts b/src/plugins/config-schema.test.ts index 268ac18ef0e..fac67f2aebb 100644 --- a/src/plugins/config-schema.test.ts +++ b/src/plugins/config-schema.test.ts @@ -10,11 +10,18 @@ function expectSafeParseCases( expect(cases.map(([value]) => safeParse?.(value))).toEqual(cases.map(([, expected]) => expected)); } +function expectJsonSchema( + result: ReturnType, + expected: Record, +) { + expect(result.jsonSchema).toMatchObject(expected); +} + describe("buildPluginConfigSchema", () => { it("builds json schema when toJSONSchema is available", () => { const schema = z.strictObject({ enabled: z.boolean().default(true) }); const result = buildPluginConfigSchema(schema); - expect(result.jsonSchema).toMatchObject({ + expectJsonSchema(result, { type: "object", additionalProperties: false, properties: { enabled: { type: "boolean", default: true } }, @@ -51,7 +58,7 @@ describe("buildPluginConfigSchema", () => { it("falls back when toJSONSchema is missing", () => { const legacySchema = {} as unknown as Parameters[0]; const result = buildPluginConfigSchema(legacySchema); - expect(result.jsonSchema).toEqual({ type: "object", additionalProperties: true }); + expectJsonSchema(result, { type: "object", additionalProperties: true }); }); it("uses zod runtime parsing by default", () => { diff --git a/src/plugins/discovery.test.ts b/src/plugins/discovery.test.ts index bdc89bed81e..1f325d0c4d0 100644 --- a/src/plugins/discovery.test.ts +++ b/src/plugins/discovery.test.ts @@ -185,6 +185,54 @@ function expectCandidatePresence( }); } +function expectCandidateOrder( + candidates: Array<{ idHint: string }>, + expectedIds: readonly string[], +) { + expect(candidates.map((candidate) => candidate.idHint)).toEqual(expectedIds); +} + +function expectBundleCandidateMatch(params: { + candidates: Array<{ + idHint?: string; + format?: string; + bundleFormat?: string; + source?: string; + rootDir?: string; + }>; + idHint: string; + bundleFormat: string; + source: string; + expectRootDir?: boolean; +}) { + const bundle = findCandidateById(params.candidates, params.idHint); + expect(bundle).toBeDefined(); + expect(bundle).toEqual( + expect.objectContaining({ + idHint: params.idHint, + format: "bundle", + bundleFormat: params.bundleFormat, + source: params.source, + }), + ); + if (params.expectRootDir) { + expect(normalizePathForAssertion(bundle?.rootDir)).toBe( + normalizePathForAssertion(fs.realpathSync(params.source)), + ); + } +} + +function expectCachedDiscoveryPair(params: { + first: ReturnType; + second: ReturnType; + assert: ( + first: ReturnType, + second: ReturnType, + ) => void; +}) { + params.assert(params.first, params.second); +} + async function expectRejectedPackageExtensionEntry(params: { stateDir: string; setup: (stateDir: string) => boolean | void; @@ -227,10 +275,7 @@ describe("discoverOpenClawPlugins", () => { fs.writeFileSync(path.join(workspaceExt, "beta.ts"), "export default function () {}", "utf-8"); const { candidates } = await discoverWithStateDir(stateDir, { workspaceDir }); - - const ids = candidates.map((c) => c.idHint); - expect(ids).toContain("alpha"); - expect(ids).toContain("beta"); + expectCandidateIds(candidates, { includes: ["alpha", "beta"] }); }); it("resolves tilde workspace dirs against the provided env", () => { @@ -249,9 +294,7 @@ describe("discoverOpenClawPlugins", () => { }, }); - expect(result.candidates.some((candidate) => candidate.idHint === "tilde-workspace")).toBe( - true, - ); + expectCandidatePresence(result, { present: ["tilde-workspace"] }); }); it("ignores backup and disabled plugin directories in scanned roots", async () => { @@ -276,12 +319,10 @@ describe("discoverOpenClawPlugins", () => { fs.writeFileSync(path.join(liveDir, "index.ts"), "export default function () {}", "utf-8"); const { candidates } = await discoverWithStateDir(stateDir, {}); - - const ids = candidates.map((candidate) => candidate.idHint); - expect(ids).toContain("live"); - expect(ids).not.toContain("feishu.backup-20260222"); - expect(ids).not.toContain("telegram.disabled.20260222"); - expect(ids).not.toContain("discord.bak"); + expectCandidateIds(candidates, { + includes: ["live"], + excludes: ["feishu.backup-20260222", "telegram.disabled.20260222", "discord.bak"], + }); }); it("loads package extension packs", async () => { @@ -340,8 +381,7 @@ describe("discoverOpenClawPlugins", () => { ); const { candidates } = await discoverWithStateDir(stateDir, {}); - - expect(candidates.map((candidate) => candidate.idHint)).toEqual(["opik-openclaw"]); + expectCandidateOrder(candidates, ["opik-openclaw"]); }); it.each([ @@ -461,22 +501,14 @@ describe("discoverOpenClawPlugins", () => { const stateDir = makeTempDir(); const bundleDir = setup(stateDir); const { candidates } = await discoverWithStateDir(stateDir, {}); - const bundle = findCandidateById(candidates, idHint); - expect(bundle).toBeDefined(); - expect(bundle).toEqual( - expect.objectContaining({ - idHint, - format: "bundle", - bundleFormat, - source: bundleDir, - }), - ); - if (expectRootDir) { - expect(normalizePathForAssertion(bundle?.rootDir)).toBe( - normalizePathForAssertion(fs.realpathSync(bundleDir)), - ); - } + expectBundleCandidateMatch({ + candidates, + idHint, + bundleFormat, + source: bundleDir, + expectRootDir, + }); }); it.each([ @@ -777,7 +809,7 @@ describe("discoverOpenClawPlugins", () => { }, ] as const)("$name", ({ setup }) => { const { first, second, assert } = setup(); - assert(first, second); + expectCachedDiscoveryPair({ first, second, assert }); }); it("treats configured load-path order as cache-significant", () => { @@ -798,7 +830,7 @@ describe("discoverOpenClawPlugins", () => { env, }); - expect(first.candidates.map((candidate) => candidate.idHint)).toEqual(["alpha", "beta"]); - expect(second.candidates.map((candidate) => candidate.idHint)).toEqual(["beta", "alpha"]); + expectCandidateOrder(first.candidates, ["alpha", "beta"]); + expectCandidateOrder(second.candidates, ["beta", "alpha"]); }); }); diff --git a/src/plugins/marketplace.test.ts b/src/plugins/marketplace.test.ts index 01cbb01d5f3..7b23774d8c5 100644 --- a/src/plugins/marketplace.test.ts +++ b/src/plugins/marketplace.test.ts @@ -46,6 +46,17 @@ async function writeRemoteMarketplaceFixture(params: { ); } +async function writeLocalMarketplaceFixture(params: { + rootDir: string; + manifest: unknown; + pluginDir?: string; +}) { + if (params.pluginDir) { + await fs.mkdir(params.pluginDir, { recursive: true }); + } + return writeMarketplaceManifest(params.rootDir, params.manifest); +} + function mockRemoteMarketplaceClone(params: { manifest: unknown; pluginDir?: string }) { runCommandWithTimeoutMock.mockImplementationOnce(async (argv: string[]) => { const repoDir = argv.at(-1); @@ -72,6 +83,65 @@ async function expectRemoteMarketplaceError(params: { manifest: unknown; expecte expect(runCommandWithTimeoutMock).toHaveBeenCalledTimes(1); } +function expectRemoteMarketplaceInstallResult(result: unknown) { + expect(runCommandWithTimeoutMock).toHaveBeenCalledTimes(1); + expect(runCommandWithTimeoutMock).toHaveBeenCalledWith( + ["git", "clone", "--depth", "1", "https://github.com/owner/repo.git", expect.any(String)], + { timeoutMs: 120_000 }, + ); + expect(installPluginFromPathMock).toHaveBeenCalledWith( + expect.objectContaining({ + path: expect.stringMatching(/[\\/]repo[\\/]plugins[\\/]frontend-design$/), + }), + ); + expect(result).toMatchObject({ + ok: true, + pluginId: "frontend-design", + marketplacePlugin: "frontend-design", + marketplaceSource: "owner/repo", + }); +} + +function expectMarketplaceManifestListing( + result: Awaited>, +) { + expect(result.ok).toBe(true); + if (!result.ok) { + throw new Error("expected marketplace listing to succeed"); + } + expect(result.sourceLabel.replaceAll("\\", "/")).toContain(".claude-plugin/marketplace.json"); + expect(result.manifest).toEqual({ + name: "Example Marketplace", + version: "1.0.0", + plugins: [ + { + name: "frontend-design", + version: "0.1.0", + description: "Design system bundle", + source: { kind: "path", path: "./plugins/frontend-design" }, + }, + ], + }); +} + +function expectLocalMarketplaceInstallResult(params: { + result: unknown; + pluginDir: string; + marketplaceSource: string; +}) { + expect(installPluginFromPathMock).toHaveBeenCalledWith( + expect.objectContaining({ + path: params.pluginDir, + }), + ); + expect(params.result).toMatchObject({ + ok: true, + pluginId: "frontend-design", + marketplacePlugin: "frontend-design", + marketplaceSource: params.marketplaceSource, + }); +} + describe("marketplace plugins", () => { afterEach(() => { installPluginFromPathMock.mockReset(); @@ -95,38 +165,24 @@ describe("marketplace plugins", () => { }); const { listMarketplacePlugins } = await import("./marketplace.js"); - const result = await listMarketplacePlugins({ marketplace: rootDir }); - expect(result.ok).toBe(true); - if (!result.ok) { - throw new Error("expected marketplace listing to succeed"); - } - expect(result.sourceLabel.replaceAll("\\", "/")).toContain(".claude-plugin/marketplace.json"); - expect(result.manifest).toEqual({ - name: "Example Marketplace", - version: "1.0.0", - plugins: [ - { - name: "frontend-design", - version: "0.1.0", - description: "Design system bundle", - source: { kind: "path", path: "./plugins/frontend-design" }, - }, - ], - }); + expectMarketplaceManifestListing(await listMarketplacePlugins({ marketplace: rootDir })); }); }); it("resolves relative plugin paths against the marketplace root", async () => { await withTempDir(async (rootDir) => { const pluginDir = path.join(rootDir, "plugins", "frontend-design"); - await fs.mkdir(pluginDir, { recursive: true }); - const manifestPath = await writeMarketplaceManifest(rootDir, { - plugins: [ - { - name: "frontend-design", - source: "./plugins/frontend-design", - }, - ], + const manifestPath = await writeLocalMarketplaceFixture({ + rootDir, + pluginDir, + manifest: { + plugins: [ + { + name: "frontend-design", + source: "./plugins/frontend-design", + }, + ], + }, }); installPluginFromPathMock.mockResolvedValue({ ok: true, @@ -142,15 +198,9 @@ describe("marketplace plugins", () => { plugin: "frontend-design", }); - expect(installPluginFromPathMock).toHaveBeenCalledWith( - expect.objectContaining({ - path: pluginDir, - }), - ); - expect(result).toMatchObject({ - ok: true, - pluginId: "frontend-design", - marketplacePlugin: "frontend-design", + expectLocalMarketplaceInstallResult({ + result, + pluginDir, marketplaceSource: path.join(rootDir, ".claude-plugin", "marketplace.json"), }); }); @@ -215,22 +265,7 @@ describe("marketplace plugins", () => { plugin: "frontend-design", }); - expect(runCommandWithTimeoutMock).toHaveBeenCalledTimes(1); - expect(runCommandWithTimeoutMock).toHaveBeenCalledWith( - ["git", "clone", "--depth", "1", "https://github.com/owner/repo.git", expect.any(String)], - { timeoutMs: 120_000 }, - ); - expect(installPluginFromPathMock).toHaveBeenCalledWith( - expect.objectContaining({ - path: expect.stringMatching(/[\\/]repo[\\/]plugins[\\/]frontend-design$/), - }), - ); - expect(result).toMatchObject({ - ok: true, - pluginId: "frontend-design", - marketplacePlugin: "frontend-design", - marketplaceSource: "owner/repo", - }); + expectRemoteMarketplaceInstallResult(result); }); it("returns a structured error for archive downloads with an empty response body", async () => { diff --git a/src/plugins/memory-runtime.test.ts b/src/plugins/memory-runtime.test.ts index 058883a42a0..8867b98b835 100644 --- a/src/plugins/memory-runtime.test.ts +++ b/src/plugins/memory-runtime.test.ts @@ -49,6 +49,14 @@ function expectMemoryRuntimeLoaded(autoEnabledConfig: unknown) { }); } +function expectMemoryAutoEnableApplied(rawConfig: unknown, autoEnabledConfig: unknown) { + expect(applyPluginAutoEnableMock).toHaveBeenCalledWith({ + config: rawConfig, + env: process.env, + }); + expectMemoryRuntimeLoaded(autoEnabledConfig); +} + function setAutoEnabledMemoryRuntime() { const { rawConfig, autoEnabledConfig } = createMemoryAutoEnableFixture(); const runtime = createMemoryRuntimeFixture(); @@ -57,6 +65,37 @@ function setAutoEnabledMemoryRuntime() { return { rawConfig, autoEnabledConfig, runtime }; } +function expectNoMemoryRuntimeBootstrap() { + expect(applyPluginAutoEnableMock).not.toHaveBeenCalled(); + expect(resolveRuntimePluginRegistryMock).not.toHaveBeenCalled(); +} + +async function expectAutoEnabledMemoryRuntimeCase(params: { + run: (rawConfig: unknown) => Promise; + expectedResult: unknown; +}) { + const { rawConfig, autoEnabledConfig } = setAutoEnabledMemoryRuntime(); + const result = await params.run(rawConfig); + + if (params.expectedResult !== undefined) { + expect(result).toEqual(params.expectedResult); + } + expectMemoryAutoEnableApplied(rawConfig, autoEnabledConfig); +} + +async function expectCloseMemoryRuntimeCase(params: { + config: unknown; + setup: () => { closeAllMemorySearchManagers: ReturnType } | undefined; +}) { + const runtime = params.setup(); + await closeActiveMemorySearchManagers(params.config as never); + + if (runtime) { + expect(runtime.closeAllMemorySearchManagers).toHaveBeenCalledTimes(1); + } + expectNoMemoryRuntimeBootstrap(); +} + describe("memory runtime auto-enable loading", () => { beforeEach(async () => { vi.resetModules(); @@ -94,42 +133,33 @@ describe("memory runtime auto-enable loading", () => { expectedResult: { backend: "builtin" }, }, ] as const)("$name", async ({ run, expectedResult }) => { - const { rawConfig, autoEnabledConfig } = setAutoEnabledMemoryRuntime(); - - const result = await run(rawConfig); - - if (expectedResult !== undefined) { - expect(result).toEqual(expectedResult); - } - expect(applyPluginAutoEnableMock).toHaveBeenCalledWith({ - config: rawConfig, - env: process.env, - }); - expectMemoryRuntimeLoaded(autoEnabledConfig); + await expectAutoEnabledMemoryRuntimeCase({ run, expectedResult }); }); - it("does not bootstrap the memory runtime just to close managers", async () => { - const rawConfig = { - plugins: {}, - channels: { memory: { enabled: true } }, - }; - getMemoryRuntimeMock.mockReturnValue(undefined); - - await closeActiveMemorySearchManagers(rawConfig as never); - - expect(applyPluginAutoEnableMock).not.toHaveBeenCalled(); - expect(resolveRuntimePluginRegistryMock).not.toHaveBeenCalled(); - }); - - it("closes an already-registered memory runtime without reloading plugins", async () => { - const runtime = { - closeAllMemorySearchManagers: vi.fn(async () => {}), - }; - getMemoryRuntimeMock.mockReturnValue(runtime); - - await closeActiveMemorySearchManagers({} as never); - - expect(runtime.closeAllMemorySearchManagers).toHaveBeenCalledTimes(1); - expect(resolveRuntimePluginRegistryMock).not.toHaveBeenCalled(); + it.each([ + { + name: "does not bootstrap the memory runtime just to close managers", + config: { + plugins: {}, + channels: { memory: { enabled: true } }, + }, + setup: () => { + getMemoryRuntimeMock.mockReturnValue(undefined); + return undefined; + }, + }, + { + name: "closes an already-registered memory runtime without reloading plugins", + config: {}, + setup: () => { + const runtime = { + closeAllMemorySearchManagers: vi.fn(async () => {}), + }; + getMemoryRuntimeMock.mockReturnValue(runtime); + return runtime; + }, + }, + ] as const)("$name", async ({ config, setup }) => { + await expectCloseMemoryRuntimeCase({ config, setup }); }); }); diff --git a/src/plugins/memory-state.test.ts b/src/plugins/memory-state.test.ts index a518f73c0cd..54dc632818f 100644 --- a/src/plugins/memory-state.test.ts +++ b/src/plugins/memory-state.test.ts @@ -49,6 +49,23 @@ function createMemoryStateSnapshot() { }; } +function registerMemoryState(params: { + promptSection?: string[]; + relativePath?: string; + runtime?: ReturnType; +}) { + if (params.promptSection) { + registerMemoryPromptSection(() => params.promptSection ?? []); + } + if (params.relativePath) { + const relativePath = params.relativePath; + registerMemoryFlushPlanResolver(() => createMemoryFlushPlan(relativePath)); + } + if (params.runtime) { + registerMemoryRuntime(params.runtime); + } +} + describe("memory plugin state", () => { afterEach(() => { clearMemoryPluginState(); @@ -114,10 +131,12 @@ describe("memory plugin state", () => { }); it("restoreMemoryPluginState swaps both prompt and flush state", () => { - registerMemoryPromptSection(() => ["first"]); - registerMemoryFlushPlanResolver(() => createMemoryFlushPlan("memory/first.md")); const runtime = createMemoryRuntime(); - registerMemoryRuntime(runtime); + registerMemoryState({ + promptSection: ["first"], + relativePath: "memory/first.md", + runtime, + }); const snapshot = createMemoryStateSnapshot(); _resetMemoryPluginState(); @@ -130,9 +149,11 @@ describe("memory plugin state", () => { }); it("clearMemoryPluginState resets both registries", () => { - registerMemoryPromptSection(() => ["stale section"]); - registerMemoryFlushPlanResolver(() => createMemoryFlushPlan("memory/stale.md")); - registerMemoryRuntime(createMemoryRuntime()); + registerMemoryState({ + promptSection: ["stale section"], + relativePath: "memory/stale.md", + runtime: createMemoryRuntime(), + }); clearMemoryPluginState(); diff --git a/src/plugins/schema-validator.test.ts b/src/plugins/schema-validator.test.ts index 896b2bbe750..25b3c060d2c 100644 --- a/src/plugins/schema-validator.test.ts +++ b/src/plugins/schema-validator.test.ts @@ -31,28 +31,58 @@ function expectIssueMessageIncludes( }); } +function expectSuccessfulValidationValue(params: { + input: Parameters[0]; + expectedValue: unknown; +}) { + const result = validateJsonSchemaValue(params.input); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.value).toEqual(params.expectedValue); + } +} + +function expectValidationSuccess(params: Parameters[0]) { + const result = validateJsonSchemaValue(params); + expect(result.ok).toBe(true); +} + +function expectUriValidationCase(params: { + input: Parameters[0]; + ok: boolean; + expectedPath?: string; + expectedMessage?: string; +}) { + if (params.ok) { + expectValidationSuccess(params.input); + return; + } + + const result = expectValidationFailure(params.input); + const issue = expectValidationIssue(result, params.expectedPath ?? ""); + expect(issue?.message).toContain(params.expectedMessage ?? ""); +} + describe("schema validator", () => { it("can apply JSON Schema defaults while validating", () => { - const res = validateJsonSchemaValue({ - cacheKey: "schema-validator.test.defaults", - schema: { - type: "object", - properties: { - mode: { - type: "string", - default: "auto", + expectSuccessfulValidationValue({ + input: { + cacheKey: "schema-validator.test.defaults", + schema: { + type: "object", + properties: { + mode: { + type: "string", + default: "auto", + }, }, + additionalProperties: false, }, - additionalProperties: false, + value: {}, + applyDefaults: true, }, - value: {}, - applyDefaults: true, + expectedValue: { mode: "auto" }, }); - - expect(res.ok).toBe(true); - if (res.ok) { - expect(res.value).toEqual({ mode: "auto" }); - } }); it.each([ @@ -275,18 +305,12 @@ describe("schema validator", () => { ])( "supports uri-formatted string schemas: $title", ({ params, ok, expectedPath, expectedMessage }) => { - const result = validateJsonSchemaValue(params); - - if (ok) { - expect(result.ok).toBe(true); - return; - } - - expect(result.ok).toBe(false); - if (!result.ok) { - const issue = expectValidationIssue(result, expectedPath as string); - expect(issue?.message).toContain(expectedMessage); - } + expectUriValidationCase({ + input: params, + ok, + expectedPath, + expectedMessage, + }); }, ); });