mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-31 11:51:22 +00:00
335 lines
9.6 KiB
TypeScript
335 lines
9.6 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;
|
|
|
|
function expectLoadedManifest(rootDir: string, bundleFormat: "codex" | "claude" | "cursor") {
|
|
const result = loadBundleManifest({ rootDir, bundleFormat });
|
|
expect(result.ok).toBe(true);
|
|
if (!result.ok) {
|
|
throw new Error("expected bundle manifest to load");
|
|
}
|
|
return result.manifest;
|
|
}
|
|
|
|
function writeBundleManifest(
|
|
rootDir: string,
|
|
relativePath: string,
|
|
manifest: Record<string, unknown>,
|
|
) {
|
|
writeBundleFixtureFile(rootDir, relativePath, manifest);
|
|
}
|
|
|
|
function writeBundleFixtureFile(rootDir: string, relativePath: string, value: unknown) {
|
|
mkdirSafe(path.dirname(path.join(rootDir, relativePath)));
|
|
fs.writeFileSync(
|
|
path.join(rootDir, relativePath),
|
|
typeof value === "string" ? value : JSON.stringify(value),
|
|
"utf-8",
|
|
);
|
|
}
|
|
|
|
function writeBundleFixtureFiles(rootDir: string, files: Readonly<Record<string, unknown>>) {
|
|
Object.entries(files).forEach(([relativePath, value]) => {
|
|
writeBundleFixtureFile(rootDir, relativePath, value);
|
|
});
|
|
}
|
|
|
|
function setupBundleFixture(params: {
|
|
rootDir: string;
|
|
dirs?: readonly string[];
|
|
jsonFiles?: Readonly<Record<string, unknown>>;
|
|
textFiles?: Readonly<Record<string, string>>;
|
|
manifestRelativePath?: string;
|
|
manifest?: Record<string, unknown>;
|
|
}) {
|
|
for (const relativeDir of params.dirs ?? []) {
|
|
mkdirSafe(path.join(params.rootDir, relativeDir));
|
|
}
|
|
writeBundleFixtureFiles(params.rootDir, params.jsonFiles ?? {});
|
|
writeBundleFixtureFiles(params.rootDir, params.textFiles ?? {});
|
|
if (params.manifestRelativePath && params.manifest) {
|
|
writeBundleManifest(params.rootDir, params.manifestRelativePath, params.manifest);
|
|
}
|
|
}
|
|
|
|
function setupClaudeHookFixture(
|
|
rootDir: string,
|
|
kind: "default-hooks" | "custom-hooks" | "no-hooks",
|
|
) {
|
|
if (kind === "default-hooks") {
|
|
setupBundleFixture({
|
|
rootDir,
|
|
dirs: [".claude-plugin", "hooks"],
|
|
jsonFiles: { "hooks/hooks.json": { hooks: [] } },
|
|
manifestRelativePath: CLAUDE_BUNDLE_MANIFEST_RELATIVE_PATH,
|
|
manifest: {
|
|
name: "Hook Plugin",
|
|
description: "Claude hooks fixture",
|
|
},
|
|
});
|
|
return;
|
|
}
|
|
if (kind === "custom-hooks") {
|
|
setupBundleFixture({
|
|
rootDir,
|
|
dirs: [".claude-plugin", "custom-hooks"],
|
|
manifestRelativePath: CLAUDE_BUNDLE_MANIFEST_RELATIVE_PATH,
|
|
manifest: {
|
|
name: "Custom Hook Plugin",
|
|
hooks: "custom-hooks",
|
|
},
|
|
});
|
|
return;
|
|
}
|
|
setupBundleFixture({
|
|
rootDir,
|
|
dirs: [".claude-plugin", "skills"],
|
|
manifestRelativePath: CLAUDE_BUNDLE_MANIFEST_RELATIVE_PATH,
|
|
manifest: { name: "No Hooks" },
|
|
});
|
|
}
|
|
|
|
function expectBundleManifest(params: {
|
|
rootDir: string;
|
|
bundleFormat: "codex" | "claude" | "cursor";
|
|
expected: Record<string, unknown>;
|
|
}) {
|
|
expect(detectBundleManifestFormat(params.rootDir)).toBe(params.bundleFormat);
|
|
expect(expectLoadedManifest(params.rootDir, params.bundleFormat)).toMatchObject(params.expected);
|
|
}
|
|
|
|
function expectClaudeHookResolution(params: {
|
|
rootDir: string;
|
|
expectedHooks: readonly string[];
|
|
hasHooksCapability: boolean;
|
|
}) {
|
|
const manifest = expectLoadedManifest(params.rootDir, "claude");
|
|
expect(manifest.hooks).toEqual(params.expectedHooks);
|
|
expect(manifest.capabilities.includes("hooks")).toBe(params.hasHooksCapability);
|
|
}
|
|
|
|
afterEach(() => {
|
|
cleanupTrackedTempDirs(tempDirs);
|
|
});
|
|
|
|
describe("bundle manifest parsing", () => {
|
|
it.each([
|
|
{
|
|
name: "detects and loads Codex bundle manifests",
|
|
bundleFormat: "codex" as const,
|
|
setup: (rootDir: string) => {
|
|
setupBundleFixture({
|
|
rootDir,
|
|
dirs: [".codex-plugin", "skills", "hooks"],
|
|
manifestRelativePath: CODEX_BUNDLE_MANIFEST_RELATIVE_PATH,
|
|
manifest: {
|
|
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",
|
|
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) => {
|
|
setupBundleFixture({
|
|
rootDir,
|
|
dirs: [
|
|
".claude-plugin",
|
|
"skill-packs/starter",
|
|
"commands-pack",
|
|
"agents-pack",
|
|
"hooks-pack",
|
|
"mcp",
|
|
"lsp",
|
|
"styles",
|
|
"hooks",
|
|
],
|
|
textFiles: {
|
|
"hooks/hooks.json": '{"hooks":[]}',
|
|
"settings.json": '{"hideThinkingBlock":true}',
|
|
},
|
|
manifestRelativePath: CLAUDE_BUNDLE_MANIFEST_RELATIVE_PATH,
|
|
manifest: {
|
|
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",
|
|
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) => {
|
|
setupBundleFixture({
|
|
rootDir,
|
|
dirs: [".cursor-plugin", "skills", ".cursor/commands", ".cursor/rules", ".cursor/agents"],
|
|
textFiles: {
|
|
".cursor/hooks.json": '{"hooks":[]}',
|
|
".mcp.json": '{"servers":{}}',
|
|
},
|
|
manifestRelativePath: CURSOR_BUNDLE_MANIFEST_RELATIVE_PATH,
|
|
manifest: {
|
|
name: "Cursor Sample",
|
|
description: "Cursor fixture",
|
|
mcpServers: "./.mcp.json",
|
|
},
|
|
});
|
|
},
|
|
expected: {
|
|
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",
|
|
]),
|
|
},
|
|
},
|
|
{
|
|
name: "detects manifestless Claude bundles from the default layout",
|
|
bundleFormat: "claude" as const,
|
|
setup: (rootDir: string) => {
|
|
setupBundleFixture({
|
|
rootDir,
|
|
dirs: ["commands", "skills"],
|
|
textFiles: {
|
|
"settings.json": '{"hideThinkingBlock":true}',
|
|
},
|
|
});
|
|
},
|
|
expected: (rootDir: string) => ({
|
|
id: path.basename(rootDir).toLowerCase(),
|
|
skills: ["skills", "commands"],
|
|
settingsFiles: ["settings.json"],
|
|
capabilities: expect.arrayContaining(["skills", "commands", "settings"]),
|
|
}),
|
|
},
|
|
] as const)("$name", ({ bundleFormat, setup, expected }) => {
|
|
const rootDir = makeTempDir();
|
|
setup(rootDir);
|
|
|
|
expectBundleManifest({
|
|
rootDir,
|
|
bundleFormat,
|
|
expected: typeof expected === "function" ? expected(rootDir) : expected,
|
|
});
|
|
});
|
|
|
|
it.each([
|
|
{
|
|
name: "resolves Claude bundle hooks from default and declared paths",
|
|
setupKind: "default-hooks",
|
|
expectedHooks: ["hooks/hooks.json"],
|
|
hasHooksCapability: true,
|
|
},
|
|
{
|
|
name: "resolves Claude bundle hooks from manifest-declared paths only",
|
|
setupKind: "custom-hooks",
|
|
expectedHooks: ["custom-hooks"],
|
|
hasHooksCapability: true,
|
|
},
|
|
{
|
|
name: "returns empty hooks for Claude bundles with no hooks directory",
|
|
setupKind: "no-hooks",
|
|
expectedHooks: [],
|
|
hasHooksCapability: false,
|
|
},
|
|
] as const)("$name", ({ setupKind, expectedHooks, hasHooksCapability }) => {
|
|
const rootDir = makeTempDir();
|
|
setupClaudeHookFixture(rootDir, setupKind);
|
|
expectClaudeHookResolution({
|
|
rootDir,
|
|
expectedHooks,
|
|
hasHooksCapability,
|
|
});
|
|
});
|
|
|
|
it("does not misclassify native index plugins as manifestless Claude bundles", () => {
|
|
const rootDir = makeTempDir();
|
|
setupBundleFixture({
|
|
rootDir,
|
|
dirs: ["commands"],
|
|
textFiles: { "index.ts": "export default {}" },
|
|
});
|
|
|
|
expect(detectBundleManifestFormat(rootDir)).toBeNull();
|
|
});
|
|
});
|