test: dedupe plugin bundle and discovery helpers

This commit is contained in:
Peter Steinberger
2026-03-28 03:06:11 +00:00
parent 7a6f32a730
commit 95acd74d7c
7 changed files with 107 additions and 69 deletions

View File

@@ -32,6 +32,10 @@ describe("Claude bundle plugin inspect integration", () => {
expect(values).toEqual(expect.arrayContaining([...params.includes]));
}
function expectNoDiagnostics(diagnostics: unknown[]) {
expect(diagnostics).toEqual([]);
}
beforeAll(() => {
rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-claude-bundle-"));
@@ -182,7 +186,7 @@ describe("Claude bundle plugin inspect integration", () => {
expect(mcp.hasSupportedStdioServer).toBe(true);
expect(mcp.supportedServerNames).toContain("test-stdio-server");
expect(mcp.unsupportedServerNames).toContain("test-sse-server");
expect(mcp.diagnostics).toEqual([]);
expectNoDiagnostics(mcp.diagnostics);
});
it("inspects LSP runtime support with stdio server", () => {
@@ -195,6 +199,6 @@ describe("Claude bundle plugin inspect integration", () => {
expect(lsp.hasStdioServer).toBe(true);
expect(lsp.supportedServerNames).toContain("typescript-lsp");
expect(lsp.unsupportedServerNames).toEqual([]);
expect(lsp.diagnostics).toEqual([]);
expectNoDiagnostics(lsp.diagnostics);
});
});

View File

@@ -40,6 +40,37 @@ function writeBundleManifest(
fs.writeFileSync(path.join(rootDir, relativePath), JSON.stringify(manifest), "utf-8");
}
function writeJsonFile(rootDir: string, relativePath: string, value: unknown) {
mkdirSafe(path.dirname(path.join(rootDir, relativePath)));
fs.writeFileSync(path.join(rootDir, relativePath), JSON.stringify(value), "utf-8");
}
function setupClaudeHookFixture(
rootDir: string,
kind: "default-hooks" | "custom-hooks" | "no-hooks",
) {
mkdirSafe(path.join(rootDir, ".claude-plugin"));
if (kind === "default-hooks") {
mkdirSafe(path.join(rootDir, "hooks"));
writeJsonFile(rootDir, "hooks/hooks.json", { hooks: [] });
writeBundleManifest(rootDir, CLAUDE_BUNDLE_MANIFEST_RELATIVE_PATH, {
name: "Hook Plugin",
description: "Claude hooks fixture",
});
return;
}
if (kind === "custom-hooks") {
mkdirSafe(path.join(rootDir, "custom-hooks"));
writeBundleManifest(rootDir, CLAUDE_BUNDLE_MANIFEST_RELATIVE_PATH, {
name: "Custom Hook Plugin",
hooks: "custom-hooks",
});
return;
}
mkdirSafe(path.join(rootDir, "skills"));
writeBundleManifest(rootDir, CLAUDE_BUNDLE_MANIFEST_RELATIVE_PATH, { name: "No Hooks" });
}
afterEach(() => {
cleanupTrackedTempDirs(tempDirs);
});
@@ -224,36 +255,7 @@ describe("bundle manifest parsing", () => {
},
] as const)("$name", ({ setupKind, expectedHooks, hasHooksCapability }) => {
const rootDir = makeTempDir();
mkdirSafe(path.join(rootDir, ".claude-plugin"));
if (setupKind === "default-hooks") {
mkdirSafe(path.join(rootDir, "hooks"));
fs.writeFileSync(path.join(rootDir, "hooks", "hooks.json"), '{"hooks":[]}', "utf-8");
fs.writeFileSync(
path.join(rootDir, CLAUDE_BUNDLE_MANIFEST_RELATIVE_PATH),
JSON.stringify({
name: "Hook Plugin",
description: "Claude hooks fixture",
}),
"utf-8",
);
} else if (setupKind === "custom-hooks") {
mkdirSafe(path.join(rootDir, "custom-hooks"));
fs.writeFileSync(
path.join(rootDir, CLAUDE_BUNDLE_MANIFEST_RELATIVE_PATH),
JSON.stringify({
name: "Custom Hook Plugin",
hooks: "custom-hooks",
}),
"utf-8",
);
} else {
mkdirSafe(path.join(rootDir, "skills"));
fs.writeFileSync(
path.join(rootDir, CLAUDE_BUNDLE_MANIFEST_RELATIVE_PATH),
JSON.stringify({ name: "No Hooks" }),
"utf-8",
);
}
setupClaudeHookFixture(rootDir, setupKind);
const manifest = expectLoadedManifest(rootDir, "claude");
expect(manifest.hooks).toEqual(expectedHooks);
expect(manifest.capabilities.includes("hooks")).toBe(hasHooksCapability);

View File

@@ -28,6 +28,10 @@ async function expectResolvedPathEqual(actual: unknown, expected: string): Promi
);
}
function expectNoDiagnostics(diagnostics: unknown[]) {
expect(diagnostics).toEqual([]);
}
const tempHarness = createBundleMcpTempHarness();
afterEach(async () => {
@@ -98,7 +102,7 @@ describe("loadEnabledBundleMcpConfig", () => {
const loadedServerPath = typeof loadedArgs?.[0] === "string" ? loadedArgs[0] : undefined;
const resolvedPluginRoot = await fs.realpath(pluginRoot);
expect(loaded.diagnostics).toEqual([]);
expectNoDiagnostics(loaded.diagnostics);
expect(isRecord(loadedServer) ? loadedServer.command : undefined).toBe("node");
expect(loadedArgs).toHaveLength(1);
expect(loadedServerPath).toBeDefined();
@@ -191,7 +195,7 @@ describe("loadEnabledBundleMcpConfig", () => {
const loadedEnv =
isRecord(loadedServer) && isRecord(loadedServer.env) ? loadedServer.env : {};
expect(loaded.diagnostics).toEqual([]);
expectNoDiagnostics(loaded.diagnostics);
await expectResolvedPathEqual(loadedCwd, pluginRoot);
expect(typeof loadedCommand).toBe("string");
expect(loadedArgs).toHaveLength(2);

View File

@@ -35,6 +35,21 @@ const { ClawHubRequestError } = await import("../infra/clawhub.js");
const { CLAWHUB_INSTALL_ERROR_CODE, formatClawHubSpecifier, installPluginFromClawHub } =
await import("./clawhub.js");
async function expectClawHubInstallError(params: {
setup?: () => void;
spec: string;
expected: {
ok: false;
code: (typeof CLAWHUB_INSTALL_ERROR_CODE)[keyof typeof CLAWHUB_INSTALL_ERROR_CODE];
error: string;
};
}) {
params.setup?.();
await expect(installPluginFromClawHub({ spec: params.spec })).resolves.toMatchObject(
params.expected,
);
}
describe("installPluginFromClawHub", () => {
beforeEach(() => {
parseClawHubPluginSpecMock.mockReset();
@@ -208,7 +223,6 @@ describe("installPluginFromClawHub", () => {
},
},
] as const)("$name", async ({ setup, spec, expected }) => {
setup();
await expect(installPluginFromClawHub({ spec })).resolves.toMatchObject(expected);
await expectClawHubInstallError({ setup, spec, expected });
});
});

View File

@@ -68,6 +68,10 @@ function bundledSkillPath(repoRoot: string, pluginId: string, ...relativePath: s
return path.join(bundledPluginDir(repoRoot, pluginId), ...relativePath);
}
function expectBundledSkills(repoRoot: string, pluginId: string, skills: string[]) {
expect(readBundledManifest(repoRoot, pluginId).skills).toEqual(skills);
}
function createTlonSkillPlugin(repoRoot: string, skillPath = "node_modules/@tloncorp/tlon-skill") {
return createPlugin(repoRoot, {
id: "tlon",
@@ -117,8 +121,7 @@ describe("copyBundledPluginMetadata", () => {
"utf8",
),
).toContain("ACP Router");
const bundledManifest = readBundledManifest(repoRoot, "acpx");
expect(bundledManifest.skills).toEqual(["./skills"]);
expectBundledSkills(repoRoot, "acpx", ["./skills"]);
const packageJson = readBundledPackageJson(repoRoot, "acpx");
expect(packageJson.openclaw?.extensions).toEqual(["./index.js"]);
});
@@ -172,8 +175,7 @@ describe("copyBundledPluginMetadata", () => {
expect(fs.existsSync(path.join(bundledPluginDir(repoRoot, "tlon"), "node_modules"))).toBe(
false,
);
const bundledManifest = readBundledManifest(repoRoot, "tlon");
expect(bundledManifest.skills).toEqual(["./bundled-skills/@tloncorp/tlon-skill"]);
expectBundledSkills(repoRoot, "tlon", ["./bundled-skills/@tloncorp/tlon-skill"]);
});
it("falls back to repo-root hoisted node_modules skill paths", () => {
@@ -192,8 +194,7 @@ describe("copyBundledPluginMetadata", () => {
"utf8",
),
).toContain("Hoisted Tlon Skill");
const bundledManifest = readBundledManifest(repoRoot, "tlon");
expect(bundledManifest.skills).toEqual(["./bundled-skills/@tloncorp/tlon-skill"]);
expectBundledSkills(repoRoot, "tlon", ["./bundled-skills/@tloncorp/tlon-skill"]);
});
it("omits missing declared skill paths and removes stale generated outputs", () => {
@@ -212,8 +213,7 @@ describe("copyBundledPluginMetadata", () => {
copyBundledPluginMetadata({ repoRoot });
const bundledManifest = readBundledManifest(repoRoot, "tlon");
expect(bundledManifest.skills).toEqual([]);
expectBundledSkills(repoRoot, "tlon", []);
expect(fs.existsSync(path.join(repoRoot, "dist", "extensions", "tlon", "bundled-skills"))).toBe(
false,
);

View File

@@ -115,6 +115,23 @@ function createPackagePlugin(params: {
}
}
function createPackagePluginWithEntry(params: {
packageDir: string;
packageName: string;
pluginId?: string;
entryPath?: string;
}) {
const entryPath = params.entryPath ?? "src/index.ts";
mkdirSafe(path.dirname(path.join(params.packageDir, entryPath)));
createPackagePlugin({
packageDir: params.packageDir,
packageName: params.packageName,
extensions: [`./${entryPath}`],
...(params.pluginId ? { pluginId: params.pluginId } : {}),
});
writePluginEntry(path.join(params.packageDir, entryPath));
}
function createBundleRoot(bundleDir: string, markerPath: string, manifest?: unknown) {
mkdirSafe(path.dirname(path.join(bundleDir, markerPath)));
if (manifest) {
@@ -296,13 +313,11 @@ describe("discoverOpenClawPlugins", () => {
name: "derives unscoped ids for scoped packages",
setup: (stateDir: string) => {
const packageDir = path.join(stateDir, "extensions", "voice-call-pack");
mkdirSafe(path.join(packageDir, "src"));
createPackagePlugin({
createPackagePluginWithEntry({
packageDir,
packageName: "@openclaw/voice-call",
extensions: ["./src/index.ts"],
entryPath: "src/index.ts",
});
writePluginEntry(path.join(packageDir, "src", "index.ts"));
return {};
},
includes: ["voice-call"],
@@ -311,14 +326,12 @@ describe("discoverOpenClawPlugins", () => {
name: "strips provider suffixes from package-derived ids",
setup: (stateDir: string) => {
const packageDir = path.join(stateDir, "extensions", "ollama-provider-pack");
mkdirSafe(path.join(packageDir, "src"));
createPackagePlugin({
createPackagePluginWithEntry({
packageDir,
packageName: "@openclaw/ollama-provider",
extensions: ["./src/index.ts"],
pluginId: "ollama",
entryPath: "src/index.ts",
});
writePluginEntry(path.join(packageDir, "src", "index.ts"));
return {};
},
includes: ["ollama"],
@@ -332,14 +345,12 @@ describe("discoverOpenClawPlugins", () => {
["microsoft-speech-pack", "@openclaw/microsoft-speech", "microsoft"],
] as const) {
const packageDir = path.join(stateDir, "extensions", dirName);
mkdirSafe(path.join(packageDir, "src"));
createPackagePlugin({
createPackagePluginWithEntry({
packageDir,
packageName,
extensions: ["./src/index.ts"],
pluginId,
entryPath: "src/index.ts",
});
writePluginEntry(path.join(packageDir, "src", "index.ts"));
}
return {};
},
@@ -350,12 +361,11 @@ describe("discoverOpenClawPlugins", () => {
name: "treats configured directory paths as plugin packages",
setup: (stateDir: string) => {
const packageDir = path.join(stateDir, "packs", "demo-plugin-dir");
createPackagePlugin({
createPackagePluginWithEntry({
packageDir,
packageName: "@openclaw/demo-plugin-dir",
extensions: ["./index.js"],
entryPath: "index.js",
});
fs.writeFileSync(path.join(packageDir, "index.js"), "module.exports = {}", "utf-8");
return { extraPaths: [packageDir] };
},
includes: ["demo-plugin-dir"],

View File

@@ -44,6 +44,19 @@ function mockRemoteMarketplaceClone(manifest: unknown) {
});
}
async function expectRemoteMarketplaceError(params: { manifest: unknown; expectedError: string }) {
mockRemoteMarketplaceClone(params.manifest);
const { listMarketplacePlugins } = await import("./marketplace.js");
const result = await listMarketplacePlugins({ marketplace: "owner/repo" });
expect(result).toEqual({
ok: false,
error: params.expectedError,
});
expect(runCommandWithTimeoutMock).toHaveBeenCalledTimes(1);
}
describe("marketplace plugins", () => {
afterEach(() => {
installPluginFromPathMock.mockReset();
@@ -296,15 +309,6 @@ describe("marketplace plugins", () => {
"remote marketplaces may not use HTTP(S) plugin paths",
},
] as const)("$name", async ({ manifest, expectedError }) => {
mockRemoteMarketplaceClone(manifest);
const { listMarketplacePlugins } = await import("./marketplace.js");
const result = await listMarketplacePlugins({ marketplace: "owner/repo" });
expect(result).toEqual({
ok: false,
error: expectedError,
});
expect(runCommandWithTimeoutMock).toHaveBeenCalledTimes(1);
await expectRemoteMarketplaceError({ manifest, expectedError });
});
});