openclaw/src/plugins/bundle-manifest.test.ts

202 lines
6.3 KiB
TypeScript

import fs from "node:fs";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
CLAUDE_BUNDLE_MANIFEST_RELATIVE_PATH,
CODEX_BUNDLE_MANIFEST_RELATIVE_PATH,
CURSOR_BUNDLE_MANIFEST_RELATIVE_PATH,
detectBundleManifestFormat,
loadBundleManifest,
} from "./bundle-manifest.js";
import {
cleanupTrackedTempDirs,
makeTrackedTempDir,
mkdirSafeDir,
} from "./test-helpers/fs-fixtures.js";
const tempDirs: string[] = [];
function makeTempDir() {
return makeTrackedTempDir("openclaw-bundle-manifest", tempDirs);
}
const mkdirSafe = mkdirSafeDir;
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({
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");
const result = loadBundleManifest({ rootDir, bundleFormat: "codex" });
expect(result.ok).toBe(true);
if (!result.ok) {
return;
}
expect(result.manifest).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({
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");
const result = loadBundleManifest({ rootDir, bundleFormat: "claude" });
expect(result.ok).toBe(true);
if (!result.ok) {
return;
}
expect(result.manifest).toMatchObject({
id: "claude-sample",
name: "Claude Sample",
description: "Claude fixture",
bundleFormat: "claude",
skills: ["skill-packs/starter", "commands-pack"],
settingsFiles: ["settings.json"],
hooks: [],
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({
name: "Cursor Sample",
description: "Cursor fixture",
mcpServers: "./.mcp.json",
}),
"utf-8",
);
fs.writeFileSync(path.join(rootDir, ".mcp.json"), '{"servers":{}}', "utf-8");
expect(detectBundleManifestFormat(rootDir)).toBe("cursor");
const result = loadBundleManifest({ rootDir, bundleFormat: "cursor" });
expect(result.ok).toBe(true);
if (!result.ok) {
return;
}
expect(result.manifest).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", () => {
const rootDir = makeTempDir();
mkdirSafe(path.join(rootDir, "commands"));
mkdirSafe(path.join(rootDir, "skills"));
fs.writeFileSync(path.join(rootDir, "settings.json"), '{"hideThinkingBlock":true}', "utf-8");
expect(detectBundleManifestFormat(rootDir)).toBe("claude");
const result = loadBundleManifest({ rootDir, bundleFormat: "claude" });
expect(result.ok).toBe(true);
if (!result.ok) {
return;
}
expect(result.manifest.id).toBe(path.basename(rootDir).toLowerCase());
expect(result.manifest.skills).toEqual(["skills", "commands"]);
expect(result.manifest.settingsFiles).toEqual(["settings.json"]);
expect(result.manifest.capabilities).toEqual(
expect.arrayContaining(["skills", "commands", "settings"]),
);
});
it("does not misclassify native index plugins as manifestless Claude bundles", () => {
const rootDir = makeTempDir();
mkdirSafe(path.join(rootDir, "commands"));
fs.writeFileSync(path.join(rootDir, "index.ts"), "export default {}", "utf-8");
expect(detectBundleManifestFormat(rootDir)).toBeNull();
});
});