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: "accepts JSON5 Codex bundle manifests" ,
bundleFormat: "codex" as const ,
manifestRelativePath: CODEX_BUNDLE_MANIFEST_RELATIVE_PATH,
json5Manifest: `{
// Bundle name can include comments and trailing commas.
name: "Codex JSON5 Bundle" ,
skills: "skills" ,
hooks: "hooks" ,
}`,
dirs: ["skills" , "hooks" ],
expected: {
id: "codex-json5-bundle" ,
name: "Codex JSON5 Bundle" ,
bundleFormat: "codex" ,
skills: ["skills" ],
hooks: ["hooks" ],
},
},
{
name: "accepts JSON5 Claude bundle manifests" ,
bundleFormat: "claude" as const ,
manifestRelativePath: CLAUDE_BUNDLE_MANIFEST_RELATIVE_PATH,
json5Manifest: `{
name: "Claude JSON5 Bundle" ,
commands: "commands-pack" ,
hooks: "hooks-pack" ,
outputStyles: "styles" ,
}`,
dirs: [".claude-plugin" , "commands-pack" , "hooks-pack" , "styles" ],
expected: {
id: "claude-json5-bundle" ,
name: "Claude JSON5 Bundle" ,
bundleFormat: "claude" ,
skills: ["commands-pack" , "styles" ],
hooks: ["hooks-pack" ],
},
},
{
name: "accepts JSON5 Cursor bundle manifests" ,
bundleFormat: "cursor" as const ,
manifestRelativePath: CURSOR_BUNDLE_MANIFEST_RELATIVE_PATH,
json5Manifest: `{
name: "Cursor JSON5 Bundle" ,
commands: ".cursor/commands" ,
mcpServers: "./.mcp.json" ,
}`,
dirs: [".cursor-plugin" , "skills" , ".cursor/commands" ],
textFiles: {
".mcp.json" : "{ servers: {}, }" ,
},
expected: {
id: "cursor-json5-bundle" ,
name: "Cursor JSON5 Bundle" ,
bundleFormat: "cursor" ,
skills: ["skills" , ".cursor/commands" ],
hooks: [],
},
},
] as const )(
"$name" ,
({ bundleFormat, manifestRelativePath, json5Manifest, dirs, textFiles, expected }) => {
const rootDir = makeTempDir();
setupBundleFixture({
rootDir,
dirs: [path.dirname(manifestRelativePath), ...dirs],
textFiles: {
[manifestRelativePath]: json5Manifest,
...textFiles,
},
});
expectBundleManifest({
rootDir,
bundleFormat,
expected,
});
},
);
it.each([
{
name: "rejects JSON5 Codex bundle manifests that parse to non-objects" ,
bundleFormat: "codex" as const ,
manifestRelativePath: CODEX_BUNDLE_MANIFEST_RELATIVE_PATH,
},
{
name: "rejects JSON5 Claude bundle manifests that parse to non-objects" ,
bundleFormat: "claude" as const ,
manifestRelativePath: CLAUDE_BUNDLE_MANIFEST_RELATIVE_PATH,
},
{
name: "rejects JSON5 Cursor bundle manifests that parse to non-objects" ,
bundleFormat: "cursor" as const ,
manifestRelativePath: CURSOR_BUNDLE_MANIFEST_RELATIVE_PATH,
},
] as const )("$name" , ({ bundleFormat, manifestRelativePath }) => {
const rootDir = makeTempDir();
setupBundleFixture({
rootDir,
dirs: [path.dirname(manifestRelativePath)],
textFiles: {
[manifestRelativePath]: "'still not an object'" ,
},
});
const result = loadBundleManifest({ rootDir, bundleFormat });
expect(result.ok).toBe(false );
if (!result.ok) {
expect(result.error).toContain("plugin manifest must be an object" );
}
});
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();
});
});
Messung V0.5 in Prozent C=100 H=97 G=98
¤ Dauer der Verarbeitung: 0.11 Sekunden
(vorverarbeitet am 2026-06-09)
¤
*© Formatika GbR, Deutschland