From 045461208355ffa4246a225efc808a40cc39ca36 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 28 Mar 2026 02:33:23 +0000 Subject: [PATCH] test: dedupe plugin bundle and discovery suites --- src/plugins/bundle-claude-inspect.test.ts | 59 +- src/plugins/bundle-commands.test.ts | 87 +-- src/plugins/bundle-manifest.test.ts | 287 +++++---- src/plugins/bundle-mcp.test.ts | 141 ++--- .../bundled-provider-auth-env-vars.test.ts | 34 +- src/plugins/bundled-sources.test.ts | 117 ++-- src/plugins/clawhub.test.ts | 136 ++-- src/plugins/commands.test.ts | 328 +++++----- src/plugins/config-schema.test.ts | 18 +- src/plugins/conversation-binding.test.ts | 560 ++++++++--------- .../copy-bundled-plugin-metadata.test.ts | 239 ++++--- src/plugins/discovery.test.ts | 532 ++++++++-------- src/plugins/http-registry.test.ts | 55 +- src/plugins/interactive.test.ts | 588 ++++++++---------- src/plugins/logger.test.ts | 30 +- src/plugins/marketplace.test.ts | 185 +++--- src/plugins/memory-state.test.ts | 67 +- 17 files changed, 1703 insertions(+), 1760 deletions(-) diff --git a/src/plugins/bundle-claude-inspect.test.ts b/src/plugins/bundle-claude-inspect.test.ts index d089d8f4f06..28459753957 100644 --- a/src/plugins/bundle-claude-inspect.test.ts +++ b/src/plugins/bundle-claude-inspect.test.ts @@ -23,6 +23,15 @@ describe("Claude bundle plugin inspect integration", () => { return result.manifest; } + function expectClaudeManifestField(params: { + field: "skills" | "hooks" | "settingsFiles" | "capabilities"; + includes: readonly string[]; + }) { + const manifest = expectLoadedClaudeManifest(); + const values = manifest[params.field]; + expect(values).toEqual(expect.arrayContaining([...params.includes])); + } + beforeAll(() => { rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-claude-bundle-")); @@ -129,30 +138,26 @@ describe("Claude bundle plugin inspect integration", () => { expect(m.bundleFormat).toBe("claude"); }); - it("resolves skills from skills, commands, and agents paths", () => { - const manifest = expectLoadedClaudeManifest(); - expect(manifest.skills).toContain("skill-packs"); - expect(manifest.skills).toContain("extra-commands"); - // Agent and output style dirs are merged into skills so their .md files are discoverable - expect(manifest.skills).toContain("agents"); - expect(manifest.skills).toContain("output-styles"); - }); - - it("resolves hooks from default and declared paths", () => { - const manifest = expectLoadedClaudeManifest(); - // Default hooks/hooks.json path + declared custom-hooks - expect(manifest.hooks).toContain("hooks/hooks.json"); - expect(manifest.hooks).toContain("custom-hooks"); - }); - - it("detects settings files", () => { - expect(expectLoadedClaudeManifest().settingsFiles).toEqual(["settings.json"]); - }); - - it("detects all bundle capabilities", () => { - const caps = expectLoadedClaudeManifest().capabilities; - expect(caps).toEqual( - expect.arrayContaining([ + it.each([ + { + name: "resolves skills from skills, commands, and agents paths", + field: "skills" as const, + includes: ["skill-packs", "extra-commands", "agents", "output-styles"], + }, + { + name: "resolves hooks from default and declared paths", + field: "hooks" as const, + includes: ["hooks/hooks.json", "custom-hooks"], + }, + { + name: "detects settings files", + field: "settingsFiles" as const, + includes: ["settings.json"], + }, + { + name: "detects all bundle capabilities", + field: "capabilities" as const, + includes: [ "skills", "commands", "agents", @@ -161,8 +166,10 @@ describe("Claude bundle plugin inspect integration", () => { "lspServers", "outputStyles", "settings", - ]), - ); + ], + }, + ] as const)("$name", ({ field, includes }) => { + expectClaudeManifestField({ field, includes }); }); it("inspects MCP runtime support with supported and unsupported servers", () => { diff --git a/src/plugins/bundle-commands.test.ts b/src/plugins/bundle-commands.test.ts index aed0b37affb..bb719f67797 100644 --- a/src/plugins/bundle-commands.test.ts +++ b/src/plugins/bundle-commands.test.ts @@ -29,45 +29,60 @@ async function withBundleHomeEnv( } } +async function writeClaudeBundleCommandFixture(params: { + homeDir: string; + 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", + ); + for (const command of params.commands) { + await fs.mkdir(path.dirname(path.join(pluginRoot, command.relativePath)), { recursive: true }); + await fs.writeFile( + path.join(pluginRoot, command.relativePath), + [...command.contents, ""].join("\n"), + "utf-8", + ); + } +} + describe("loadEnabledClaudeBundleCommands", () => { it("loads enabled Claude bundle markdown commands and skips disabled-model-invocation entries", async () => { await withBundleHomeEnv("openclaw-bundle-commands", async ({ homeDir, workspaceDir }) => { - const pluginRoot = path.join(homeDir, ".openclaw", "extensions", "compound-bundle"); - await fs.mkdir(path.join(pluginRoot, ".claude-plugin"), { recursive: true }); - await fs.mkdir(path.join(pluginRoot, "commands", "workflows"), { recursive: true }); - await fs.writeFile( - path.join(pluginRoot, ".claude-plugin", "plugin.json"), - `${JSON.stringify({ name: "compound-bundle" }, null, 2)}\n`, - "utf-8", - ); - await fs.writeFile( - path.join(pluginRoot, "commands", "office-hours.md"), - [ - "---", - "description: Help with scoping and architecture", - "---", - "Give direct engineering advice.", - "", - ].join("\n"), - "utf-8", - ); - await fs.writeFile( - path.join(pluginRoot, "commands", "workflows", "review.md"), - [ - "---", - "name: workflows:review", - "description: Run a structured review", - "---", - "Review the code. $ARGUMENTS", - "", - ].join("\n"), - "utf-8", - ); - await fs.writeFile( - path.join(pluginRoot, "commands", "disabled.md"), - ["---", "disable-model-invocation: true", "---", "Do not load me.", ""].join("\n"), - "utf-8", - ); + 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, diff --git a/src/plugins/bundle-manifest.test.ts b/src/plugins/bundle-manifest.test.ts index d01a2603a52..42a83722787 100644 --- a/src/plugins/bundle-manifest.test.ts +++ b/src/plugins/bundle-manifest.test.ts @@ -31,152 +31,175 @@ function expectLoadedManifest(rootDir: string, bundleFormat: "codex" | "claude" return result.manifest; } +function writeBundleManifest( + rootDir: string, + relativePath: string, + manifest: Record, +) { + mkdirSafe(path.dirname(path.join(rootDir, relativePath))); + fs.writeFileSync(path.join(rootDir, relativePath), JSON.stringify(manifest), "utf-8"); +} + afterEach(() => { cleanupTrackedTempDirs(tempDirs); }); describe("bundle manifest parsing", () => { - it("detects and loads Codex bundle manifests", () => { - const rootDir = makeTempDir(); - mkdirSafe(path.join(rootDir, ".codex-plugin")); - mkdirSafe(path.join(rootDir, "skills")); - mkdirSafe(path.join(rootDir, "hooks")); - fs.writeFileSync( - path.join(rootDir, CODEX_BUNDLE_MANIFEST_RELATIVE_PATH), - JSON.stringify({ + it.each([ + { + 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"], + }, + }, + apps: { + sample: { + title: "Sample App", + }, + }, + }); + }, + expected: { + id: "sample-bundle", name: "Sample Bundle", description: "Codex fixture", - skills: "skills", - hooks: "hooks", - mcpServers: { - sample: { - command: "node", - args: ["server.js"], - }, - }, - apps: { - sample: { - title: "Sample App", - }, - }, - }), - "utf-8", - ); - - expect(detectBundleManifestFormat(rootDir)).toBe("codex"); - expect(expectLoadedManifest(rootDir, "codex")).toMatchObject({ - id: "sample-bundle", - name: "Sample Bundle", - description: "Codex fixture", - bundleFormat: "codex", - skills: ["skills"], - hooks: ["hooks"], - capabilities: expect.arrayContaining(["hooks", "skills", "mcpServers", "apps"]), - }); - }); - - it("detects and loads Claude bundle manifests from the component layout", () => { - const rootDir = makeTempDir(); - mkdirSafe(path.join(rootDir, ".claude-plugin")); - mkdirSafe(path.join(rootDir, "skill-packs", "starter")); - mkdirSafe(path.join(rootDir, "commands-pack")); - mkdirSafe(path.join(rootDir, "agents-pack")); - mkdirSafe(path.join(rootDir, "hooks-pack")); - mkdirSafe(path.join(rootDir, "mcp")); - mkdirSafe(path.join(rootDir, "lsp")); - mkdirSafe(path.join(rootDir, "styles")); - mkdirSafe(path.join(rootDir, "hooks")); - fs.writeFileSync(path.join(rootDir, "hooks", "hooks.json"), '{"hooks":[]}', "utf-8"); - fs.writeFileSync(path.join(rootDir, "settings.json"), '{"hideThinkingBlock":true}', "utf-8"); - fs.writeFileSync( - path.join(rootDir, CLAUDE_BUNDLE_MANIFEST_RELATIVE_PATH), - JSON.stringify({ + bundleFormat: "codex", + skills: ["skills"], + hooks: ["hooks"], + capabilities: expect.arrayContaining(["hooks", "skills", "mcpServers", "apps"]), + }, + }, + { + 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", + }); + }, + expected: { + id: "claude-sample", 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", - }), - "utf-8", - ); - - expect(detectBundleManifestFormat(rootDir)).toBe("claude"); - expect(expectLoadedManifest(rootDir, "claude")).toMatchObject({ - id: "claude-sample", - name: "Claude Sample", - description: "Claude fixture", - bundleFormat: "claude", - skills: ["skill-packs/starter", "commands-pack", "agents-pack", "styles"], - settingsFiles: ["settings.json"], - hooks: ["hooks/hooks.json", "hooks-pack"], - capabilities: expect.arrayContaining([ - "hooks", - "skills", - "commands", - "agents", - "mcpServers", - "lspServers", - "outputStyles", - "settings", - ]), - }); - }); - - it("detects and loads Cursor bundle manifests", () => { - const rootDir = makeTempDir(); - mkdirSafe(path.join(rootDir, ".cursor-plugin")); - mkdirSafe(path.join(rootDir, "skills")); - mkdirSafe(path.join(rootDir, ".cursor", "commands")); - mkdirSafe(path.join(rootDir, ".cursor", "rules")); - mkdirSafe(path.join(rootDir, ".cursor", "agents")); - fs.writeFileSync(path.join(rootDir, ".cursor", "hooks.json"), '{"hooks":[]}', "utf-8"); - fs.writeFileSync( - path.join(rootDir, CURSOR_BUNDLE_MANIFEST_RELATIVE_PATH), - JSON.stringify({ + bundleFormat: "claude", + skills: ["skill-packs/starter", "commands-pack", "agents-pack", "styles"], + settingsFiles: ["settings.json"], + hooks: ["hooks/hooks.json", "hooks-pack"], + capabilities: expect.arrayContaining([ + "hooks", + "skills", + "commands", + "agents", + "mcpServers", + "lspServers", + "outputStyles", + "settings", + ]), + }, + }, + { + 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", + }); + fs.writeFileSync(path.join(rootDir, ".mcp.json"), '{"servers":{}}', "utf-8"); + }, + expected: { + id: "cursor-sample", name: "Cursor Sample", description: "Cursor fixture", - mcpServers: "./.mcp.json", + bundleFormat: "cursor", + skills: ["skills", ".cursor/commands"], + hooks: [], + capabilities: expect.arrayContaining([ + "skills", + "commands", + "agents", + "rules", + "hooks", + "mcpServers", + ]), + }, + }, + { + 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", + ); + }, + expected: (rootDir: string) => ({ + id: path.basename(rootDir).toLowerCase(), + skills: ["skills", "commands"], + settingsFiles: ["settings.json"], + capabilities: expect.arrayContaining(["skills", "commands", "settings"]), }), - "utf-8", - ); - fs.writeFileSync(path.join(rootDir, ".mcp.json"), '{"servers":{}}', "utf-8"); - - expect(detectBundleManifestFormat(rootDir)).toBe("cursor"); - expect(expectLoadedManifest(rootDir, "cursor")).toMatchObject({ - id: "cursor-sample", - name: "Cursor Sample", - description: "Cursor fixture", - bundleFormat: "cursor", - skills: ["skills", ".cursor/commands"], - hooks: [], - capabilities: expect.arrayContaining([ - "skills", - "commands", - "agents", - "rules", - "hooks", - "mcpServers", - ]), - }); - }); - - it("detects manifestless Claude bundles from the default layout", () => { + }, + ] as const)("$name", ({ bundleFormat, setup, expected }) => { const rootDir = makeTempDir(); - mkdirSafe(path.join(rootDir, "commands")); - mkdirSafe(path.join(rootDir, "skills")); - fs.writeFileSync(path.join(rootDir, "settings.json"), '{"hideThinkingBlock":true}', "utf-8"); + setup(rootDir); - expect(detectBundleManifestFormat(rootDir)).toBe("claude"); - const manifest = expectLoadedManifest(rootDir, "claude"); - expect(manifest.id).toBe(path.basename(rootDir).toLowerCase()); - expect(manifest.skills).toEqual(["skills", "commands"]); - expect(manifest.settingsFiles).toEqual(["settings.json"]); - expect(manifest.capabilities).toEqual( - expect.arrayContaining(["skills", "commands", "settings"]), + expect(detectBundleManifestFormat(rootDir)).toBe(bundleFormat); + expect(expectLoadedManifest(rootDir, bundleFormat)).toMatchObject( + typeof expected === "function" ? expected(rootDir) : expected, ); }); diff --git a/src/plugins/bundle-mcp.test.ts b/src/plugins/bundle-mcp.test.ts index a7d67ebae2c..0acafc2cc4b 100644 --- a/src/plugins/bundle-mcp.test.ts +++ b/src/plugins/bundle-mcp.test.ts @@ -52,6 +52,29 @@ async function withBundleHomeEnv( } } +function createEnabledBundleConfig(pluginIds: string[]): OpenClawConfig { + return { + plugins: { + entries: Object.fromEntries(pluginIds.map((pluginId) => [pluginId, { enabled: true }])), + }, + }; +} + +async function writeInlineClaudeBundleManifest(params: { + homeDir: string; + pluginId: string; + manifest: Record; +}) { + 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", + ); + return pluginRoot; +} + describe("loadEnabledBundleMcpConfig", () => { it("loads enabled Claude bundle MCP config and absolutizes relative args", async () => { await withBundleHomeEnv("openclaw-bundle-mcp", async ({ homeDir, workspaceDir }) => { @@ -91,57 +114,43 @@ describe("loadEnabledBundleMcpConfig", () => { it("merges inline bundle MCP servers and skips disabled bundles", async () => { await withBundleHomeEnv("openclaw-bundle-inline", async ({ homeDir, workspaceDir }) => { - const enabledRoot = path.join(homeDir, ".openclaw", "extensions", "inline-enabled"); - const disabledRoot = path.join(homeDir, ".openclaw", "extensions", "inline-disabled"); - await fs.mkdir(path.join(enabledRoot, ".claude-plugin"), { recursive: true }); - await fs.mkdir(path.join(disabledRoot, ".claude-plugin"), { recursive: true }); - await fs.writeFile( - path.join(enabledRoot, ".claude-plugin", "plugin.json"), - `${JSON.stringify( - { - name: "inline-enabled", - mcpServers: { - enabledProbe: { - command: "node", - args: ["./enabled.mjs"], - }, + await writeInlineClaudeBundleManifest({ + homeDir, + pluginId: "inline-enabled", + manifest: { + name: "inline-enabled", + mcpServers: { + enabledProbe: { + command: "node", + args: ["./enabled.mjs"], }, }, - null, - 2, - )}\n`, - "utf-8", - ); - await fs.writeFile( - path.join(disabledRoot, ".claude-plugin", "plugin.json"), - `${JSON.stringify( - { - name: "inline-disabled", - mcpServers: { - disabledProbe: { - command: "node", - args: ["./disabled.mjs"], - }, - }, - }, - null, - 2, - )}\n`, - "utf-8", - ); - - const config: OpenClawConfig = { - plugins: { - entries: { - "inline-enabled": { enabled: true }, - "inline-disabled": { enabled: false }, - }, }, - }; + }); + await writeInlineClaudeBundleManifest({ + homeDir, + pluginId: "inline-disabled", + manifest: { + name: "inline-disabled", + mcpServers: { + disabledProbe: { + command: "node", + args: ["./disabled.mjs"], + }, + }, + }, + }); const loaded = loadEnabledBundleMcpConfig({ workspaceDir, - cfg: config, + cfg: { + plugins: { + entries: { + ...createEnabledBundleConfig(["inline-enabled"]).plugins?.entries, + "inline-disabled": { enabled: false }, + }, + }, + }, }); expect(loaded.config.mcpServers.enabledProbe).toBeDefined(); @@ -153,39 +162,27 @@ describe("loadEnabledBundleMcpConfig", () => { await withBundleHomeEnv( "openclaw-bundle-inline-placeholder", async ({ homeDir, workspaceDir }) => { - const pluginRoot = path.join(homeDir, ".openclaw", "extensions", "inline-claude"); - await fs.mkdir(path.join(pluginRoot, ".claude-plugin"), { recursive: true }); - await fs.writeFile( - path.join(pluginRoot, ".claude-plugin", "plugin.json"), - `${JSON.stringify( - { - name: "inline-claude", - mcpServers: { - inlineProbe: { - command: "${CLAUDE_PLUGIN_ROOT}/bin/server.sh", - args: ["${CLAUDE_PLUGIN_ROOT}/servers/probe.mjs", "./local-probe.mjs"], - cwd: "${CLAUDE_PLUGIN_ROOT}", - env: { - PLUGIN_ROOT: "${CLAUDE_PLUGIN_ROOT}", - }, + const pluginRoot = await writeInlineClaudeBundleManifest({ + homeDir, + pluginId: "inline-claude", + manifest: { + name: "inline-claude", + mcpServers: { + inlineProbe: { + command: "${CLAUDE_PLUGIN_ROOT}/bin/server.sh", + args: ["${CLAUDE_PLUGIN_ROOT}/servers/probe.mjs", "./local-probe.mjs"], + cwd: "${CLAUDE_PLUGIN_ROOT}", + env: { + PLUGIN_ROOT: "${CLAUDE_PLUGIN_ROOT}", }, }, }, - null, - 2, - )}\n`, - "utf-8", - ); + }, + }); const loaded = loadEnabledBundleMcpConfig({ workspaceDir, - cfg: { - plugins: { - entries: { - "inline-claude": { enabled: true }, - }, - }, - }, + cfg: createEnabledBundleConfig(["inline-claude"]), }); const loadedServer = loaded.config.mcpServers.inlineProbe; const loadedArgs = getServerArgs(loadedServer); diff --git a/src/plugins/bundled-provider-auth-env-vars.test.ts b/src/plugins/bundled-provider-auth-env-vars.test.ts index b41cd5c5c9d..55853375cc5 100644 --- a/src/plugins/bundled-provider-auth-env-vars.test.ts +++ b/src/plugins/bundled-provider-auth-env-vars.test.ts @@ -15,6 +15,20 @@ import { installGeneratedPluginTempRootCleanup(); +function expectGeneratedAuthEnvVarModuleState(params: { + tempRoot: string; + expectedChanged: boolean; + expectedWrote: boolean; +}) { + const result = writeBundledProviderAuthEnvVarModule({ + repoRoot: params.tempRoot, + outputPath: "src/plugins/bundled-provider-auth-env-vars.generated.ts", + check: true, + }); + expect(result.changed).toBe(params.expectedChanged); + expect(result.wrote).toBe(params.expectedWrote); +} + describe("bundled provider auth env vars", () => { it("matches the generated manifest snapshot", () => { expect(BUNDLED_PROVIDER_AUTH_ENV_VAR_CANDIDATES).toEqual( @@ -70,13 +84,11 @@ describe("bundled provider auth env vars", () => { }); expect(initial.wrote).toBe(true); - const current = writeBundledProviderAuthEnvVarModule({ - repoRoot: tempRoot, - outputPath: "src/plugins/bundled-provider-auth-env-vars.generated.ts", - check: true, + expectGeneratedAuthEnvVarModuleState({ + tempRoot, + expectedChanged: false, + expectedWrote: false, }); - expect(current.changed).toBe(false); - expect(current.wrote).toBe(false); fs.writeFileSync( path.join(tempRoot, "src/plugins/bundled-provider-auth-env-vars.generated.ts"), @@ -84,12 +96,10 @@ describe("bundled provider auth env vars", () => { "utf8", ); - const stale = writeBundledProviderAuthEnvVarModule({ - repoRoot: tempRoot, - outputPath: "src/plugins/bundled-provider-auth-env-vars.generated.ts", - check: true, + expectGeneratedAuthEnvVarModuleState({ + tempRoot, + expectedChanged: true, + expectedWrote: false, }); - expect(stale.changed).toBe(true); - expect(stale.wrote).toBe(false); }); }); diff --git a/src/plugins/bundled-sources.test.ts b/src/plugins/bundled-sources.test.ts index 5900597d0f3..b6151101bba 100644 --- a/src/plugins/bundled-sources.test.ts +++ b/src/plugins/bundled-sources.test.ts @@ -16,6 +16,24 @@ vi.mock("./manifest.js", () => ({ loadPluginManifest: (...args: unknown[]) => loadPluginManifestMock(...args), })); +function createBundledCandidate(params: { + rootDir: string; + packageName: string; + npmSpec?: string; + origin?: "bundled" | "global"; +}) { + return { + origin: params.origin ?? "bundled", + rootDir: params.rootDir, + packageName: params.packageName, + packageManifest: { + install: { + npmSpec: params.npmSpec ?? params.packageName, + }, + }, + }; +} + function setBundledDiscoveryCandidates(candidates: unknown[]) { discoverOpenClawPluginsMock.mockReturnValue({ candidates, @@ -23,6 +41,18 @@ function setBundledDiscoveryCandidates(candidates: unknown[]) { }); } +function setBundledManifestIdsByRoot(manifestIds: Record) { + loadPluginManifestMock.mockImplementation((rootDir: string) => + rootDir in manifestIds + ? { ok: true, manifest: { id: manifestIds[rootDir] } } + : { + ok: false, + error: "invalid manifest", + manifestPath: `${rootDir}/openclaw.plugin.json`, + }, + ); +} + function expectBundledSourceLookup( lookup: Parameters[0]["lookup"], expected: @@ -48,48 +78,28 @@ describe("bundled plugin sources", () => { }); it("resolves bundled sources keyed by plugin id", () => { - discoverOpenClawPluginsMock.mockReturnValue({ - candidates: [ - { - origin: "global", - rootDir: "/global/feishu", - packageName: "@openclaw/feishu", - packageManifest: { install: { npmSpec: "@openclaw/feishu" } }, - }, - { - origin: "bundled", - rootDir: "/app/extensions/feishu", - packageName: "@openclaw/feishu", - packageManifest: { install: { npmSpec: "@openclaw/feishu" } }, - }, - { - origin: "bundled", - rootDir: "/app/extensions/feishu-dup", - packageName: "@openclaw/feishu", - packageManifest: { install: { npmSpec: "@openclaw/feishu" } }, - }, - { - origin: "bundled", - rootDir: "/app/extensions/msteams", - packageName: "@openclaw/msteams", - packageManifest: { install: { npmSpec: "@openclaw/msteams" } }, - }, - ], - diagnostics: [], - }); - - loadPluginManifestMock.mockImplementation((rootDir: string) => { - if (rootDir === "/app/extensions/feishu") { - return { ok: true, manifest: { id: "feishu" } }; - } - if (rootDir === "/app/extensions/msteams") { - return { ok: true, manifest: { id: "msteams" } }; - } - return { - ok: false, - error: "invalid manifest", - manifestPath: `${rootDir}/openclaw.plugin.json`, - }; + setBundledDiscoveryCandidates([ + createBundledCandidate({ + origin: "global", + rootDir: "/global/feishu", + packageName: "@openclaw/feishu", + }), + createBundledCandidate({ + rootDir: "/app/extensions/feishu", + packageName: "@openclaw/feishu", + }), + createBundledCandidate({ + rootDir: "/app/extensions/feishu-dup", + packageName: "@openclaw/feishu", + }), + createBundledCandidate({ + rootDir: "/app/extensions/msteams", + packageName: "@openclaw/msteams", + }), + ]); + setBundledManifestIdsByRoot({ + "/app/extensions/feishu": "feishu", + "/app/extensions/msteams": "msteams", }); const map = resolveBundledPluginSources({}); @@ -125,26 +135,19 @@ describe("bundled plugin sources", () => { ], ] as const)("%s", (_name, lookup, expected) => { setBundledDiscoveryCandidates([ - { - origin: "bundled", + createBundledCandidate({ rootDir: "/app/extensions/feishu", packageName: "@openclaw/feishu", - packageManifest: { install: { npmSpec: "@openclaw/feishu" } }, - }, - { - origin: "bundled", + }), + createBundledCandidate({ rootDir: "/app/extensions/diffs", packageName: "@openclaw/diffs", - packageManifest: { install: { npmSpec: "@openclaw/diffs" } }, - }, + }), ]); - loadPluginManifestMock.mockReturnValue({ ok: true, manifest: { id: "feishu" } }); - loadPluginManifestMock.mockImplementation((rootDir: string) => ({ - ok: true, - manifest: { - id: rootDir === "/app/extensions/diffs" ? "diffs" : "feishu", - }, - })); + setBundledManifestIdsByRoot({ + "/app/extensions/feishu": "feishu", + "/app/extensions/diffs": "diffs", + }); expectBundledSourceLookup(lookup, expected); }); diff --git a/src/plugins/clawhub.test.ts b/src/plugins/clawhub.test.ts index ee824fdd325..7b4da2a7e41 100644 --- a/src/plugins/clawhub.test.ts +++ b/src/plugins/clawhub.test.ts @@ -134,67 +134,81 @@ describe("installPluginFromClawHub", () => { expect(warn).not.toHaveBeenCalled(); }); - it("rejects packages whose plugin API range exceeds the runtime version", async () => { - resolveCompatibilityHostVersionMock.mockReturnValueOnce("2026.3.21"); - - await expect(installPluginFromClawHub({ spec: "clawhub:demo" })).resolves.toMatchObject({ - ok: false, - code: CLAWHUB_INSTALL_ERROR_CODE.INCOMPATIBLE_PLUGIN_API, - error: - 'Plugin "demo" requires plugin API >=2026.3.22, but this OpenClaw runtime exposes 2026.3.21.', - }); - }); - - it("rejects skill families and redirects to skills install", async () => { - fetchClawHubPackageDetailMock.mockResolvedValueOnce({ - package: { - name: "calendar", - displayName: "Calendar", - family: "skill", - channel: "official", - isOfficial: true, - createdAt: 0, - updatedAt: 0, + it.each([ + { + name: "rejects packages whose plugin API range exceeds the runtime version", + setup: () => { + resolveCompatibilityHostVersionMock.mockReturnValueOnce("2026.3.21"); }, - }); - - await expect(installPluginFromClawHub({ spec: "clawhub:calendar" })).resolves.toMatchObject({ - ok: false, - code: CLAWHUB_INSTALL_ERROR_CODE.SKILL_PACKAGE, - error: '"calendar" is a skill. Use "openclaw skills install calendar" instead.', - }); - }); - - it("returns typed package-not-found failures", async () => { - fetchClawHubPackageDetailMock.mockRejectedValueOnce( - new ClawHubRequestError({ - path: "/api/v1/packages/demo", - status: 404, - body: "Package not found", - }), - ); - - await expect(installPluginFromClawHub({ spec: "clawhub:demo" })).resolves.toMatchObject({ - ok: false, - code: CLAWHUB_INSTALL_ERROR_CODE.PACKAGE_NOT_FOUND, - error: "Package not found on ClawHub.", - }); - }); - - it("returns typed version-not-found failures", async () => { - parseClawHubPluginSpecMock.mockReturnValueOnce({ name: "demo", version: "9.9.9" }); - fetchClawHubPackageVersionMock.mockRejectedValueOnce( - new ClawHubRequestError({ - path: "/api/v1/packages/demo/versions/9.9.9", - status: 404, - body: "Version not found", - }), - ); - - await expect(installPluginFromClawHub({ spec: "clawhub:demo@9.9.9" })).resolves.toMatchObject({ - ok: false, - code: CLAWHUB_INSTALL_ERROR_CODE.VERSION_NOT_FOUND, - error: "Version not found on ClawHub: demo@9.9.9.", - }); + spec: "clawhub:demo", + expected: { + ok: false, + code: CLAWHUB_INSTALL_ERROR_CODE.INCOMPATIBLE_PLUGIN_API, + error: + 'Plugin "demo" requires plugin API >=2026.3.22, but this OpenClaw runtime exposes 2026.3.21.', + }, + }, + { + name: "rejects skill families and redirects to skills install", + setup: () => { + fetchClawHubPackageDetailMock.mockResolvedValueOnce({ + package: { + name: "calendar", + displayName: "Calendar", + family: "skill", + channel: "official", + isOfficial: true, + createdAt: 0, + updatedAt: 0, + }, + }); + }, + spec: "clawhub:calendar", + expected: { + ok: false, + code: CLAWHUB_INSTALL_ERROR_CODE.SKILL_PACKAGE, + error: '"calendar" is a skill. Use "openclaw skills install calendar" instead.', + }, + }, + { + name: "returns typed package-not-found failures", + setup: () => { + fetchClawHubPackageDetailMock.mockRejectedValueOnce( + new ClawHubRequestError({ + path: "/api/v1/packages/demo", + status: 404, + body: "Package not found", + }), + ); + }, + spec: "clawhub:demo", + expected: { + ok: false, + code: CLAWHUB_INSTALL_ERROR_CODE.PACKAGE_NOT_FOUND, + error: "Package not found on ClawHub.", + }, + }, + { + name: "returns typed version-not-found failures", + setup: () => { + parseClawHubPluginSpecMock.mockReturnValueOnce({ name: "demo", version: "9.9.9" }); + fetchClawHubPackageVersionMock.mockRejectedValueOnce( + new ClawHubRequestError({ + path: "/api/v1/packages/demo/versions/9.9.9", + status: 404, + body: "Version not found", + }), + ); + }, + spec: "clawhub:demo@9.9.9", + expected: { + ok: false, + code: CLAWHUB_INSTALL_ERROR_CODE.VERSION_NOT_FOUND, + error: "Version not found on ClawHub: demo@9.9.9.", + }, + }, + ] as const)("$name", async ({ setup, spec, expected }) => { + setup(); + await expect(installPluginFromClawHub({ spec })).resolves.toMatchObject(expected); }); }); diff --git a/src/plugins/commands.test.ts b/src/plugins/commands.test.ts index b8064d18e4b..ddf714897c4 100644 --- a/src/plugins/commands.test.ts +++ b/src/plugins/commands.test.ts @@ -19,6 +19,21 @@ async function importCommandsModule(cacheBust: string): Promise return (await import(`${commandsModuleUrl}?t=${cacheBust}`)) as CommandsModule; } +function createVoiceCommand(overrides: Partial[1]> = {}) { + return { + name: "voice", + description: "Voice command", + handler: async () => ({ text: "ok" }), + ...overrides, + }; +} + +function resolveBindingConversationFromCommand( + params: Parameters[0], +) { + return __testing.resolveBindingConversationFromCommand(params); +} + beforeEach(() => { setActivePluginRegistry(createTestRegistry([])); }); @@ -28,30 +43,34 @@ afterEach(() => { }); describe("registerPluginCommand", () => { - it("rejects malformed runtime command shapes", () => { - const invalidName = registerPluginCommand( - "demo-plugin", - // Runtime plugin payloads are untyped; guard at boundary. - { + it.each([ + { + name: "rejects invalid command names", + command: { + // Runtime plugin payloads are untyped; guard at boundary. name: undefined as unknown as string, description: "Demo", handler: async () => ({ text: "ok" }), }, - ); - expect(invalidName).toEqual({ - ok: false, - error: "Command name must be a string", - }); - - const invalidDescription = registerPluginCommand("demo-plugin", { - name: "demo", - description: undefined as unknown as string, - handler: async () => ({ text: "ok" }), - }); - expect(invalidDescription).toEqual({ - ok: false, - error: "Command description must be a string", - }); + expected: { + ok: false, + error: "Command name must be a string", + }, + }, + { + name: "rejects invalid command descriptions", + command: { + name: "demo", + description: undefined as unknown as string, + handler: async () => ({ text: "ok" }), + }, + expected: { + ok: false, + error: "Command description must be a string", + }, + }, + ] as const)("$name", ({ command, expected }) => { + expect(registerPluginCommand("demo-plugin", command)).toEqual(expected); }); it("normalizes command metadata for downstream consumers", () => { @@ -78,15 +97,16 @@ describe("registerPluginCommand", () => { }); it("supports provider-specific native command aliases", () => { - const result = registerPluginCommand("demo-plugin", { - name: "voice", - nativeNames: { - default: "talkvoice", - discord: "discordvoice", - }, - description: "Demo command", - handler: async () => ({ text: "ok" }), - }); + const result = registerPluginCommand( + "demo-plugin", + createVoiceCommand({ + nativeNames: { + default: "talkvoice", + discord: "discordvoice", + }, + description: "Demo command", + }), + ); expect(result).toEqual({ ok: true }); expect(getPluginCommandSpecs()).toEqual([ @@ -120,14 +140,14 @@ describe("registerPluginCommand", () => { first.clearPluginCommands(); expect( - first.registerPluginCommand("demo-plugin", { - name: "voice", - nativeNames: { - telegram: "voice", - }, - description: "Voice command", - handler: async () => ({ text: "ok" }), - }), + first.registerPluginCommand( + "demo-plugin", + createVoiceCommand({ + nativeNames: { + telegram: "voice", + }, + }), + ), ).toEqual({ ok: true }); expect(second.getPluginCommandSpecs("telegram")).toEqual([ @@ -148,16 +168,17 @@ describe("registerPluginCommand", () => { }); it("matches provider-specific native aliases back to the canonical command", () => { - const result = registerPluginCommand("demo-plugin", { - name: "voice", - nativeNames: { - default: "talkvoice", - discord: "discordvoice", - }, - description: "Demo command", - acceptsArgs: true, - handler: async () => ({ text: "ok" }), - }); + const result = registerPluginCommand( + "demo-plugin", + createVoiceCommand({ + nativeNames: { + default: "talkvoice", + discord: "discordvoice", + }, + description: "Demo command", + acceptsArgs: true, + }), + ); expect(result).toEqual({ ok: true }); expect(matchPluginCommand("/talkvoice now")).toMatchObject({ @@ -170,155 +191,152 @@ describe("registerPluginCommand", () => { }); }); - it("rejects provider aliases that collide with another registered command", () => { - expect( - registerPluginCommand("demo-plugin", { - name: "voice", - nativeNames: { - telegram: "pair_device", - }, - description: "Voice command", - handler: async () => ({ text: "ok" }), - }), - ).toEqual({ ok: true }); - - expect( - registerPluginCommand("other-plugin", { + it.each([ + { + name: "rejects provider aliases that collide with another registered command", + setup: () => + registerPluginCommand( + "demo-plugin", + createVoiceCommand({ + nativeNames: { + telegram: "pair_device", + }, + }), + ), + candidate: { name: "pair", nativeNames: { telegram: "pair_device", }, description: "Pair command", handler: async () => ({ text: "ok" }), - }), - ).toEqual({ - ok: false, - error: 'Command "pair_device" already registered by plugin "demo-plugin"', - }); - }); - - it("rejects reserved provider aliases", () => { - expect( - registerPluginCommand("demo-plugin", { - name: "voice", + }, + expected: { + ok: false, + error: 'Command "pair_device" already registered by plugin "demo-plugin"', + }, + }, + { + name: "rejects reserved provider aliases", + candidate: createVoiceCommand({ nativeNames: { telegram: "help", }, - description: "Voice command", - handler: async () => ({ text: "ok" }), }), - ).toEqual({ - ok: false, - error: - 'Native command alias "telegram" invalid: Command name "help" is reserved by a built-in command', - }); + expected: { + ok: false, + error: + 'Native command alias "telegram" invalid: Command name "help" is reserved by a built-in command', + }, + }, + ] as const)("$name", ({ setup, candidate, expected }) => { + setup?.(); + expect(registerPluginCommand("other-plugin", candidate)).toEqual(expected); }); - it("resolves Discord DM command bindings with the user target prefix intact", () => { - expect( - __testing.resolveBindingConversationFromCommand({ + it.each([ + { + name: "resolves Discord DM command bindings with the user target prefix intact", + params: { channel: "discord", from: "discord:1177378744822943744", to: "slash:1177378744822943744", accountId: "default", - }), - ).toEqual({ - channel: "discord", - accountId: "default", - conversationId: "user:1177378744822943744", - }); - }); - - it("resolves Discord guild command bindings with the channel target prefix intact", () => { - expect( - __testing.resolveBindingConversationFromCommand({ + }, + expected: { + channel: "discord", + accountId: "default", + conversationId: "user:1177378744822943744", + }, + }, + { + name: "resolves Discord guild command bindings with the channel target prefix intact", + params: { channel: "discord", from: "discord:channel:1480554272859881494", accountId: "default", - }), - ).toEqual({ - channel: "discord", - accountId: "default", - conversationId: "channel:1480554272859881494", - }); - }); - - it("resolves Discord thread command bindings with parent channel context intact", () => { - expect( - __testing.resolveBindingConversationFromCommand({ + }, + expected: { + channel: "discord", + accountId: "default", + conversationId: "channel:1480554272859881494", + }, + }, + { + name: "resolves Discord thread command bindings with parent channel context intact", + params: { channel: "discord", from: "discord:channel:1480554272859881494", accountId: "default", messageThreadId: "thread-42", threadParentId: "channel-parent-7", - }), - ).toEqual({ - channel: "discord", - accountId: "default", - conversationId: "channel:1480554272859881494", - parentConversationId: "channel-parent-7", - threadId: "thread-42", - }); - }); - - it("resolves Telegram topic command bindings without a Telegram registry entry", () => { - expect( - __testing.resolveBindingConversationFromCommand({ + }, + expected: { + channel: "discord", + accountId: "default", + conversationId: "channel:1480554272859881494", + parentConversationId: "channel-parent-7", + threadId: "thread-42", + }, + }, + { + name: "resolves Telegram topic command bindings without a Telegram registry entry", + params: { channel: "telegram", from: "telegram:group:-100123", to: "telegram:group:-100123:topic:77", accountId: "default", - }), - ).toEqual({ - channel: "telegram", - accountId: "default", - conversationId: "-100123", - threadId: 77, - }); - }); - - it("resolves Telegram native slash command bindings using the From peer", () => { - expect( - __testing.resolveBindingConversationFromCommand({ + }, + expected: { + channel: "telegram", + accountId: "default", + conversationId: "-100123", + threadId: 77, + }, + }, + { + name: "resolves Telegram native slash command bindings using the From peer", + params: { channel: "telegram", from: "telegram:group:-100123:topic:77", to: "slash:12345", accountId: "default", messageThreadId: 77, - }), - ).toEqual({ - channel: "telegram", - accountId: "default", - conversationId: "-100123", - threadId: 77, - }); - }); - - it("falls back to the parsed From threadId for Telegram slash commands when messageThreadId is missing", () => { - expect( - __testing.resolveBindingConversationFromCommand({ + }, + expected: { + channel: "telegram", + accountId: "default", + conversationId: "-100123", + threadId: 77, + }, + }, + { + name: "falls back to the parsed From threadId for Telegram slash commands when messageThreadId is missing", + params: { channel: "telegram", from: "telegram:group:-100123:topic:77", to: "slash:12345", accountId: "default", - }), - ).toEqual({ - channel: "telegram", - accountId: "default", - conversationId: "-100123", - threadId: 77, - }); - }); - - it("does not resolve binding conversations for unsupported command channels", () => { - expect( - __testing.resolveBindingConversationFromCommand({ + }, + expected: { + channel: "telegram", + accountId: "default", + conversationId: "-100123", + threadId: 77, + }, + }, + { + name: "does not resolve binding conversations for unsupported command channels", + params: { channel: "slack", from: "slack:U123", to: "C456", accountId: "default", - }), - ).toBeNull(); + }, + expected: null, + }, + ] as const)("$name", ({ params, expected }) => { + expect(resolveBindingConversationFromCommand(params)).toEqual(expected); }); it("does not expose binding APIs to plugin commands on unsupported channels", async () => { diff --git a/src/plugins/config-schema.test.ts b/src/plugins/config-schema.test.ts index 98c1a9a0961..754d0444c10 100644 --- a/src/plugins/config-schema.test.ts +++ b/src/plugins/config-schema.test.ts @@ -71,11 +71,19 @@ describe("buildPluginConfigSchema", () => { describe("emptyPluginConfigSchema", () => { it("accepts undefined and empty objects only", () => { const schema = emptyPluginConfigSchema(); - expect(schema.safeParse?.(undefined)).toEqual({ success: true, data: undefined }); - expect(schema.safeParse?.({})).toEqual({ success: true, data: {} }); - expect(schema.safeParse?.({ nope: true })).toEqual({ - success: false, - error: { issues: [{ path: [], message: "config must be empty" }] }, + expect(schema.safeParse).toBeDefined(); + expect([ + [undefined, { success: true, data: undefined }], + [{}, { success: true, data: {} }], + [ + { nope: true }, + { success: false, error: { issues: [{ path: [], message: "config must be empty" }] } }, + ], + ] as const).toSatisfy((cases) => { + for (const [value, expected] of cases) { + expect(schema.safeParse?.(value)).toEqual(expected); + } + return true; }); }); }); diff --git a/src/plugins/conversation-binding.test.ts b/src/plugins/conversation-binding.test.ts index 13a49839050..a0c115b1d3c 100644 --- a/src/plugins/conversation-binding.test.ts +++ b/src/plugins/conversation-binding.test.ts @@ -197,6 +197,36 @@ function createTelegramCodexBindRequest( }; } +function createCodexBindRequest(params: { + channel: "discord" | "telegram"; + accountId: string; + conversationId: string; + summary: string; + pluginRoot?: string; + pluginId?: string; + parentConversationId?: string; + threadId?: string; + detachHint?: string; +}) { + return { + pluginId: params.pluginId ?? "codex", + pluginName: "Codex App Server", + pluginRoot: params.pluginRoot ?? "/plugins/codex-a", + requestedBySenderId: "user-1", + conversation: { + channel: params.channel, + accountId: params.accountId, + conversationId: params.conversationId, + ...(params.parentConversationId ? { parentConversationId: params.parentConversationId } : {}), + ...(params.threadId ? { threadId: params.threadId } : {}), + }, + binding: { + summary: params.summary, + ...(params.detachHint ? { detachHint: params.detachHint } : {}), + }, + } satisfies PluginBindingRequestInput; +} + async function requestPendingBinding( input: PluginBindingRequestInput, requestBinding = requestPluginConversationBinding, @@ -256,6 +286,91 @@ function createDeferredVoid(): { promise: Promise; resolve: () => void } { return { promise, resolve }; } +function createResolvedHandlerRegistry(params: { + pluginRoot: string; + handler: (input: unknown) => Promise; +}) { + const registry = createEmptyPluginRegistry(); + registry.conversationBindingResolvedHandlers.push({ + pluginId: "codex", + pluginRoot: params.pluginRoot, + handler: params.handler, + source: `${params.pluginRoot}/index.ts`, + rootDir: params.pluginRoot, + }); + setActivePluginRegistry(registry); + return registry; +} + +async function expectResolutionCallback(params: { + pluginRoot: string; + requestInput: PluginBindingRequestInput; + decision: PluginBindingDecision; + expectedStatus: "approved" | "denied"; + expectedCallback: unknown; +}) { + const onResolved = vi.fn(async () => undefined); + createResolvedHandlerRegistry({ + pluginRoot: params.pluginRoot, + handler: onResolved, + }); + + const request = await requestPluginConversationBinding(params.requestInput); + expect(request.status).toBe("pending"); + if (request.status !== "pending") { + throw new Error("expected pending bind request"); + } + + const result = await resolvePluginConversationBindingApproval({ + approvalId: request.approvalId, + decision: params.decision, + senderId: "user-1", + }); + + expect(result.status).toBe(params.expectedStatus); + await flushMicrotasks(); + expect(onResolved).toHaveBeenCalledWith(params.expectedCallback); +} + +async function expectResolutionDoesNotWait(params: { + pluginRoot: string; + requestInput: PluginBindingRequestInput; + decision: PluginBindingDecision; + expectedStatus: "approved" | "denied"; +}) { + const callbackGate = createDeferredVoid(); + const onResolved = vi.fn(async () => callbackGate.promise); + createResolvedHandlerRegistry({ + pluginRoot: params.pluginRoot, + handler: onResolved, + }); + + const request = await requestPluginConversationBinding(params.requestInput); + expect(request.status).toBe("pending"); + if (request.status !== "pending") { + throw new Error("expected pending bind request"); + } + + let settled = false; + const resolutionPromise = resolvePluginConversationBindingApproval({ + approvalId: request.approvalId, + decision: params.decision, + senderId: "user-1", + }).then((result) => { + settled = true; + return result; + }); + + await flushMicrotasks(); + + expect(settled).toBe(true); + expect(onResolved).toHaveBeenCalledTimes(1); + + callbackGate.resolve(); + const result = await resolutionPromise; + expect(result.status).toBe(params.expectedStatus); +} + describe("plugin conversation binding approvals", () => { beforeEach(async () => { vi.resetModules(); @@ -423,20 +538,16 @@ describe("plugin conversation binding approvals", () => { }); it("does not share persistent approvals across plugin roots even with the same plugin id", async () => { - const request = await requestPluginConversationBinding({ - pluginId: "codex", - pluginName: "Codex App Server", - pluginRoot: "/plugins/codex-a", - requestedBySenderId: "user-1", - conversation: { + const request = await requestPluginConversationBinding( + createCodexBindRequest({ channel: "telegram", accountId: "default", conversationId: "-10099:topic:77", parentConversationId: "-10099", threadId: "77", - }, - binding: { summary: "Bind this conversation to Codex thread abc." }, - }); + summary: "Bind this conversation to Codex thread abc.", + }), + ); expect(request.status).toBe("pending"); if (request.status !== "pending") { @@ -449,40 +560,31 @@ describe("plugin conversation binding approvals", () => { senderId: "user-1", }); - const samePluginNewPath = await requestPluginConversationBinding({ - pluginId: "codex", - pluginName: "Codex App Server", - pluginRoot: "/plugins/codex-b", - requestedBySenderId: "user-1", - conversation: { + const samePluginNewPath = await requestPluginConversationBinding( + createCodexBindRequest({ channel: "telegram", accountId: "default", conversationId: "-10099:topic:78", parentConversationId: "-10099", threadId: "78", - }, - binding: { summary: "Bind this conversation to Codex thread def." }, - }); + summary: "Bind this conversation to Codex thread def.", + pluginRoot: "/plugins/codex-b", + }), + ); expect(samePluginNewPath.status).toBe("pending"); }); it("persists detachHint on approved plugin bindings", async () => { - const request = await requestPluginConversationBinding({ - pluginId: "codex", - pluginName: "Codex App Server", - pluginRoot: "/plugins/codex-a", - requestedBySenderId: "user-1", - conversation: { + const request = await requestPluginConversationBinding( + createCodexBindRequest({ channel: "discord", accountId: "isolated", conversationId: "channel:detach-hint", - }, - binding: { summary: "Bind this conversation to Codex thread 999.", detachHint: "/codex_detach", - }, - }); + }), + ); expect(["pending", "bound"]).toContain(request.status); @@ -517,220 +619,120 @@ describe("plugin conversation binding approvals", () => { expect(currentBinding?.detachHint).toBe("/codex_detach"); }); - it("notifies the owning plugin when a bind approval is approved", async () => { - const registry = createEmptyPluginRegistry(); - const onResolved = vi.fn(async () => undefined); - registry.conversationBindingResolvedHandlers.push({ - pluginId: "codex", + it.each([ + { + name: "notifies the owning plugin when a bind approval is approved", pluginRoot: "/plugins/callback-test", - handler: onResolved, - source: "/plugins/callback-test/index.ts", - rootDir: "/plugins/callback-test", - }); - setActivePluginRegistry(registry); - - const request = await requestPluginConversationBinding({ - pluginId: "codex", - pluginName: "Codex App Server", - pluginRoot: "/plugins/callback-test", - requestedBySenderId: "user-1", - conversation: { - channel: "discord", - accountId: "isolated", - conversationId: "channel:callback-test", - }, - binding: { summary: "Bind this conversation to Codex thread abc." }, - }); - - expect(request.status).toBe("pending"); - if (request.status !== "pending") { - throw new Error("expected pending bind request"); - } - - const approved = await resolvePluginConversationBindingApproval({ - approvalId: request.approvalId, - decision: "allow-once", - senderId: "user-1", - }); - - expect(approved.status).toBe("approved"); - await flushMicrotasks(); - expect(onResolved).toHaveBeenCalledWith({ - status: "approved", - binding: expect.objectContaining({ + requestInput: { pluginId: "codex", + pluginName: "Codex App Server", pluginRoot: "/plugins/callback-test", - conversationId: "channel:callback-test", - }), - decision: "allow-once", - request: { - summary: "Bind this conversation to Codex thread abc.", - detachHint: undefined, requestedBySenderId: "user-1", conversation: { channel: "discord", accountId: "isolated", conversationId: "channel:callback-test", }, + binding: { summary: "Bind this conversation to Codex thread abc." }, }, - }); - }); - - it("notifies the owning plugin when a bind approval is denied", async () => { - const registry = createEmptyPluginRegistry(); - const onResolved = vi.fn(async () => undefined); - registry.conversationBindingResolvedHandlers.push({ - pluginId: "codex", - pluginRoot: "/plugins/callback-deny", - handler: onResolved, - source: "/plugins/callback-deny/index.ts", - rootDir: "/plugins/callback-deny", - }); - setActivePluginRegistry(registry); - - const request = await requestPluginConversationBinding({ - pluginId: "codex", - pluginName: "Codex App Server", - pluginRoot: "/plugins/callback-deny", - requestedBySenderId: "user-1", - conversation: { - channel: "telegram", - accountId: "default", - conversationId: "8460800771", + decision: "allow-once" as const, + expectedStatus: "approved" as const, + expectedCallback: { + status: "approved", + binding: expect.objectContaining({ + pluginId: "codex", + pluginRoot: "/plugins/callback-test", + conversationId: "channel:callback-test", + }), + decision: "allow-once", + request: { + summary: "Bind this conversation to Codex thread abc.", + detachHint: undefined, + requestedBySenderId: "user-1", + conversation: { + channel: "discord", + accountId: "isolated", + conversationId: "channel:callback-test", + }, + }, }, - binding: { summary: "Bind this conversation to Codex thread deny." }, - }); - - expect(request.status).toBe("pending"); - if (request.status !== "pending") { - throw new Error("expected pending bind request"); - } - - const denied = await resolvePluginConversationBindingApproval({ - approvalId: request.approvalId, - decision: "deny", - senderId: "user-1", - }); - - expect(denied.status).toBe("denied"); - await flushMicrotasks(); - expect(onResolved).toHaveBeenCalledWith({ - status: "denied", - binding: undefined, - decision: "deny", - request: { - summary: "Bind this conversation to Codex thread deny.", - detachHint: undefined, + }, + { + name: "notifies the owning plugin when a bind approval is denied", + pluginRoot: "/plugins/callback-deny", + requestInput: { + pluginId: "codex", + pluginName: "Codex App Server", + pluginRoot: "/plugins/callback-deny", requestedBySenderId: "user-1", conversation: { channel: "telegram", accountId: "default", conversationId: "8460800771", }, + binding: { summary: "Bind this conversation to Codex thread deny." }, }, - }); + decision: "deny" as const, + expectedStatus: "denied" as const, + expectedCallback: { + status: "denied", + binding: undefined, + decision: "deny", + request: { + summary: "Bind this conversation to Codex thread deny.", + detachHint: undefined, + requestedBySenderId: "user-1", + conversation: { + channel: "telegram", + accountId: "default", + conversationId: "8460800771", + }, + }, + }, + }, + ] as const)("$name", async (testCase) => { + await expectResolutionCallback(testCase); }); - it("does not wait for an approved bind callback before returning", async () => { - const registry = createEmptyPluginRegistry(); - const callbackGate = createDeferredVoid(); - const onResolved = vi.fn(async () => callbackGate.promise); - registry.conversationBindingResolvedHandlers.push({ - pluginId: "codex", + it.each([ + { + name: "does not wait for an approved bind callback before returning", pluginRoot: "/plugins/callback-slow-approve", - handler: onResolved, - source: "/plugins/callback-slow-approve/index.ts", - rootDir: "/plugins/callback-slow-approve", - }); - setActivePluginRegistry(registry); - - const request = await requestPluginConversationBinding({ - pluginId: "codex", - pluginName: "Codex App Server", - pluginRoot: "/plugins/callback-slow-approve", - requestedBySenderId: "user-1", - conversation: { - channel: "discord", - accountId: "isolated", - conversationId: "channel:slow-approve", + requestInput: { + pluginId: "codex", + pluginName: "Codex App Server", + pluginRoot: "/plugins/callback-slow-approve", + requestedBySenderId: "user-1", + conversation: { + channel: "discord", + accountId: "isolated", + conversationId: "channel:slow-approve", + }, + binding: { summary: "Bind this conversation to Codex thread slow-approve." }, }, - binding: { summary: "Bind this conversation to Codex thread slow-approve." }, - }); - - expect(request.status).toBe("pending"); - if (request.status !== "pending") { - throw new Error("expected pending bind request"); - } - - let settled = false; - const resolutionPromise = resolvePluginConversationBindingApproval({ - approvalId: request.approvalId, - decision: "allow-once", - senderId: "user-1", - }).then((result) => { - settled = true; - return result; - }); - - await flushMicrotasks(); - - expect(settled).toBe(true); - expect(onResolved).toHaveBeenCalledTimes(1); - - callbackGate.resolve(); - const approved = await resolutionPromise; - expect(approved.status).toBe("approved"); - }); - - it("does not wait for a denied bind callback before returning", async () => { - const registry = createEmptyPluginRegistry(); - const callbackGate = createDeferredVoid(); - const onResolved = vi.fn(async () => callbackGate.promise); - registry.conversationBindingResolvedHandlers.push({ - pluginId: "codex", + decision: "allow-once" as const, + expectedStatus: "approved" as const, + }, + { + name: "does not wait for a denied bind callback before returning", pluginRoot: "/plugins/callback-slow-deny", - handler: onResolved, - source: "/plugins/callback-slow-deny/index.ts", - rootDir: "/plugins/callback-slow-deny", - }); - setActivePluginRegistry(registry); - - const request = await requestPluginConversationBinding({ - pluginId: "codex", - pluginName: "Codex App Server", - pluginRoot: "/plugins/callback-slow-deny", - requestedBySenderId: "user-1", - conversation: { - channel: "telegram", - accountId: "default", - conversationId: "slow-deny", + requestInput: { + pluginId: "codex", + pluginName: "Codex App Server", + pluginRoot: "/plugins/callback-slow-deny", + requestedBySenderId: "user-1", + conversation: { + channel: "telegram", + accountId: "default", + conversationId: "slow-deny", + }, + binding: { summary: "Bind this conversation to Codex thread slow-deny." }, }, - binding: { summary: "Bind this conversation to Codex thread slow-deny." }, - }); - - expect(request.status).toBe("pending"); - if (request.status !== "pending") { - throw new Error("expected pending bind request"); - } - - let settled = false; - const resolutionPromise = resolvePluginConversationBindingApproval({ - approvalId: request.approvalId, - decision: "deny", - senderId: "user-1", - }).then((result) => { - settled = true; - return result; - }); - - await flushMicrotasks(); - - expect(settled).toBe(true); - expect(onResolved).toHaveBeenCalledTimes(1); - - callbackGate.resolve(); - const denied = await resolutionPromise; - expect(denied.status).toBe("denied"); + decision: "deny" as const, + expectedStatus: "denied" as const, + }, + ] as const)("$name", async (testCase) => { + await expectResolutionDoesNotWait(testCase); }); it("returns and detaches only bindings owned by the requesting plugin root", async () => { @@ -842,89 +844,75 @@ describe("plugin conversation binding approvals", () => { }); }); - it("migrates a legacy plugin binding record through the new approval flow even if the old plugin id differs", async () => { - sessionBindingState.setRecord({ - bindingId: "binding-legacy", - targetSessionKey: "plugin-binding:old-codex-plugin:legacy123", - targetKind: "session", - conversation: { - channel: "telegram", - accountId: "default", - conversationId: "-10099:topic:77", + it.each([ + { + name: "migrates a legacy plugin binding record through the new approval flow even if the old plugin id differs", + existingRecord: { + bindingId: "binding-legacy", + targetSessionKey: "plugin-binding:old-codex-plugin:legacy123", + targetKind: "session" as const, + conversation: { + channel: "telegram", + accountId: "default", + conversationId: "-10099:topic:77", + }, + status: "active" as const, + metadata: { + label: "legacy plugin bind", + }, }, - status: "active", - boundAt: Date.now(), - metadata: { - label: "legacy plugin bind", - }, - }); - - const request = await requestPluginConversationBinding({ - pluginId: "codex", - pluginName: "Codex App Server", - pluginRoot: "/plugins/codex-a", - requestedBySenderId: "user-1", - conversation: { + requestInput: createCodexBindRequest({ channel: "telegram", accountId: "default", conversationId: "-10099:topic:77", parentConversationId: "-10099", threadId: "77", - }, - binding: { summary: "Bind this conversation to Codex thread abc." }, - }); - - const binding = await resolveRequestedBinding(request); - - expect(binding).toEqual( - expect.objectContaining({ + summary: "Bind this conversation to Codex thread abc.", + }), + expectedBinding: { pluginId: "codex", pluginRoot: "/plugins/codex-a", conversationId: "-10099:topic:77", - }), - ); - }); - - it("migrates a legacy codex thread binding session key through the new approval flow", async () => { - sessionBindingState.setRecord({ - bindingId: "binding-legacy-codex-thread", - targetSessionKey: "openclaw-app-server:thread:019ce411-6322-7db2-a821-1a61c530e7d9", - targetKind: "session", - conversation: { + }, + }, + { + name: "migrates a legacy codex thread binding session key through the new approval flow", + existingRecord: { + bindingId: "binding-legacy-codex-thread", + targetSessionKey: "openclaw-app-server:thread:019ce411-6322-7db2-a821-1a61c530e7d9", + targetKind: "session" as const, + conversation: { + channel: "telegram", + accountId: "default", + conversationId: "8460800771", + }, + status: "active" as const, + metadata: { + label: "legacy codex thread bind", + }, + }, + requestInput: createCodexBindRequest({ channel: "telegram", accountId: "default", conversationId: "8460800771", - }, - status: "active", - boundAt: Date.now(), - metadata: { - label: "legacy codex thread bind", - }, - }); - - const request = await requestPluginConversationBinding({ - pluginId: "openclaw-codex-app-server", - pluginName: "Codex App Server", - pluginRoot: "/plugins/codex-a", - requestedBySenderId: "user-1", - conversation: { - channel: "telegram", - accountId: "default", - conversationId: "8460800771", - }, - binding: { summary: "Bind this conversation to Codex thread 019ce411-6322-7db2-a821-1a61c530e7d9.", - }, - }); - - const binding = await resolveRequestedBinding(request); - - expect(binding).toEqual( - expect.objectContaining({ + pluginId: "openclaw-codex-app-server", + }), + expectedBinding: { pluginId: "openclaw-codex-app-server", pluginRoot: "/plugins/codex-a", conversationId: "8460800771", - }), - ); + }, + }, + ] as const)("$name", async ({ existingRecord, requestInput, expectedBinding }) => { + sessionBindingState.setRecord({ + ...existingRecord, + boundAt: Date.now(), + }); + + const request = await requestPluginConversationBinding(requestInput); + const binding = await resolveRequestedBinding(request); + + expect(binding).toEqual(expect.objectContaining(expectedBinding)); }); }); diff --git a/src/plugins/copy-bundled-plugin-metadata.test.ts b/src/plugins/copy-bundled-plugin-metadata.test.ts index 8cc58362785..2d0ee949ed1 100644 --- a/src/plugins/copy-bundled-plugin-metadata.test.ts +++ b/src/plugins/copy-bundled-plugin-metadata.test.ts @@ -22,6 +22,61 @@ function writeJson(filePath: string, value: unknown): void { writeJsonFile(filePath, value); } +function createPlugin( + repoRoot: string, + params: { + id: string; + packageName: string; + manifest?: Record; + packageOpenClaw?: Record; + }, +) { + const pluginDir = path.join(repoRoot, "extensions", params.id); + fs.mkdirSync(pluginDir, { recursive: true }); + writeJson(path.join(pluginDir, "openclaw.plugin.json"), { + id: params.id, + configSchema: { type: "object" }, + ...params.manifest, + }); + writeJson(path.join(pluginDir, "package.json"), { + name: params.packageName, + ...(params.packageOpenClaw ? { openclaw: params.packageOpenClaw } : {}), + }); + return pluginDir; +} + +function readBundledManifest(repoRoot: string, pluginId: string) { + return JSON.parse( + fs.readFileSync( + path.join(repoRoot, "dist", "extensions", pluginId, "openclaw.plugin.json"), + "utf8", + ), + ) as { skills?: string[] }; +} + +function readBundledPackageJson(repoRoot: string, pluginId: string) { + return JSON.parse( + fs.readFileSync(path.join(repoRoot, "dist", "extensions", pluginId, "package.json"), "utf8"), + ) as { openclaw?: { extensions?: string[] } }; +} + +function bundledPluginDir(repoRoot: string, pluginId: string) { + return path.join(repoRoot, "dist", "extensions", pluginId); +} + +function bundledSkillPath(repoRoot: string, pluginId: string, ...relativePath: string[]) { + return path.join(bundledPluginDir(repoRoot, pluginId), ...relativePath); +} + +function createTlonSkillPlugin(repoRoot: string, skillPath = "node_modules/@tloncorp/tlon-skill") { + return createPlugin(repoRoot, { + id: "tlon", + packageName: "@openclaw/tlon", + manifest: { skills: [skillPath] }, + packageOpenClaw: { extensions: ["./index.ts"] }, + }); +} + afterEach(() => { cleanupTempDirs(tempDirs); }); @@ -38,22 +93,18 @@ describe("rewritePackageExtensions", () => { describe("copyBundledPluginMetadata", () => { it("copies plugin manifests, package metadata, and local skill directories", () => { const repoRoot = makeRepoRoot("openclaw-bundled-plugin-meta-"); - const pluginDir = path.join(repoRoot, "extensions", "acpx"); + const pluginDir = createPlugin(repoRoot, { + id: "acpx", + packageName: "@openclaw/acpx", + manifest: { skills: ["./skills"] }, + packageOpenClaw: { extensions: ["./index.ts"] }, + }); fs.mkdirSync(path.join(pluginDir, "skills", "acp-router"), { recursive: true }); fs.writeFileSync( path.join(pluginDir, "skills", "acp-router", "SKILL.md"), "# ACP Router\n", "utf8", ); - writeJson(path.join(pluginDir, "openclaw.plugin.json"), { - id: "acpx", - configSchema: { type: "object" }, - skills: ["./skills"], - }); - writeJson(path.join(pluginDir, "package.json"), { - name: "@openclaw/acpx", - openclaw: { extensions: ["./index.ts"] }, - }); copyBundledPluginMetadata({ repoRoot }); @@ -66,22 +117,15 @@ describe("copyBundledPluginMetadata", () => { "utf8", ), ).toContain("ACP Router"); - const bundledManifest = JSON.parse( - fs.readFileSync( - path.join(repoRoot, "dist", "extensions", "acpx", "openclaw.plugin.json"), - "utf8", - ), - ) as { skills?: string[] }; + const bundledManifest = readBundledManifest(repoRoot, "acpx"); expect(bundledManifest.skills).toEqual(["./skills"]); - const packageJson = JSON.parse( - fs.readFileSync(path.join(repoRoot, "dist", "extensions", "acpx", "package.json"), "utf8"), - ) as { openclaw?: { extensions?: string[] } }; + const packageJson = readBundledPackageJson(repoRoot, "acpx"); expect(packageJson.openclaw?.extensions).toEqual(["./index.js"]); }); it("relocates node_modules-backed skill paths into bundled-skills and rewrites the manifest", () => { const repoRoot = makeRepoRoot("openclaw-bundled-plugin-node-modules-"); - const pluginDir = path.join(repoRoot, "extensions", "tlon"); + const pluginDir = createTlonSkillPlugin(repoRoot); const storeSkillDir = path.join( repoRoot, "node_modules", @@ -105,20 +149,8 @@ describe("copyBundledPluginMetadata", () => { path.join(pluginDir, "node_modules", "@tloncorp", "tlon-skill"), process.platform === "win32" ? "junction" : "dir", ); - writeJson(path.join(pluginDir, "openclaw.plugin.json"), { - id: "tlon", - configSchema: { type: "object" }, - skills: ["node_modules/@tloncorp/tlon-skill"], - }); - writeJson(path.join(pluginDir, "package.json"), { - name: "@openclaw/tlon", - openclaw: { extensions: ["./index.ts"] }, - }); const staleNodeModulesSkillDir = path.join( - repoRoot, - "dist", - "extensions", - "tlon", + bundledPluginDir(repoRoot, "tlon"), "node_modules", "@tloncorp", "tlon-skill", @@ -129,10 +161,7 @@ describe("copyBundledPluginMetadata", () => { copyBundledPluginMetadata({ repoRoot }); const copiedSkillDir = path.join( - repoRoot, - "dist", - "extensions", - "tlon", + bundledPluginDir(repoRoot, "tlon"), "bundled-skills", "@tloncorp", "tlon-skill", @@ -140,96 +169,50 @@ describe("copyBundledPluginMetadata", () => { expect(fs.existsSync(path.join(copiedSkillDir, "SKILL.md"))).toBe(true); expect(fs.lstatSync(copiedSkillDir).isSymbolicLink()).toBe(false); expect(fs.existsSync(path.join(copiedSkillDir, "node_modules"))).toBe(false); - expect(fs.existsSync(path.join(repoRoot, "dist", "extensions", "tlon", "node_modules"))).toBe( + expect(fs.existsSync(path.join(bundledPluginDir(repoRoot, "tlon"), "node_modules"))).toBe( false, ); - const bundledManifest = JSON.parse( - fs.readFileSync( - path.join(repoRoot, "dist", "extensions", "tlon", "openclaw.plugin.json"), - "utf8", - ), - ) as { skills?: string[] }; + const bundledManifest = readBundledManifest(repoRoot, "tlon"); expect(bundledManifest.skills).toEqual(["./bundled-skills/@tloncorp/tlon-skill"]); }); it("falls back to repo-root hoisted node_modules skill paths", () => { const repoRoot = makeRepoRoot("openclaw-bundled-plugin-hoisted-skill-"); - const pluginDir = path.join(repoRoot, "extensions", "tlon"); + const pluginDir = createTlonSkillPlugin(repoRoot); const hoistedSkillDir = path.join(repoRoot, "node_modules", "@tloncorp", "tlon-skill"); fs.mkdirSync(hoistedSkillDir, { recursive: true }); fs.writeFileSync(path.join(hoistedSkillDir, "SKILL.md"), "# Hoisted Tlon Skill\n", "utf8"); fs.mkdirSync(pluginDir, { recursive: true }); - writeJson(path.join(pluginDir, "openclaw.plugin.json"), { - id: "tlon", - configSchema: { type: "object" }, - skills: ["node_modules/@tloncorp/tlon-skill"], - }); - writeJson(path.join(pluginDir, "package.json"), { - name: "@openclaw/tlon", - openclaw: { extensions: ["./index.ts"] }, - }); copyBundledPluginMetadata({ repoRoot }); expect( fs.readFileSync( - path.join( - repoRoot, - "dist", - "extensions", - "tlon", - "bundled-skills", - "@tloncorp", - "tlon-skill", - "SKILL.md", - ), + bundledSkillPath(repoRoot, "tlon", "bundled-skills", "@tloncorp", "tlon-skill", "SKILL.md"), "utf8", ), ).toContain("Hoisted Tlon Skill"); - const bundledManifest = JSON.parse( - fs.readFileSync( - path.join(repoRoot, "dist", "extensions", "tlon", "openclaw.plugin.json"), - "utf8", - ), - ) as { skills?: string[] }; + const bundledManifest = readBundledManifest(repoRoot, "tlon"); expect(bundledManifest.skills).toEqual(["./bundled-skills/@tloncorp/tlon-skill"]); }); it("omits missing declared skill paths and removes stale generated outputs", () => { const repoRoot = makeRepoRoot("openclaw-bundled-plugin-missing-skill-"); - const pluginDir = path.join(repoRoot, "extensions", "tlon"); - fs.mkdirSync(pluginDir, { recursive: true }); - writeJson(path.join(pluginDir, "openclaw.plugin.json"), { - id: "tlon", - configSchema: { type: "object" }, - skills: ["node_modules/@tloncorp/tlon-skill"], - }); - writeJson(path.join(pluginDir, "package.json"), { - name: "@openclaw/tlon", - openclaw: { extensions: ["./index.ts"] }, - }); + createTlonSkillPlugin(repoRoot); const staleBundledSkillDir = path.join( - repoRoot, - "dist", - "extensions", - "tlon", + bundledPluginDir(repoRoot, "tlon"), "bundled-skills", "@tloncorp", "tlon-skill", ); fs.mkdirSync(staleBundledSkillDir, { recursive: true }); fs.writeFileSync(path.join(staleBundledSkillDir, "SKILL.md"), "# stale\n", "utf8"); - const staleNodeModulesDir = path.join(repoRoot, "dist", "extensions", "tlon", "node_modules"); + const staleNodeModulesDir = path.join(bundledPluginDir(repoRoot, "tlon"), "node_modules"); fs.mkdirSync(staleNodeModulesDir, { recursive: true }); copyBundledPluginMetadata({ repoRoot }); - const bundledManifest = JSON.parse( - fs.readFileSync( - path.join(repoRoot, "dist", "extensions", "tlon", "openclaw.plugin.json"), - "utf8", - ), - ) as { skills?: string[] }; + const bundledManifest = readBundledManifest(repoRoot, "tlon"); expect(bundledManifest.skills).toEqual([]); expect(fs.existsSync(path.join(repoRoot, "dist", "extensions", "tlon", "bundled-skills"))).toBe( false, @@ -239,18 +222,14 @@ describe("copyBundledPluginMetadata", () => { it("retries transient skill copy races from concurrent runtime postbuilds", () => { const repoRoot = makeRepoRoot("openclaw-bundled-plugin-retry-"); - const pluginDir = path.join(repoRoot, "extensions", "diffs"); + const pluginDir = createPlugin(repoRoot, { + id: "diffs", + packageName: "@openclaw/diffs", + manifest: { skills: ["./skills"] }, + packageOpenClaw: { extensions: ["./index.ts"] }, + }); fs.mkdirSync(path.join(pluginDir, "skills", "diffs"), { recursive: true }); fs.writeFileSync(path.join(pluginDir, "skills", "diffs", "SKILL.md"), "# Diffs\n", "utf8"); - writeJson(path.join(pluginDir, "openclaw.plugin.json"), { - id: "diffs", - configSchema: { type: "object" }, - skills: ["./skills"], - }); - writeJson(path.join(pluginDir, "package.json"), { - name: "@openclaw/diffs", - openclaw: { extensions: ["./index.ts"] }, - }); const realCpSync = fs.cpSync.bind(fs); let attempts = 0; @@ -339,42 +318,36 @@ describe("copyBundledPluginMetadata", () => { expect(fs.existsSync(staleDistDir)).toBe(false); }); - it("skips metadata for optional bundled clusters only when explicitly disabled", () => { - const repoRoot = makeRepoRoot("openclaw-bundled-plugin-optional-skip-"); - const pluginDir = path.join(repoRoot, "extensions", "acpx"); - fs.mkdirSync(pluginDir, { recursive: true }); - writeJson(path.join(pluginDir, "openclaw.plugin.json"), { - id: "acpx", - configSchema: { type: "object" }, - }); - writeJson(path.join(pluginDir, "package.json"), { - name: "@openclaw/acpx-plugin", - openclaw: { extensions: ["./index.ts"] }, - }); - - copyBundledPluginMetadataWithEnv({ repoRoot, env: excludeOptionalEnv }); - - expect(fs.existsSync(path.join(repoRoot, "dist", "extensions", "acpx"))).toBe(false); - }); - - it("still bundles previously released optional plugins without the opt-in env", () => { - const repoRoot = makeRepoRoot("openclaw-bundled-plugin-released-optional-"); - const pluginDir = path.join(repoRoot, "extensions", "whatsapp"); - fs.mkdirSync(pluginDir, { recursive: true }); - writeJson(path.join(pluginDir, "openclaw.plugin.json"), { - id: "whatsapp", - configSchema: { type: "object" }, - }); - writeJson(path.join(pluginDir, "package.json"), { - name: "@openclaw/whatsapp", - openclaw: { + it.each([ + { + name: "skips metadata for optional bundled clusters only when explicitly disabled", + pluginId: "acpx", + packageName: "@openclaw/acpx-plugin", + packageOpenClaw: { extensions: ["./index.ts"] }, + env: excludeOptionalEnv, + expectedExists: false, + }, + { + name: "still bundles previously released optional plugins without the opt-in env", + pluginId: "whatsapp", + packageName: "@openclaw/whatsapp", + packageOpenClaw: { extensions: ["./index.ts"], install: { npmSpec: "@openclaw/whatsapp" }, }, + env: {}, + expectedExists: true, + }, + ] as const)("$name", ({ pluginId, packageName, packageOpenClaw, env, expectedExists }) => { + const repoRoot = makeRepoRoot(`openclaw-bundled-plugin-${pluginId}-`); + createPlugin(repoRoot, { + id: pluginId, + packageName, + packageOpenClaw, }); - copyBundledPluginMetadataWithEnv({ repoRoot, env: {} }); + copyBundledPluginMetadataWithEnv({ repoRoot, env }); - expect(fs.existsSync(path.join(repoRoot, "dist", "extensions", "whatsapp"))).toBe(true); + expect(fs.existsSync(path.join(repoRoot, "dist", "extensions", pluginId))).toBe(expectedExists); }); }); diff --git a/src/plugins/discovery.test.ts b/src/plugins/discovery.test.ts index f59575358a4..1297b36b5a3 100644 --- a/src/plugins/discovery.test.ts +++ b/src/plugins/discovery.test.ts @@ -41,6 +41,17 @@ function buildDiscoveryEnv(stateDir: string): NodeJS.ProcessEnv { }; } +function buildCachedDiscoveryEnv( + stateDir: string, + overrides: Partial = {}, +): NodeJS.ProcessEnv { + return { + ...buildDiscoveryEnv(stateDir), + OPENCLAW_PLUGIN_DISCOVERY_CACHE_MS: "5000", + ...overrides, + }; +} + async function discoverWithStateDir( stateDir: string, params: Parameters[0], @@ -48,6 +59,10 @@ async function discoverWithStateDir( return discoverOpenClawPlugins({ ...params, env: buildDiscoveryEnv(stateDir) }); } +function discoverWithCachedEnv(params: Parameters[0]) { + return discoverOpenClawPlugins(params); +} + function writePluginPackageManifest(params: { packageDir: string; packageName: string; @@ -74,6 +89,66 @@ function writePluginManifest(params: { pluginDir: string; id: string }) { ); } +function writePluginEntry(filePath: string) { + fs.writeFileSync(filePath, "export default function () {}", "utf-8"); +} + +function writeStandalonePlugin(filePath: string, source = "export default function () {}") { + mkdirSafe(path.dirname(filePath)); + fs.writeFileSync(filePath, source, "utf-8"); +} + +function createPackagePlugin(params: { + packageDir: string; + packageName: string; + extensions: string[]; + pluginId?: string; +}) { + mkdirSafe(params.packageDir); + writePluginPackageManifest({ + packageDir: params.packageDir, + packageName: params.packageName, + extensions: params.extensions, + }); + if (params.pluginId) { + writePluginManifest({ pluginDir: params.packageDir, id: params.pluginId }); + } +} + +function createBundleRoot(bundleDir: string, markerPath: string, manifest?: unknown) { + mkdirSafe(path.dirname(path.join(bundleDir, markerPath))); + if (manifest) { + fs.writeFileSync(path.join(bundleDir, markerPath), JSON.stringify(manifest), "utf-8"); + return; + } + mkdirSafe(path.join(bundleDir, markerPath)); +} + +function expectCandidateIds( + candidates: Array<{ idHint: string }>, + params: { includes?: readonly string[]; excludes?: readonly string[] }, +) { + const ids = candidates.map((candidate) => candidate.idHint); + for (const includedId of params.includes ?? []) { + expect(ids).toContain(includedId); + } + for (const excludedId of params.excludes ?? []) { + expect(ids).not.toContain(excludedId); + } +} + +function findCandidateById(candidates: T[], idHint: string) { + return candidates.find((candidate) => candidate.idHint === idHint); +} + +function expectCandidateSource( + candidates: Array<{ idHint?: string; source?: string }>, + idHint: string, + source: string, +) { + expect(findCandidateById(candidates, idHint)?.source).toBe(source); +} + function expectEscapesPackageDiagnostic(diagnostics: Array<{ message: string }>) { expect(diagnostics.some((entry) => entry.message.includes("escapes package directory"))).toBe( true, @@ -166,22 +241,11 @@ describe("discoverOpenClawPlugins", () => { packageName: "pack", extensions: ["./src/one.ts", "./src/two.ts"], }); - fs.writeFileSync( - path.join(globalExt, "src", "one.ts"), - "export default function () {}", - "utf-8", - ); - fs.writeFileSync( - path.join(globalExt, "src", "two.ts"), - "export default function () {}", - "utf-8", - ); + writePluginEntry(path.join(globalExt, "src", "one.ts")); + writePluginEntry(path.join(globalExt, "src", "two.ts")); const { candidates } = await discoverWithStateDir(stateDir, {}); - - const ids = candidates.map((c) => c.idHint); - expect(ids).toContain("pack/one"); - expect(ids).toContain("pack/two"); + expectCandidateIds(candidates, { includes: ["pack/one", "pack/two"] }); }); it("does not discover nested node_modules copies under installed plugins", async () => { @@ -227,210 +291,178 @@ describe("discoverOpenClawPlugins", () => { expect(candidates.map((candidate) => candidate.idHint)).toEqual(["opik-openclaw"]); }); - it("derives unscoped ids for scoped packages", async () => { + it.each([ + { + 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({ + packageDir, + packageName: "@openclaw/voice-call", + extensions: ["./src/index.ts"], + }); + writePluginEntry(path.join(packageDir, "src", "index.ts")); + return {}; + }, + includes: ["voice-call"], + }, + { + 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({ + packageDir, + packageName: "@openclaw/ollama-provider", + extensions: ["./src/index.ts"], + pluginId: "ollama", + }); + writePluginEntry(path.join(packageDir, "src", "index.ts")); + return {}; + }, + includes: ["ollama"], + excludes: ["ollama-provider"], + }, + { + name: "normalizes bundled speech package ids to canonical plugin ids", + setup: (stateDir: string) => { + for (const [dirName, packageName, pluginId] of [ + ["elevenlabs-speech-pack", "@openclaw/elevenlabs-speech", "elevenlabs"], + ["microsoft-speech-pack", "@openclaw/microsoft-speech", "microsoft"], + ] as const) { + const packageDir = path.join(stateDir, "extensions", dirName); + mkdirSafe(path.join(packageDir, "src")); + createPackagePlugin({ + packageDir, + packageName, + extensions: ["./src/index.ts"], + pluginId, + }); + writePluginEntry(path.join(packageDir, "src", "index.ts")); + } + return {}; + }, + includes: ["elevenlabs", "microsoft"], + excludes: ["elevenlabs-speech", "microsoft-speech"], + }, + { + name: "treats configured directory paths as plugin packages", + setup: (stateDir: string) => { + const packageDir = path.join(stateDir, "packs", "demo-plugin-dir"); + createPackagePlugin({ + packageDir, + packageName: "@openclaw/demo-plugin-dir", + extensions: ["./index.js"], + }); + fs.writeFileSync(path.join(packageDir, "index.js"), "module.exports = {}", "utf-8"); + return { extraPaths: [packageDir] }; + }, + includes: ["demo-plugin-dir"], + }, + ] as const)("$name", async ({ setup, includes, excludes }) => { const stateDir = makeTempDir(); - const globalExt = path.join(stateDir, "extensions", "voice-call-pack"); - mkdirSafe(path.join(globalExt, "src")); - - writePluginPackageManifest({ - packageDir: globalExt, - packageName: "@openclaw/voice-call", - extensions: ["./src/index.ts"], - }); - fs.writeFileSync( - path.join(globalExt, "src", "index.ts"), - "export default function () {}", - "utf-8", - ); + const discoverParams = setup(stateDir); + const { candidates } = await discoverWithStateDir(stateDir, discoverParams); + expectCandidateIds(candidates, { includes, excludes }); + }); + it.each([ + { + name: "auto-detects Codex bundles as bundle candidates", + idHint: "sample-bundle", + bundleFormat: "codex", + setup: (stateDir: string) => { + const bundleDir = path.join(stateDir, "extensions", "sample-bundle"); + createBundleRoot(bundleDir, ".codex-plugin/plugin.json", { + name: "Sample Bundle", + skills: "skills", + }); + mkdirSafe(path.join(bundleDir, "skills")); + return bundleDir; + }, + expectRootDir: true, + }, + { + name: "auto-detects manifestless Claude bundles from the default layout", + idHint: "claude-bundle", + bundleFormat: "claude", + setup: (stateDir: string) => { + const bundleDir = path.join(stateDir, "extensions", "claude-bundle"); + mkdirSafe(path.join(bundleDir, "commands")); + fs.writeFileSync( + path.join(bundleDir, "settings.json"), + '{"hideThinkingBlock":true}', + "utf-8", + ); + return bundleDir; + }, + }, + { + name: "auto-detects Cursor bundles as bundle candidates", + idHint: "cursor-bundle", + bundleFormat: "cursor", + setup: (stateDir: string) => { + const bundleDir = path.join(stateDir, "extensions", "cursor-bundle"); + createBundleRoot(bundleDir, ".cursor-plugin/plugin.json", { + name: "Cursor Bundle", + }); + mkdirSafe(path.join(bundleDir, ".cursor", "commands")); + return bundleDir; + }, + }, + ] as const)("$name", async ({ idHint, bundleFormat, setup, expectRootDir }) => { + const stateDir = makeTempDir(); + const bundleDir = setup(stateDir); const { candidates } = await discoverWithStateDir(stateDir, {}); + const bundle = findCandidateById(candidates, idHint); - const ids = candidates.map((c) => c.idHint); - expect(ids).toContain("voice-call"); - }); - - it("strips provider suffixes from package-derived ids", async () => { - const stateDir = makeTempDir(); - const globalExt = path.join(stateDir, "extensions", "ollama-provider-pack"); - mkdirSafe(path.join(globalExt, "src")); - - writePluginPackageManifest({ - packageDir: globalExt, - packageName: "@openclaw/ollama-provider", - extensions: ["./src/index.ts"], - }); - writePluginManifest({ pluginDir: globalExt, id: "ollama" }); - fs.writeFileSync( - path.join(globalExt, "src", "index.ts"), - "export default function () {}", - "utf-8", - ); - - const { candidates } = await discoverWithStateDir(stateDir, {}); - - const ids = candidates.map((c) => c.idHint); - expect(ids).toContain("ollama"); - expect(ids).not.toContain("ollama-provider"); - }); - - it("normalizes bundled speech package ids to canonical plugin ids", async () => { - const stateDir = makeTempDir(); - const extensionsDir = path.join(stateDir, "extensions"); - const elevenlabsDir = path.join(extensionsDir, "elevenlabs-speech-pack"); - const microsoftDir = path.join(extensionsDir, "microsoft-speech-pack"); - - mkdirSafe(path.join(elevenlabsDir, "src")); - mkdirSafe(path.join(microsoftDir, "src")); - - writePluginPackageManifest({ - packageDir: elevenlabsDir, - packageName: "@openclaw/elevenlabs-speech", - extensions: ["./src/index.ts"], - }); - writePluginManifest({ pluginDir: elevenlabsDir, id: "elevenlabs" }); - writePluginPackageManifest({ - packageDir: microsoftDir, - packageName: "@openclaw/microsoft-speech", - extensions: ["./src/index.ts"], - }); - writePluginManifest({ pluginDir: microsoftDir, id: "microsoft" }); - - fs.writeFileSync( - path.join(elevenlabsDir, "src", "index.ts"), - "export default function () {}", - "utf-8", - ); - fs.writeFileSync( - path.join(microsoftDir, "src", "index.ts"), - "export default function () {}", - "utf-8", - ); - - const { candidates } = await discoverWithStateDir(stateDir, {}); - - const ids = candidates.map((c) => c.idHint); - expect(ids).toContain("elevenlabs"); - expect(ids).toContain("microsoft"); - expect(ids).not.toContain("elevenlabs-speech"); - expect(ids).not.toContain("microsoft-speech"); - }); - - it("treats configured directory paths as plugin packages", async () => { - const stateDir = makeTempDir(); - const packDir = path.join(stateDir, "packs", "demo-plugin-dir"); - mkdirSafe(packDir); - - writePluginPackageManifest({ - packageDir: packDir, - packageName: "@openclaw/demo-plugin-dir", - extensions: ["./index.js"], - }); - fs.writeFileSync(path.join(packDir, "index.js"), "module.exports = {}", "utf-8"); - - const { candidates } = await discoverWithStateDir(stateDir, { extraPaths: [packDir] }); - - const ids = candidates.map((c) => c.idHint); - expect(ids).toContain("demo-plugin-dir"); - }); - - it("auto-detects Codex bundles as bundle candidates", async () => { - const stateDir = makeTempDir(); - const bundleDir = path.join(stateDir, "extensions", "sample-bundle"); - mkdirSafe(path.join(bundleDir, ".codex-plugin")); - mkdirSafe(path.join(bundleDir, "skills")); - fs.writeFileSync( - path.join(bundleDir, ".codex-plugin", "plugin.json"), - JSON.stringify({ - name: "Sample Bundle", - skills: "skills", + expect(bundle).toBeDefined(); + expect(bundle).toEqual( + expect.objectContaining({ + idHint, + format: "bundle", + bundleFormat, + source: bundleDir, }), - "utf-8", - ); - - const { candidates } = await discoverWithStateDir(stateDir, {}); - const bundle = candidates.find((candidate) => candidate.idHint === "sample-bundle"); - - expect(bundle).toBeDefined(); - expect(bundle?.idHint).toBe("sample-bundle"); - expect(bundle?.format).toBe("bundle"); - expect(bundle?.bundleFormat).toBe("codex"); - expect(bundle?.source).toBe(bundleDir); - expect(normalizePathForAssertion(bundle?.rootDir)).toBe( - normalizePathForAssertion(fs.realpathSync(bundleDir)), ); + if (expectRootDir) { + expect(normalizePathForAssertion(bundle?.rootDir)).toBe( + normalizePathForAssertion(fs.realpathSync(bundleDir)), + ); + } }); - it("auto-detects manifestless Claude bundles from the default layout", async () => { + it.each([ + { + name: "falls back to legacy index discovery when a scanned bundle sidecar is malformed", + bundleMarker: ".claude-plugin/plugin.json", + setup: (stateDir: string) => { + const pluginDir = path.join(stateDir, "extensions", "legacy-with-bad-bundle"); + mkdirSafe(path.dirname(path.join(pluginDir, ".claude-plugin", "plugin.json"))); + fs.writeFileSync(path.join(pluginDir, "index.ts"), "export default {}", "utf-8"); + fs.writeFileSync(path.join(pluginDir, ".claude-plugin", "plugin.json"), "{", "utf-8"); + return {}; + }, + }, + { + name: "falls back to legacy index discovery for configured paths with malformed bundle sidecars", + bundleMarker: ".codex-plugin/plugin.json", + setup: (stateDir: string) => { + const pluginDir = path.join(stateDir, "plugins", "legacy-with-bad-bundle"); + mkdirSafe(path.dirname(path.join(pluginDir, ".codex-plugin", "plugin.json"))); + fs.writeFileSync(path.join(pluginDir, "index.ts"), "export default {}", "utf-8"); + fs.writeFileSync(path.join(pluginDir, ".codex-plugin", "plugin.json"), "{", "utf-8"); + return { extraPaths: [pluginDir] }; + }, + }, + ] as const)("$name", async ({ setup, bundleMarker }) => { const stateDir = makeTempDir(); - const bundleDir = path.join(stateDir, "extensions", "claude-bundle"); - mkdirSafe(path.join(bundleDir, "commands")); - fs.writeFileSync(path.join(bundleDir, "settings.json"), '{"hideThinkingBlock":true}', "utf-8"); + const result = await discoverWithStateDir(stateDir, setup(stateDir)); + const legacy = findCandidateById(result.candidates, "legacy-with-bad-bundle"); - const { candidates } = await discoverWithStateDir(stateDir, {}); - const bundle = candidates.find((candidate) => candidate.idHint === "claude-bundle"); - - expect(bundle).toBeDefined(); - expect(bundle?.format).toBe("bundle"); - expect(bundle?.bundleFormat).toBe("claude"); - expect(bundle?.source).toBe(bundleDir); - }); - - it("auto-detects Cursor bundles as bundle candidates", async () => { - const stateDir = makeTempDir(); - const bundleDir = path.join(stateDir, "extensions", "cursor-bundle"); - mkdirSafe(path.join(bundleDir, ".cursor-plugin")); - mkdirSafe(path.join(bundleDir, ".cursor", "commands")); - fs.writeFileSync( - path.join(bundleDir, ".cursor-plugin", "plugin.json"), - JSON.stringify({ - name: "Cursor Bundle", - }), - "utf-8", - ); - - const { candidates } = await discoverWithStateDir(stateDir, {}); - const bundle = candidates.find((candidate) => candidate.idHint === "cursor-bundle"); - - expect(bundle).toBeDefined(); - expect(bundle?.format).toBe("bundle"); - expect(bundle?.bundleFormat).toBe("cursor"); - expect(bundle?.source).toBe(bundleDir); - }); - - it("falls back to legacy index discovery when a scanned bundle sidecar is malformed", async () => { - const stateDir = makeTempDir(); - const pluginDir = path.join(stateDir, "extensions", "legacy-with-bad-bundle"); - mkdirSafe(path.join(pluginDir, ".claude-plugin")); - fs.writeFileSync(path.join(pluginDir, "index.ts"), "export default {}", "utf-8"); - fs.writeFileSync(path.join(pluginDir, ".claude-plugin", "plugin.json"), "{", "utf-8"); - - const result = await discoverWithStateDir(stateDir, {}); - const legacy = result.candidates.find( - (candidate) => candidate.idHint === "legacy-with-bad-bundle", - ); - - expect(legacy).toBeDefined(); expect(legacy?.format).toBe("openclaw"); - expect(hasDiagnosticSourceSuffix(result.diagnostics, ".claude-plugin/plugin.json")).toBe(true); - }); - - it("falls back to legacy index discovery for configured paths with malformed bundle sidecars", async () => { - const stateDir = makeTempDir(); - const pluginDir = path.join(stateDir, "plugins", "legacy-with-bad-bundle"); - mkdirSafe(path.join(pluginDir, ".codex-plugin")); - fs.writeFileSync(path.join(pluginDir, "index.ts"), "export default {}", "utf-8"); - fs.writeFileSync(path.join(pluginDir, ".codex-plugin", "plugin.json"), "{", "utf-8"); - - const result = await discoverWithStateDir(stateDir, { - extraPaths: [pluginDir], - }); - const legacy = result.candidates.find( - (candidate) => candidate.idHint === "legacy-with-bad-bundle", - ); - - expect(legacy).toBeDefined(); - expect(legacy?.format).toBe("openclaw"); - expect(hasDiagnosticSourceSuffix(result.diagnostics, ".codex-plugin/plugin.json")).toBe(true); + expect(hasDiagnosticSourceSuffix(result.diagnostics, bundleMarker)).toBe(true); }); it("blocks extension entries that escape package directory", async () => { @@ -635,57 +667,29 @@ describe("discoverOpenClawPlugins", () => { const pluginPath = path.join(globalExt, "cached.ts"); fs.writeFileSync(pluginPath, "export default function () {}", "utf-8"); - const first = discoverOpenClawPlugins({ - env: { - ...buildDiscoveryEnv(stateDir), - OPENCLAW_PLUGIN_DISCOVERY_CACHE_MS: "5000", - }, - }); + const cachedEnv = buildCachedDiscoveryEnv(stateDir); + const first = discoverWithCachedEnv({ env: cachedEnv }); expect(first.candidates.some((candidate) => candidate.idHint === "cached")).toBe(true); fs.rmSync(pluginPath, { force: true }); - const second = discoverOpenClawPlugins({ - env: { - ...buildDiscoveryEnv(stateDir), - OPENCLAW_PLUGIN_DISCOVERY_CACHE_MS: "5000", - }, - }); + const second = discoverWithCachedEnv({ env: cachedEnv }); expect(second.candidates.some((candidate) => candidate.idHint === "cached")).toBe(true); clearPluginDiscoveryCache(); - const third = discoverOpenClawPlugins({ - env: { - ...buildDiscoveryEnv(stateDir), - OPENCLAW_PLUGIN_DISCOVERY_CACHE_MS: "5000", - }, - }); + const third = discoverWithCachedEnv({ env: cachedEnv }); 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(); - const globalExtA = path.join(stateDirA, "extensions"); - const globalExtB = path.join(stateDirB, "extensions"); - mkdirSafe(globalExtA); - mkdirSafe(globalExtB); - fs.writeFileSync(path.join(globalExtA, "alpha.ts"), "export default function () {}", "utf-8"); - fs.writeFileSync(path.join(globalExtB, "beta.ts"), "export default function () {}", "utf-8"); + writeStandalonePlugin(path.join(stateDirA, "extensions", "alpha.ts")); + writeStandalonePlugin(path.join(stateDirB, "extensions", "beta.ts")); - const first = discoverOpenClawPlugins({ - env: { - ...buildDiscoveryEnv(stateDirA), - OPENCLAW_PLUGIN_DISCOVERY_CACHE_MS: "5000", - }, - }); - const second = discoverOpenClawPlugins({ - env: { - ...buildDiscoveryEnv(stateDirB), - OPENCLAW_PLUGIN_DISCOVERY_CACHE_MS: "5000", - }, - }); + 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); @@ -699,52 +703,36 @@ describe("discoverOpenClawPlugins", () => { const homeB = makeTempDir(); const pluginA = path.join(homeA, "plugins", "demo.ts"); const pluginB = path.join(homeB, "plugins", "demo.ts"); - mkdirSafe(path.dirname(pluginA)); - mkdirSafe(path.dirname(pluginB)); - fs.writeFileSync(pluginA, "export default {}", "utf-8"); - fs.writeFileSync(pluginB, "export default {}", "utf-8"); + writeStandalonePlugin(pluginA, "export default {}"); + writeStandalonePlugin(pluginB, "export default {}"); - const first = discoverOpenClawPlugins({ + const first = discoverWithCachedEnv({ extraPaths: ["~/plugins/demo.ts"], - env: { - ...buildDiscoveryEnv(stateDir), - HOME: homeA, - OPENCLAW_PLUGIN_DISCOVERY_CACHE_MS: "5000", - }, + env: buildCachedDiscoveryEnv(stateDir, { HOME: homeA }), }); - const second = discoverOpenClawPlugins({ + const second = discoverWithCachedEnv({ extraPaths: ["~/plugins/demo.ts"], - env: { - ...buildDiscoveryEnv(stateDir), - HOME: homeB, - OPENCLAW_PLUGIN_DISCOVERY_CACHE_MS: "5000", - }, + env: buildCachedDiscoveryEnv(stateDir, { HOME: homeB }), }); - expect(first.candidates.find((candidate) => candidate.idHint === "demo")?.source).toBe(pluginA); - expect(second.candidates.find((candidate) => candidate.idHint === "demo")?.source).toBe( - pluginB, - ); + expectCandidateSource(first.candidates, "demo", pluginA); + expectCandidateSource(second.candidates, "demo", pluginB); }); it("treats configured load-path order as cache-significant", () => { const stateDir = makeTempDir(); const pluginA = path.join(stateDir, "plugins", "alpha.ts"); const pluginB = path.join(stateDir, "plugins", "beta.ts"); - mkdirSafe(path.dirname(pluginA)); - fs.writeFileSync(pluginA, "export default {}", "utf-8"); - fs.writeFileSync(pluginB, "export default {}", "utf-8"); + writeStandalonePlugin(pluginA, "export default {}"); + writeStandalonePlugin(pluginB, "export default {}"); - const env = { - ...buildDiscoveryEnv(stateDir), - OPENCLAW_PLUGIN_DISCOVERY_CACHE_MS: "5000", - }; + const env = buildCachedDiscoveryEnv(stateDir); - const first = discoverOpenClawPlugins({ + const first = discoverWithCachedEnv({ extraPaths: [pluginA, pluginB], env, }); - const second = discoverOpenClawPlugins({ + const second = discoverWithCachedEnv({ extraPaths: [pluginB, pluginA], env, }); diff --git a/src/plugins/http-registry.test.ts b/src/plugins/http-registry.test.ts index cb7c1f37596..e889545bd0e 100644 --- a/src/plugins/http-registry.test.ts +++ b/src/plugins/http-registry.test.ts @@ -43,6 +43,26 @@ function expectRouteRegistrationDenied(params: { expect(registry.httpRoutes).toHaveLength(1); } +function expectRegisteredRouteShape( + registry: ReturnType, + params: { + path: string; + handler?: unknown; + auth: "plugin" | "gateway"; + match?: "exact" | "prefix"; + }, +) { + expect(registry.httpRoutes).toHaveLength(1); + expect(registry.httpRoutes[0]).toEqual( + expect.objectContaining({ + path: params.path, + auth: params.auth, + ...(params.match ? { match: params.match } : {}), + ...(params.handler ? { handler: params.handler } : {}), + }), + ); +} + describe("registerPluginHttpRoute", () => { afterEach(() => { releasePinnedPluginHttpRouteRegistry(); @@ -60,11 +80,12 @@ describe("registerPluginHttpRoute", () => { registry, }); - expect(registry.httpRoutes).toHaveLength(1); - expect(registry.httpRoutes[0]?.path).toBe("/plugins/demo"); - expect(registry.httpRoutes[0]?.handler).toBe(handler); - expect(registry.httpRoutes[0]?.auth).toBe("plugin"); - expect(registry.httpRoutes[0]?.match).toBe("exact"); + expectRegisteredRouteShape(registry, { + path: "/plugins/demo", + handler, + auth: "plugin", + match: "exact", + }); unregister(); expect(registry.httpRoutes).toHaveLength(0); @@ -129,17 +150,21 @@ describe("registerPluginHttpRoute", () => { expect(registry.httpRoutes).toHaveLength(0); }); - it("rejects conflicting route registrations without replaceExisting", () => { - expectRouteRegistrationDenied({ + it.each([ + { + name: "rejects conflicting route registrations without replaceExisting", replaceExisting: false, expectedLogFragment: "route conflict", - }); - }); - - it("rejects route replacement when a different plugin owns the route", () => { - expectRouteRegistrationDenied({ + }, + { + name: "rejects route replacement when a different plugin owns the route", replaceExisting: true, expectedLogFragment: "route replacement denied", + }, + ] as const)("$name", ({ replaceExisting, expectedLogFragment }) => { + expectRouteRegistrationDenied({ + replaceExisting, + expectedLogFragment, }); }); @@ -190,8 +215,10 @@ describe("registerPluginHttpRoute", () => { handler: vi.fn(), }); - expect(startupRegistry.httpRoutes).toHaveLength(1); - expect(startupRegistry.httpRoutes[0]?.path).toBe("/bluebubbles-webhook"); + expectRegisteredRouteShape(startupRegistry, { + path: "/bluebubbles-webhook", + auth: "plugin", + }); expect(laterActiveRegistry.httpRoutes).toHaveLength(0); unregister(); diff --git a/src/plugins/interactive.test.ts b/src/plugins/interactive.test.ts index 6a149c55729..fc6eb587cad 100644 --- a/src/plugins/interactive.test.ts +++ b/src/plugins/interactive.test.ts @@ -192,6 +192,113 @@ async function expectDedupedInteractiveDispatch(params: { expect(params.handler).toHaveBeenCalledWith(expect.objectContaining(params.expectedCall)); } +async function dispatchInteractive(params: InteractiveDispatchParams) { + if (params.channel === "telegram") { + return await dispatchPluginInteractiveHandler(params); + } + if (params.channel === "discord") { + return await dispatchPluginInteractiveHandler(params); + } + return await dispatchPluginInteractiveHandler(params); +} + +function expectRegisteredInteractiveHandler(params: { + channel: "telegram" | "discord" | "slack"; + namespace: string; + handler: ReturnType; +}) { + expect( + registerPluginInteractiveHandler("codex-plugin", { + channel: params.channel, + namespace: params.namespace, + handler: params.handler as never, + }), + ).toEqual({ ok: true }); +} + +type BindingHelperCase = { + name: string; + registerParams: { channel: "telegram" | "discord" | "slack"; namespace: string }; + dispatchParams: InteractiveDispatchParams; + requestResult: { + status: "bound"; + binding: { + bindingId: string; + pluginId: string; + pluginName: string; + pluginRoot: string; + channel: string; + accountId: string; + conversationId: string; + parentConversationId?: string; + threadId?: string | number; + boundAt: number; + }; + }; + requestSummary: string; + expectedConversation: { + channel: string; + accountId: string; + conversationId: string; + parentConversationId?: string; + threadId?: string | number; + }; +}; + +async function expectBindingHelperWiring(params: BindingHelperCase) { + const currentBinding = { + ...params.requestResult.binding, + boundAt: params.requestResult.binding.boundAt + 1, + }; + requestPluginConversationBindingMock.mockResolvedValueOnce(params.requestResult); + getCurrentPluginConversationBindingMock.mockResolvedValueOnce(currentBinding); + + const handler = vi.fn(async (ctx) => { + await expect( + ctx.requestConversationBinding({ summary: params.requestSummary }), + ).resolves.toEqual(params.requestResult); + await expect(ctx.detachConversationBinding()).resolves.toEqual({ removed: true }); + await expect(ctx.getCurrentConversationBinding()).resolves.toEqual(currentBinding); + return { handled: true }; + }); + + expect( + registerPluginInteractiveHandler( + "codex-plugin", + { + ...params.registerParams, + handler: handler as never, + }, + { pluginName: "Codex", pluginRoot: "/plugins/codex" }, + ), + ).toEqual({ ok: true }); + + await expect(dispatchInteractive(params.dispatchParams)).resolves.toEqual({ + matched: true, + handled: true, + duplicate: false, + }); + + expect(requestPluginConversationBindingMock).toHaveBeenCalledWith({ + pluginId: "codex-plugin", + pluginName: "Codex", + pluginRoot: "/plugins/codex", + requestedBySenderId: "user-1", + conversation: params.expectedConversation, + binding: { + summary: params.requestSummary, + }, + }); + expect(detachPluginConversationBindingMock).toHaveBeenCalledWith({ + pluginRoot: "/plugins/codex", + conversation: params.expectedConversation, + }); + expect(getCurrentPluginConversationBindingMock).toHaveBeenCalledWith({ + pluginRoot: "/plugins/codex", + conversation: params.expectedConversation, + }); +} + describe("plugin interactive handlers", () => { beforeEach(() => { clearPluginInteractiveHandlers(); @@ -235,24 +342,14 @@ describe("plugin interactive handlers", () => { vi.restoreAllMocks(); }); - it("routes Telegram callbacks by namespace and dedupes callback ids", async () => { - const handler = vi.fn(async () => ({ handled: true })); - expect( - registerPluginInteractiveHandler("codex-plugin", { - channel: "telegram", - namespace: "codex", - handler, + it.each([ + { + name: "routes Telegram callbacks by namespace and dedupes callback ids", + channel: "telegram" as const, + baseParams: createTelegramDispatchParams({ + data: "codex:resume:thread-1", + callbackId: "cb-1", }), - ).toEqual({ ok: true }); - - const baseParams = createTelegramDispatchParams({ - data: "codex:resume:thread-1", - callbackId: "cb-1", - }); - - await expectDedupedInteractiveDispatch({ - baseParams, - handler, expectedCall: { channel: "telegram", conversationId: "-10099:topic:77", @@ -263,6 +360,58 @@ describe("plugin interactive handlers", () => { messageId: 55, }), }, + }, + { + name: "routes Discord interactions by namespace and dedupes interaction ids", + channel: "discord" as const, + baseParams: createDiscordDispatchParams({ + data: "codex:approve:thread-1", + interactionId: "ix-1", + interaction: { kind: "button", values: ["allow"] }, + }), + expectedCall: { + channel: "discord", + conversationId: "channel-1", + interaction: expect.objectContaining({ + namespace: "codex", + payload: "approve:thread-1", + messageId: "message-1", + values: ["allow"], + }), + }, + }, + { + name: "routes Slack interactions by namespace and dedupes interaction ids", + channel: "slack" as const, + baseParams: createSlackDispatchParams({ + data: "codex:approve:thread-1", + interactionId: "slack-ix-1", + interaction: { kind: "button" }, + }), + expectedCall: { + channel: "slack", + conversationId: "C123", + threadId: "1710000000.000100", + interaction: expect.objectContaining({ + namespace: "codex", + payload: "approve:thread-1", + actionId: "codex", + messageTs: "1710000000.000200", + }), + }, + }, + ] as const)("$name", async ({ channel, baseParams, expectedCall }) => { + const handler = vi.fn(async () => ({ handled: true })); + expectRegisteredInteractiveHandler({ + channel, + namespace: "codex", + handler, + }); + + await expectDedupedInteractiveDispatch({ + baseParams, + handler, + expectedCall, }); }); @@ -322,38 +471,6 @@ describe("plugin interactive handlers", () => { }); }); - it("routes Discord interactions by namespace and dedupes interaction ids", async () => { - const handler = vi.fn(async () => ({ handled: true })); - expect( - registerPluginInteractiveHandler("codex-plugin", { - channel: "discord", - namespace: "codex", - handler, - }), - ).toEqual({ ok: true }); - - const baseParams = createDiscordDispatchParams({ - data: "codex:approve:thread-1", - interactionId: "ix-1", - interaction: { kind: "button", values: ["allow"] }, - }); - - await expectDedupedInteractiveDispatch({ - baseParams, - handler, - expectedCall: { - channel: "discord", - conversationId: "channel-1", - interaction: expect.objectContaining({ - namespace: "codex", - payload: "approve:thread-1", - messageId: "message-1", - values: ["allow"], - }), - }, - }); - }); - it("acknowledges matched Discord interactions before awaiting plugin handlers", async () => { const callOrder: string[] = []; const handler = vi.fn(async () => { @@ -387,326 +504,107 @@ describe("plugin interactive handlers", () => { }); }); - it("routes Slack interactions by namespace and dedupes interaction ids", async () => { - const handler = vi.fn(async () => ({ handled: true })); - expect( - registerPluginInteractiveHandler("codex-plugin", { - channel: "slack", - namespace: "codex", - handler, + it.each([ + { + name: "wires Telegram conversation binding helpers with topic context", + registerParams: { channel: "telegram", namespace: "codex" }, + dispatchParams: createTelegramDispatchParams({ + data: "codex:bind", + callbackId: "cb-bind", }), - ).toEqual({ ok: true }); - - const baseParams = createSlackDispatchParams({ - data: "codex:approve:thread-1", - interactionId: "slack-ix-1", - interaction: { kind: "button" }, - }); - - await expectDedupedInteractiveDispatch({ - baseParams, - handler, - expectedCall: { - channel: "slack", - conversationId: "C123", - threadId: "1710000000.000100", - interaction: expect.objectContaining({ - namespace: "codex", - payload: "approve:thread-1", - actionId: "codex", - messageTs: "1710000000.000200", - }), - }, - }); - }); - - it("wires Telegram conversation binding helpers with topic context", async () => { - const requestResult = { - status: "bound" as const, - binding: { - bindingId: "binding-telegram", - pluginId: "codex-plugin", - pluginName: "Codex", - pluginRoot: "/plugins/codex", - channel: "telegram", - accountId: "default", - conversationId: "-10099:topic:77", - parentConversationId: "-10099", - threadId: 77, - boundAt: 1, - }, - }; - const currentBinding = { - ...requestResult.binding, - boundAt: 2, - }; - requestPluginConversationBindingMock.mockResolvedValueOnce(requestResult); - getCurrentPluginConversationBindingMock.mockResolvedValueOnce(currentBinding); - - const handler = vi.fn(async (ctx) => { - await expect( - ctx.requestConversationBinding({ - summary: "Bind this topic", - detachHint: "Use /new to detach", - }), - ).resolves.toEqual(requestResult); - await expect(ctx.detachConversationBinding()).resolves.toEqual({ removed: true }); - await expect(ctx.getCurrentConversationBinding()).resolves.toEqual(currentBinding); - return { handled: true }; - }); - expect( - registerPluginInteractiveHandler( - "codex-plugin", - { + requestResult: { + status: "bound" as const, + binding: { + bindingId: "binding-telegram", + pluginId: "codex-plugin", + pluginName: "Codex", + pluginRoot: "/plugins/codex", channel: "telegram", - namespace: "codex", - handler, + accountId: "default", + conversationId: "-10099:topic:77", + parentConversationId: "-10099", + threadId: 77, + boundAt: 1, }, - { pluginName: "Codex", pluginRoot: "/plugins/codex" }, - ), - ).toEqual({ ok: true }); - - await expect( - dispatchPluginInteractiveHandler( - createTelegramDispatchParams({ - data: "codex:bind", - callbackId: "cb-bind", - }), - ), - ).resolves.toEqual({ - matched: true, - handled: true, - duplicate: false, - }); - - expect(requestPluginConversationBindingMock).toHaveBeenCalledWith({ - pluginId: "codex-plugin", - pluginName: "Codex", - pluginRoot: "/plugins/codex", - requestedBySenderId: "user-1", - conversation: { + }, + requestSummary: "Bind this topic", + expectedConversation: { channel: "telegram", accountId: "default", conversationId: "-10099:topic:77", parentConversationId: "-10099", threadId: 77, }, - binding: { - summary: "Bind this topic", - detachHint: "Use /new to detach", - }, - }); - expect(detachPluginConversationBindingMock).toHaveBeenCalledWith({ - pluginRoot: "/plugins/codex", - conversation: { - channel: "telegram", - accountId: "default", - conversationId: "-10099:topic:77", - parentConversationId: "-10099", - threadId: 77, - }, - }); - expect(getCurrentPluginConversationBindingMock).toHaveBeenCalledWith({ - pluginRoot: "/plugins/codex", - conversation: { - channel: "telegram", - accountId: "default", - conversationId: "-10099:topic:77", - parentConversationId: "-10099", - threadId: 77, - }, - }); - }); - - it("wires Discord conversation binding helpers with parent channel context", async () => { - const requestResult = { - status: "bound" as const, - binding: { - bindingId: "binding-discord", - pluginId: "codex-plugin", - pluginName: "Codex", - pluginRoot: "/plugins/codex", - channel: "discord", - accountId: "default", - conversationId: "channel-1", - parentConversationId: "parent-1", - boundAt: 1, - }, - }; - const currentBinding = { - ...requestResult.binding, - boundAt: 2, - }; - requestPluginConversationBindingMock.mockResolvedValueOnce(requestResult); - getCurrentPluginConversationBindingMock.mockResolvedValueOnce(currentBinding); - - const handler = vi.fn(async (ctx) => { - await expect(ctx.requestConversationBinding({ summary: "Bind Discord" })).resolves.toEqual( - requestResult, - ); - await expect(ctx.detachConversationBinding()).resolves.toEqual({ removed: true }); - await expect(ctx.getCurrentConversationBinding()).resolves.toEqual(currentBinding); - return { handled: true }; - }); - expect( - registerPluginInteractiveHandler( - "codex-plugin", - { + }, + { + name: "wires Discord conversation binding helpers with parent channel context", + registerParams: { channel: "discord", namespace: "codex" }, + dispatchParams: createDiscordDispatchParams({ + data: "codex:bind", + interactionId: "ix-bind", + interaction: { kind: "button", values: ["allow"] }, + }), + requestResult: { + status: "bound" as const, + binding: { + bindingId: "binding-discord", + pluginId: "codex-plugin", + pluginName: "Codex", + pluginRoot: "/plugins/codex", channel: "discord", - namespace: "codex", - handler, + accountId: "default", + conversationId: "channel-1", + parentConversationId: "parent-1", + boundAt: 1, }, - { pluginName: "Codex", pluginRoot: "/plugins/codex" }, - ), - ).toEqual({ ok: true }); - - await expect( - dispatchPluginInteractiveHandler( - createDiscordDispatchParams({ - data: "codex:bind", - interactionId: "ix-bind", - interaction: { kind: "button", values: ["allow"] }, - }), - ), - ).resolves.toEqual({ - matched: true, - handled: true, - duplicate: false, - }); - - expect(requestPluginConversationBindingMock).toHaveBeenCalledWith({ - pluginId: "codex-plugin", - pluginName: "Codex", - pluginRoot: "/plugins/codex", - requestedBySenderId: "user-1", - conversation: { + }, + requestSummary: "Bind Discord", + expectedConversation: { channel: "discord", accountId: "default", conversationId: "channel-1", parentConversationId: "parent-1", }, - binding: { - summary: "Bind Discord", - }, - }); - expect(detachPluginConversationBindingMock).toHaveBeenCalledWith({ - pluginRoot: "/plugins/codex", - conversation: { - channel: "discord", - accountId: "default", - conversationId: "channel-1", - parentConversationId: "parent-1", - }, - }); - expect(getCurrentPluginConversationBindingMock).toHaveBeenCalledWith({ - pluginRoot: "/plugins/codex", - conversation: { - channel: "discord", - accountId: "default", - conversationId: "channel-1", - parentConversationId: "parent-1", - }, - }); - }); - - it("wires Slack conversation binding helpers with thread context", async () => { - const requestResult = { - status: "bound" as const, - binding: { - bindingId: "binding-slack", - pluginId: "codex-plugin", - pluginName: "Codex", - pluginRoot: "/plugins/codex", - channel: "slack", - accountId: "default", - conversationId: "C123", - parentConversationId: "C123", - threadId: "1710000000.000100", - boundAt: 1, - }, - }; - const currentBinding = { - ...requestResult.binding, - boundAt: 2, - }; - requestPluginConversationBindingMock.mockResolvedValueOnce(requestResult); - getCurrentPluginConversationBindingMock.mockResolvedValueOnce(currentBinding); - - const handler = vi.fn(async (ctx) => { - await expect(ctx.requestConversationBinding({ summary: "Bind Slack" })).resolves.toEqual( - requestResult, - ); - await expect(ctx.detachConversationBinding()).resolves.toEqual({ removed: true }); - await expect(ctx.getCurrentConversationBinding()).resolves.toEqual(currentBinding); - return { handled: true }; - }); - expect( - registerPluginInteractiveHandler( - "codex-plugin", - { + }, + { + name: "wires Slack conversation binding helpers with thread context", + registerParams: { channel: "slack", namespace: "codex" }, + dispatchParams: createSlackDispatchParams({ + data: "codex:bind", + interactionId: "slack-bind", + interaction: { + kind: "button", + value: "bind", + selectedValues: ["bind"], + selectedLabels: ["Bind"], + }, + }), + requestResult: { + status: "bound" as const, + binding: { + bindingId: "binding-slack", + pluginId: "codex-plugin", + pluginName: "Codex", + pluginRoot: "/plugins/codex", channel: "slack", - namespace: "codex", - handler, + accountId: "default", + conversationId: "C123", + parentConversationId: "C123", + threadId: "1710000000.000100", + boundAt: 1, }, - { pluginName: "Codex", pluginRoot: "/plugins/codex" }, - ), - ).toEqual({ ok: true }); - - await expect( - dispatchPluginInteractiveHandler( - createSlackDispatchParams({ - data: "codex:bind", - interactionId: "slack-bind", - interaction: { - kind: "button", - value: "bind", - selectedValues: ["bind"], - selectedLabels: ["Bind"], - }, - }), - ), - ).resolves.toEqual({ - matched: true, - handled: true, - duplicate: false, - }); - - expect(requestPluginConversationBindingMock).toHaveBeenCalledWith({ - pluginId: "codex-plugin", - pluginName: "Codex", - pluginRoot: "/plugins/codex", - requestedBySenderId: "user-1", - conversation: { + }, + requestSummary: "Bind Slack", + expectedConversation: { channel: "slack", accountId: "default", conversationId: "C123", parentConversationId: "C123", threadId: "1710000000.000100", }, - binding: { - summary: "Bind Slack", - }, - }); - expect(detachPluginConversationBindingMock).toHaveBeenCalledWith({ - pluginRoot: "/plugins/codex", - conversation: { - channel: "slack", - accountId: "default", - conversationId: "C123", - parentConversationId: "C123", - threadId: "1710000000.000100", - }, - }); - expect(getCurrentPluginConversationBindingMock).toHaveBeenCalledWith({ - pluginRoot: "/plugins/codex", - conversation: { - channel: "slack", - accountId: "default", - conversationId: "C123", - parentConversationId: "C123", - threadId: "1710000000.000100", - }, - }); + }, + ] as const)("$name", async (testCase) => { + await expectBindingHelperWiring(testCase); }); it("does not consume dedupe keys when a handler throws", async () => { diff --git a/src/plugins/logger.test.ts b/src/plugins/logger.test.ts index cf1d773a9d6..40c3c4a0be1 100644 --- a/src/plugins/logger.test.ts +++ b/src/plugins/logger.test.ts @@ -3,20 +3,22 @@ import { createPluginLoaderLogger } from "./logger.js"; describe("plugins/logger", () => { it("forwards logger methods", () => { - const info = vi.fn(); - const warn = vi.fn(); - const error = vi.fn(); - const debug = vi.fn(); - const logger = createPluginLoaderLogger({ info, warn, error, debug }); + const methods = { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }; + const logger = createPluginLoaderLogger(methods); - logger.info("i"); - logger.warn("w"); - logger.error("e"); - logger.debug?.("d"); - - expect(info).toHaveBeenCalledWith("i"); - expect(warn).toHaveBeenCalledWith("w"); - expect(error).toHaveBeenCalledWith("e"); - expect(debug).toHaveBeenCalledWith("d"); + for (const [method, value] of [ + ["info", "i"], + ["warn", "w"], + ["error", "e"], + ["debug", "d"], + ] as const) { + logger[method]?.(value); + expect(methods[method]).toHaveBeenCalledWith(value); + } }); }); diff --git a/src/plugins/marketplace.test.ts b/src/plugins/marketplace.test.ts index 3cfafb87827..d5f1512fd48 100644 --- a/src/plugins/marketplace.test.ts +++ b/src/plugins/marketplace.test.ts @@ -24,6 +24,13 @@ async function withTempDir(fn: (dir: string) => Promise): Promise { } } +async function writeMarketplaceManifest(rootDir: string, manifest: unknown): Promise { + const manifestPath = path.join(rootDir, ".claude-plugin", "marketplace.json"); + await fs.mkdir(path.dirname(manifestPath), { recursive: true }); + await fs.writeFile(manifestPath, JSON.stringify(manifest)); + return manifestPath; +} + function mockRemoteMarketplaceClone(manifest: unknown) { runCommandWithTimeoutMock.mockImplementationOnce(async (argv: string[]) => { const repoDir = argv.at(-1); @@ -46,22 +53,18 @@ describe("marketplace plugins", () => { it("lists plugins from a local marketplace root", async () => { await withTempDir(async (rootDir) => { - await fs.mkdir(path.join(rootDir, ".claude-plugin"), { recursive: true }); - await fs.writeFile( - path.join(rootDir, ".claude-plugin", "marketplace.json"), - JSON.stringify({ - name: "Example Marketplace", - version: "1.0.0", - plugins: [ - { - name: "frontend-design", - version: "0.1.0", - description: "Design system bundle", - source: "./plugins/frontend-design", - }, - ], - }), - ); + await writeMarketplaceManifest(rootDir, { + name: "Example Marketplace", + version: "1.0.0", + plugins: [ + { + name: "frontend-design", + version: "0.1.0", + description: "Design system bundle", + source: "./plugins/frontend-design", + }, + ], + }); const { listMarketplacePlugins } = await import("./marketplace.js"); const result = await listMarketplacePlugins({ marketplace: rootDir }); @@ -88,19 +91,15 @@ describe("marketplace plugins", () => { 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(path.join(rootDir, ".claude-plugin"), { recursive: true }); await fs.mkdir(pluginDir, { recursive: true }); - await fs.writeFile( - path.join(rootDir, ".claude-plugin", "marketplace.json"), - JSON.stringify({ - plugins: [ - { - name: "frontend-design", - source: "./plugins/frontend-design", - }, - ], - }), - ); + const manifestPath = await writeMarketplaceManifest(rootDir, { + plugins: [ + { + name: "frontend-design", + source: "./plugins/frontend-design", + }, + ], + }); installPluginFromPathMock.mockResolvedValue({ ok: true, pluginId: "frontend-design", @@ -111,7 +110,7 @@ describe("marketplace plugins", () => { const { installPluginFromMarketplace } = await import("./marketplace.js"); const result = await installPluginFromMarketplace({ - marketplace: path.join(rootDir, ".claude-plugin", "marketplace.json"), + marketplace: manifestPath, plugin: "frontend-design", }); @@ -221,22 +220,18 @@ describe("marketplace plugins", () => { "fetch", vi.fn(async () => new Response(null, { status: 200 })), ); - await fs.mkdir(path.join(rootDir, ".claude-plugin"), { recursive: true }); - await fs.writeFile( - path.join(rootDir, ".claude-plugin", "marketplace.json"), - JSON.stringify({ - plugins: [ - { - name: "frontend-design", - source: "https://example.com/frontend-design.tgz", - }, - ], - }), - ); + const manifestPath = await writeMarketplaceManifest(rootDir, { + plugins: [ + { + name: "frontend-design", + source: "https://example.com/frontend-design.tgz", + }, + ], + }); const { installPluginFromMarketplace } = await import("./marketplace.js"); const result = await installPluginFromMarketplace({ - marketplace: path.join(rootDir, ".claude-plugin", "marketplace.json"), + marketplace: manifestPath, plugin: "frontend-design", }); @@ -248,77 +243,67 @@ describe("marketplace plugins", () => { }); }); - it("rejects remote marketplace git plugin sources before cloning nested remotes", async () => { - mockRemoteMarketplaceClone({ - plugins: [ - { - name: "frontend-design", - source: { - type: "git", - url: "https://evil.example/repo.git", + it.each([ + { + name: "rejects remote marketplace git plugin sources before cloning nested remotes", + manifest: { + plugins: [ + { + name: "frontend-design", + source: { + type: "git", + url: "https://evil.example/repo.git", + }, }, - }, - ], - }); - - const { listMarketplacePlugins } = await import("./marketplace.js"); - const result = await listMarketplacePlugins({ marketplace: "owner/repo" }); - - expect(result).toEqual({ - ok: false, - error: + ], + }, + expectedError: 'invalid marketplace entry "frontend-design" in owner/repo: ' + "remote marketplaces may not use git plugin sources", - }); - expect(runCommandWithTimeoutMock).toHaveBeenCalledTimes(1); - }); - - it("rejects remote marketplace absolute plugin paths", async () => { - mockRemoteMarketplaceClone({ - plugins: [ - { - name: "frontend-design", - source: { - type: "path", - path: "/tmp/frontend-design", + }, + { + name: "rejects remote marketplace absolute plugin paths", + manifest: { + plugins: [ + { + name: "frontend-design", + source: { + type: "path", + path: "/tmp/frontend-design", + }, }, - }, - ], - }); - - const { listMarketplacePlugins } = await import("./marketplace.js"); - const result = await listMarketplacePlugins({ marketplace: "owner/repo" }); - - expect(result).toEqual({ - ok: false, - error: + ], + }, + expectedError: 'invalid marketplace entry "frontend-design" in owner/repo: ' + "remote marketplaces may only use relative plugin paths", - }); - expect(runCommandWithTimeoutMock).toHaveBeenCalledTimes(1); - }); - - it("rejects remote marketplace HTTP plugin paths", async () => { - mockRemoteMarketplaceClone({ - plugins: [ - { - name: "frontend-design", - source: { - type: "path", - path: "https://evil.example/plugin.tgz", + }, + { + name: "rejects remote marketplace HTTP plugin paths", + manifest: { + plugins: [ + { + name: "frontend-design", + source: { + type: "path", + path: "https://evil.example/plugin.tgz", + }, }, - }, - ], - }); + ], + }, + expectedError: + 'invalid marketplace entry "frontend-design" in owner/repo: ' + + "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: - 'invalid marketplace entry "frontend-design" in owner/repo: ' + - "remote marketplaces may not use HTTP(S) plugin paths", + error: expectedError, }); expect(runCommandWithTimeoutMock).toHaveBeenCalledTimes(1); }); diff --git a/src/plugins/memory-state.test.ts b/src/plugins/memory-state.test.ts index e1542dca8c0..9c3ef95b0bc 100644 --- a/src/plugins/memory-state.test.ts +++ b/src/plugins/memory-state.test.ts @@ -13,6 +13,28 @@ import { restoreMemoryPluginState, } from "./memory-state.js"; +function createMemoryRuntime() { + return { + async getMemorySearchManager() { + return { manager: null, error: "missing" }; + }, + resolveMemoryBackendConfig() { + return { backend: "builtin" as const }; + }, + }; +} + +function createMemoryFlushPlan(relativePath: string) { + return { + softThresholdTokens: 1, + forceFlushTranscriptBytes: 2, + reserveTokensFloor: 3, + prompt: relativePath, + systemPrompt: relativePath, + relativePath, + }; +} + describe("memory plugin state", () => { afterEach(() => { clearMemoryPluginState(); @@ -66,14 +88,7 @@ describe("memory plugin state", () => { }); it("stores the registered memory runtime", async () => { - const runtime = { - async getMemorySearchManager() { - return { manager: null, error: "missing" }; - }, - resolveMemoryBackendConfig() { - return { backend: "builtin" as const }; - }, - }; + const runtime = createMemoryRuntime(); registerMemoryRuntime(runtime); @@ -88,22 +103,8 @@ describe("memory plugin state", () => { it("restoreMemoryPluginState swaps both prompt and flush state", () => { registerMemoryPromptSection(() => ["first"]); - registerMemoryFlushPlanResolver(() => ({ - softThresholdTokens: 1, - forceFlushTranscriptBytes: 2, - reserveTokensFloor: 3, - prompt: "first", - systemPrompt: "first", - relativePath: "memory/first.md", - })); - const runtime = { - async getMemorySearchManager() { - return { manager: null, error: "missing" }; - }, - resolveMemoryBackendConfig() { - return { backend: "builtin" as const }; - }, - }; + registerMemoryFlushPlanResolver(() => createMemoryFlushPlan("memory/first.md")); + const runtime = createMemoryRuntime(); registerMemoryRuntime(runtime); const snapshot = { promptBuilder: getMemoryPromptSectionBuilder(), @@ -124,22 +125,8 @@ describe("memory plugin state", () => { it("clearMemoryPluginState resets both registries", () => { registerMemoryPromptSection(() => ["stale section"]); - registerMemoryFlushPlanResolver(() => ({ - softThresholdTokens: 1, - forceFlushTranscriptBytes: 2, - reserveTokensFloor: 3, - prompt: "prompt", - systemPrompt: "system", - relativePath: "memory/stale.md", - })); - registerMemoryRuntime({ - async getMemorySearchManager() { - return { manager: null }; - }, - resolveMemoryBackendConfig() { - return { backend: "builtin" as const }; - }, - }); + registerMemoryFlushPlanResolver(() => createMemoryFlushPlan("memory/stale.md")); + registerMemoryRuntime(createMemoryRuntime()); clearMemoryPluginState();