From 95acd74d7c682760d07d439e2b8e65bbd8df280c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 28 Mar 2026 03:06:11 +0000 Subject: [PATCH] test: dedupe plugin bundle and discovery helpers --- src/plugins/bundle-claude-inspect.test.ts | 8 ++- src/plugins/bundle-manifest.test.ts | 62 ++++++++++--------- src/plugins/bundle-mcp.test.ts | 8 ++- src/plugins/clawhub.test.ts | 18 +++++- .../copy-bundled-plugin-metadata.test.ts | 16 ++--- src/plugins/discovery.test.ts | 40 +++++++----- src/plugins/marketplace.test.ts | 24 ++++--- 7 files changed, 107 insertions(+), 69 deletions(-) diff --git a/src/plugins/bundle-claude-inspect.test.ts b/src/plugins/bundle-claude-inspect.test.ts index 28459753957..073b4159c25 100644 --- a/src/plugins/bundle-claude-inspect.test.ts +++ b/src/plugins/bundle-claude-inspect.test.ts @@ -32,6 +32,10 @@ describe("Claude bundle plugin inspect integration", () => { expect(values).toEqual(expect.arrayContaining([...params.includes])); } + function expectNoDiagnostics(diagnostics: unknown[]) { + expect(diagnostics).toEqual([]); + } + beforeAll(() => { rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-claude-bundle-")); @@ -182,7 +186,7 @@ describe("Claude bundle plugin inspect integration", () => { expect(mcp.hasSupportedStdioServer).toBe(true); expect(mcp.supportedServerNames).toContain("test-stdio-server"); expect(mcp.unsupportedServerNames).toContain("test-sse-server"); - expect(mcp.diagnostics).toEqual([]); + expectNoDiagnostics(mcp.diagnostics); }); it("inspects LSP runtime support with stdio server", () => { @@ -195,6 +199,6 @@ describe("Claude bundle plugin inspect integration", () => { expect(lsp.hasStdioServer).toBe(true); expect(lsp.supportedServerNames).toContain("typescript-lsp"); expect(lsp.unsupportedServerNames).toEqual([]); - expect(lsp.diagnostics).toEqual([]); + expectNoDiagnostics(lsp.diagnostics); }); }); diff --git a/src/plugins/bundle-manifest.test.ts b/src/plugins/bundle-manifest.test.ts index 42a83722787..51f9bebbd10 100644 --- a/src/plugins/bundle-manifest.test.ts +++ b/src/plugins/bundle-manifest.test.ts @@ -40,6 +40,37 @@ function writeBundleManifest( fs.writeFileSync(path.join(rootDir, relativePath), JSON.stringify(manifest), "utf-8"); } +function writeJsonFile(rootDir: string, relativePath: string, value: unknown) { + mkdirSafe(path.dirname(path.join(rootDir, relativePath))); + fs.writeFileSync(path.join(rootDir, relativePath), JSON.stringify(value), "utf-8"); +} + +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", + }); + 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", + }); + return; + } + mkdirSafe(path.join(rootDir, "skills")); + writeBundleManifest(rootDir, CLAUDE_BUNDLE_MANIFEST_RELATIVE_PATH, { name: "No Hooks" }); +} + afterEach(() => { cleanupTrackedTempDirs(tempDirs); }); @@ -224,36 +255,7 @@ describe("bundle manifest parsing", () => { }, ] as const)("$name", ({ setupKind, expectedHooks, hasHooksCapability }) => { const rootDir = makeTempDir(); - mkdirSafe(path.join(rootDir, ".claude-plugin")); - if (setupKind === "default-hooks") { - mkdirSafe(path.join(rootDir, "hooks")); - fs.writeFileSync(path.join(rootDir, "hooks", "hooks.json"), '{"hooks":[]}', "utf-8"); - fs.writeFileSync( - path.join(rootDir, CLAUDE_BUNDLE_MANIFEST_RELATIVE_PATH), - JSON.stringify({ - name: "Hook Plugin", - description: "Claude hooks fixture", - }), - "utf-8", - ); - } else if (setupKind === "custom-hooks") { - mkdirSafe(path.join(rootDir, "custom-hooks")); - fs.writeFileSync( - path.join(rootDir, CLAUDE_BUNDLE_MANIFEST_RELATIVE_PATH), - JSON.stringify({ - name: "Custom Hook Plugin", - hooks: "custom-hooks", - }), - "utf-8", - ); - } else { - mkdirSafe(path.join(rootDir, "skills")); - fs.writeFileSync( - path.join(rootDir, CLAUDE_BUNDLE_MANIFEST_RELATIVE_PATH), - JSON.stringify({ name: "No Hooks" }), - "utf-8", - ); - } + setupClaudeHookFixture(rootDir, setupKind); const manifest = expectLoadedManifest(rootDir, "claude"); expect(manifest.hooks).toEqual(expectedHooks); expect(manifest.capabilities.includes("hooks")).toBe(hasHooksCapability); diff --git a/src/plugins/bundle-mcp.test.ts b/src/plugins/bundle-mcp.test.ts index 0acafc2cc4b..207b9a98fc4 100644 --- a/src/plugins/bundle-mcp.test.ts +++ b/src/plugins/bundle-mcp.test.ts @@ -28,6 +28,10 @@ async function expectResolvedPathEqual(actual: unknown, expected: string): Promi ); } +function expectNoDiagnostics(diagnostics: unknown[]) { + expect(diagnostics).toEqual([]); +} + const tempHarness = createBundleMcpTempHarness(); afterEach(async () => { @@ -98,7 +102,7 @@ describe("loadEnabledBundleMcpConfig", () => { const loadedServerPath = typeof loadedArgs?.[0] === "string" ? loadedArgs[0] : undefined; const resolvedPluginRoot = await fs.realpath(pluginRoot); - expect(loaded.diagnostics).toEqual([]); + expectNoDiagnostics(loaded.diagnostics); expect(isRecord(loadedServer) ? loadedServer.command : undefined).toBe("node"); expect(loadedArgs).toHaveLength(1); expect(loadedServerPath).toBeDefined(); @@ -191,7 +195,7 @@ describe("loadEnabledBundleMcpConfig", () => { const loadedEnv = isRecord(loadedServer) && isRecord(loadedServer.env) ? loadedServer.env : {}; - expect(loaded.diagnostics).toEqual([]); + expectNoDiagnostics(loaded.diagnostics); await expectResolvedPathEqual(loadedCwd, pluginRoot); expect(typeof loadedCommand).toBe("string"); expect(loadedArgs).toHaveLength(2); diff --git a/src/plugins/clawhub.test.ts b/src/plugins/clawhub.test.ts index 7b4da2a7e41..dfcc8c0fbfa 100644 --- a/src/plugins/clawhub.test.ts +++ b/src/plugins/clawhub.test.ts @@ -35,6 +35,21 @@ const { ClawHubRequestError } = await import("../infra/clawhub.js"); const { CLAWHUB_INSTALL_ERROR_CODE, formatClawHubSpecifier, installPluginFromClawHub } = await import("./clawhub.js"); +async function expectClawHubInstallError(params: { + setup?: () => void; + spec: string; + expected: { + ok: false; + code: (typeof CLAWHUB_INSTALL_ERROR_CODE)[keyof typeof CLAWHUB_INSTALL_ERROR_CODE]; + error: string; + }; +}) { + params.setup?.(); + await expect(installPluginFromClawHub({ spec: params.spec })).resolves.toMatchObject( + params.expected, + ); +} + describe("installPluginFromClawHub", () => { beforeEach(() => { parseClawHubPluginSpecMock.mockReset(); @@ -208,7 +223,6 @@ describe("installPluginFromClawHub", () => { }, }, ] as const)("$name", async ({ setup, spec, expected }) => { - setup(); - await expect(installPluginFromClawHub({ spec })).resolves.toMatchObject(expected); + await expectClawHubInstallError({ setup, spec, expected }); }); }); diff --git a/src/plugins/copy-bundled-plugin-metadata.test.ts b/src/plugins/copy-bundled-plugin-metadata.test.ts index 2d0ee949ed1..290aa07ab39 100644 --- a/src/plugins/copy-bundled-plugin-metadata.test.ts +++ b/src/plugins/copy-bundled-plugin-metadata.test.ts @@ -68,6 +68,10 @@ function bundledSkillPath(repoRoot: string, pluginId: string, ...relativePath: s return path.join(bundledPluginDir(repoRoot, pluginId), ...relativePath); } +function expectBundledSkills(repoRoot: string, pluginId: string, skills: string[]) { + expect(readBundledManifest(repoRoot, pluginId).skills).toEqual(skills); +} + function createTlonSkillPlugin(repoRoot: string, skillPath = "node_modules/@tloncorp/tlon-skill") { return createPlugin(repoRoot, { id: "tlon", @@ -117,8 +121,7 @@ describe("copyBundledPluginMetadata", () => { "utf8", ), ).toContain("ACP Router"); - const bundledManifest = readBundledManifest(repoRoot, "acpx"); - expect(bundledManifest.skills).toEqual(["./skills"]); + expectBundledSkills(repoRoot, "acpx", ["./skills"]); const packageJson = readBundledPackageJson(repoRoot, "acpx"); expect(packageJson.openclaw?.extensions).toEqual(["./index.js"]); }); @@ -172,8 +175,7 @@ describe("copyBundledPluginMetadata", () => { expect(fs.existsSync(path.join(bundledPluginDir(repoRoot, "tlon"), "node_modules"))).toBe( false, ); - const bundledManifest = readBundledManifest(repoRoot, "tlon"); - expect(bundledManifest.skills).toEqual(["./bundled-skills/@tloncorp/tlon-skill"]); + expectBundledSkills(repoRoot, "tlon", ["./bundled-skills/@tloncorp/tlon-skill"]); }); it("falls back to repo-root hoisted node_modules skill paths", () => { @@ -192,8 +194,7 @@ describe("copyBundledPluginMetadata", () => { "utf8", ), ).toContain("Hoisted Tlon Skill"); - const bundledManifest = readBundledManifest(repoRoot, "tlon"); - expect(bundledManifest.skills).toEqual(["./bundled-skills/@tloncorp/tlon-skill"]); + expectBundledSkills(repoRoot, "tlon", ["./bundled-skills/@tloncorp/tlon-skill"]); }); it("omits missing declared skill paths and removes stale generated outputs", () => { @@ -212,8 +213,7 @@ describe("copyBundledPluginMetadata", () => { copyBundledPluginMetadata({ repoRoot }); - const bundledManifest = readBundledManifest(repoRoot, "tlon"); - expect(bundledManifest.skills).toEqual([]); + expectBundledSkills(repoRoot, "tlon", []); expect(fs.existsSync(path.join(repoRoot, "dist", "extensions", "tlon", "bundled-skills"))).toBe( false, ); diff --git a/src/plugins/discovery.test.ts b/src/plugins/discovery.test.ts index 1297b36b5a3..903904b4a02 100644 --- a/src/plugins/discovery.test.ts +++ b/src/plugins/discovery.test.ts @@ -115,6 +115,23 @@ function createPackagePlugin(params: { } } +function createPackagePluginWithEntry(params: { + packageDir: string; + packageName: string; + pluginId?: string; + entryPath?: string; +}) { + const entryPath = params.entryPath ?? "src/index.ts"; + mkdirSafe(path.dirname(path.join(params.packageDir, entryPath))); + createPackagePlugin({ + packageDir: params.packageDir, + packageName: params.packageName, + extensions: [`./${entryPath}`], + ...(params.pluginId ? { pluginId: params.pluginId } : {}), + }); + writePluginEntry(path.join(params.packageDir, entryPath)); +} + function createBundleRoot(bundleDir: string, markerPath: string, manifest?: unknown) { mkdirSafe(path.dirname(path.join(bundleDir, markerPath))); if (manifest) { @@ -296,13 +313,11 @@ describe("discoverOpenClawPlugins", () => { name: "derives unscoped ids for scoped packages", setup: (stateDir: string) => { const packageDir = path.join(stateDir, "extensions", "voice-call-pack"); - mkdirSafe(path.join(packageDir, "src")); - createPackagePlugin({ + createPackagePluginWithEntry({ packageDir, packageName: "@openclaw/voice-call", - extensions: ["./src/index.ts"], + entryPath: "src/index.ts", }); - writePluginEntry(path.join(packageDir, "src", "index.ts")); return {}; }, includes: ["voice-call"], @@ -311,14 +326,12 @@ describe("discoverOpenClawPlugins", () => { name: "strips provider suffixes from package-derived ids", setup: (stateDir: string) => { const packageDir = path.join(stateDir, "extensions", "ollama-provider-pack"); - mkdirSafe(path.join(packageDir, "src")); - createPackagePlugin({ + createPackagePluginWithEntry({ packageDir, packageName: "@openclaw/ollama-provider", - extensions: ["./src/index.ts"], pluginId: "ollama", + entryPath: "src/index.ts", }); - writePluginEntry(path.join(packageDir, "src", "index.ts")); return {}; }, includes: ["ollama"], @@ -332,14 +345,12 @@ describe("discoverOpenClawPlugins", () => { ["microsoft-speech-pack", "@openclaw/microsoft-speech", "microsoft"], ] as const) { const packageDir = path.join(stateDir, "extensions", dirName); - mkdirSafe(path.join(packageDir, "src")); - createPackagePlugin({ + createPackagePluginWithEntry({ packageDir, packageName, - extensions: ["./src/index.ts"], pluginId, + entryPath: "src/index.ts", }); - writePluginEntry(path.join(packageDir, "src", "index.ts")); } return {}; }, @@ -350,12 +361,11 @@ describe("discoverOpenClawPlugins", () => { name: "treats configured directory paths as plugin packages", setup: (stateDir: string) => { const packageDir = path.join(stateDir, "packs", "demo-plugin-dir"); - createPackagePlugin({ + createPackagePluginWithEntry({ packageDir, packageName: "@openclaw/demo-plugin-dir", - extensions: ["./index.js"], + entryPath: "index.js", }); - fs.writeFileSync(path.join(packageDir, "index.js"), "module.exports = {}", "utf-8"); return { extraPaths: [packageDir] }; }, includes: ["demo-plugin-dir"], diff --git a/src/plugins/marketplace.test.ts b/src/plugins/marketplace.test.ts index d5f1512fd48..c1d386346b6 100644 --- a/src/plugins/marketplace.test.ts +++ b/src/plugins/marketplace.test.ts @@ -44,6 +44,19 @@ function mockRemoteMarketplaceClone(manifest: unknown) { }); } +async function expectRemoteMarketplaceError(params: { manifest: unknown; expectedError: string }) { + mockRemoteMarketplaceClone(params.manifest); + + const { listMarketplacePlugins } = await import("./marketplace.js"); + const result = await listMarketplacePlugins({ marketplace: "owner/repo" }); + + expect(result).toEqual({ + ok: false, + error: params.expectedError, + }); + expect(runCommandWithTimeoutMock).toHaveBeenCalledTimes(1); +} + describe("marketplace plugins", () => { afterEach(() => { installPluginFromPathMock.mockReset(); @@ -296,15 +309,6 @@ describe("marketplace plugins", () => { "remote marketplaces may not use HTTP(S) plugin paths", }, ] as const)("$name", async ({ manifest, expectedError }) => { - mockRemoteMarketplaceClone(manifest); - - const { listMarketplacePlugins } = await import("./marketplace.js"); - const result = await listMarketplacePlugins({ marketplace: "owner/repo" }); - - expect(result).toEqual({ - ok: false, - error: expectedError, - }); - expect(runCommandWithTimeoutMock).toHaveBeenCalledTimes(1); + await expectRemoteMarketplaceError({ manifest, expectedError }); }); });