/**
* E2E Plugin Validation Tests
*
* Validates that Claude Code plugin configuration is valid
* and won't break due to downstream changes.
*
* Note: Gemini CLI extension tests have been moved to the gemini-prompts repo.
*/
import { describe, expect, it } from '@jest/globals';
import { promises as fs } from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import { z } from 'zod';
// ESM equivalent of __dirname
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// Project root is two levels up from server/tests/e2e
const PROJECT_ROOT = path.resolve(__dirname, '../../..');
// =============================================================================
// Validation Schemas
// =============================================================================
// Current plugin.json schema (without mcpServers - uses .mcp.json separately)
const claudePluginSchema = z.object({
name: z.string().min(1),
version: z.string().regex(/^\d+\.\d+\.\d+$/, 'Must be semver format'),
description: z.string(),
author: z.object({ name: z.string() }),
repository: z.string().optional(),
homepage: z.string().url().optional(),
license: z.string().optional(),
keywords: z.array(z.string()).optional(),
});
// .mcp.json schema - MCP server configuration (with mcpServers wrapper)
const claudeMcpConfigSchema = z.object({
mcpServers: z.record(
z.object({
command: z.string(),
args: z.array(z.string()).optional(),
env: z.record(z.string()).optional(),
})
),
});
const claudeHooksSchema = z.object({
hooks: z.record(
z.array(
z.object({
matcher: z.string().optional(),
hooks: z.array(
z.object({
type: z.string(),
command: z.string(),
})
),
})
)
),
});
// =============================================================================
// Helper Functions
// =============================================================================
async function fileExists(filePath: string): Promise<boolean> {
try {
await fs.access(filePath);
return true;
} catch {
return false;
}
}
async function isExecutable(filePath: string): Promise<boolean> {
try {
const stats = await fs.stat(filePath);
// Check if file has execute permission (owner, group, or other)
return (stats.mode & 0o111) !== 0;
} catch {
return false;
}
}
async function loadJson<T>(filePath: string): Promise<T> {
const content = await fs.readFile(filePath, 'utf-8');
return JSON.parse(content) as T;
}
function extractScriptPaths(command: string): string[] {
// Extract paths from commands like "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/dev-sync.py"
const matches = command.match(/\$\{[^}]+\}\/[^\s"']+/g) || [];
return matches.map((m) => m.replace(/\$\{CLAUDE_PLUGIN_ROOT\}/g, PROJECT_ROOT));
}
// =============================================================================
// Claude Code Plugin Tests
// =============================================================================
describe('Claude Code Plugin', () => {
const pluginJsonPath = path.join(PROJECT_ROOT, '.claude-plugin', 'plugin.json');
const mcpJsonPath = path.join(PROJECT_ROOT, '.mcp.json'); // Note: dot prefix
const hooksJsonPath = path.join(PROJECT_ROOT, 'hooks', 'hooks.json');
describe('plugin.json', () => {
it('exists and is valid JSON', async () => {
expect(await fileExists(pluginJsonPath)).toBe(true);
const content = await loadJson(pluginJsonPath);
expect(content).toBeDefined();
});
it('has required fields with correct types', async () => {
const plugin = await loadJson(pluginJsonPath);
const result = claudePluginSchema.safeParse(plugin);
if (!result.success) {
console.error('Validation errors:', result.error.format());
}
expect(result.success).toBe(true);
});
it('version matches server package.json', async () => {
const plugin = await loadJson<{ version: string }>(pluginJsonPath);
const serverPkg = await loadJson<{ version: string }>(
path.join(PROJECT_ROOT, 'server', 'package.json')
);
expect(plugin.version).toBe(serverPkg.version);
});
});
describe('.mcp.json', () => {
it('exists and is valid JSON', async () => {
expect(await fileExists(mcpJsonPath)).toBe(true);
const content = await loadJson(mcpJsonPath);
expect(content).toBeDefined();
});
it('has valid MCP server configuration', async () => {
const mcpConfig = await loadJson(mcpJsonPath);
const result = claudeMcpConfigSchema.safeParse(mcpConfig);
if (!result.success) {
console.error('Validation errors:', result.error.format());
}
expect(result.success).toBe(true);
});
it('uses ${CLAUDE_PLUGIN_ROOT} variable syntax', async () => {
const content = await fs.readFile(mcpJsonPath, 'utf-8');
if (content.includes('${')) {
expect(content).toContain('${CLAUDE_PLUGIN_ROOT}');
expect(content).not.toContain('${extensionPath}');
}
});
it('references server entry point that exists', async () => {
const mcpConfig = await loadJson<{ mcpServers: Record<string, { args?: string[] }> }>(
mcpJsonPath
);
for (const config of Object.values(mcpConfig.mcpServers)) {
if (config.args) {
for (const arg of config.args) {
if (arg.includes('index.js') || arg.includes('dist')) {
const resolvedPath = arg.replace(/\$\{CLAUDE_PLUGIN_ROOT\}/g, PROJECT_ROOT);
expect(await fileExists(resolvedPath)).toBe(true);
}
}
}
}
});
});
describe('hooks', () => {
it('hooks.json exists and is valid', async () => {
expect(await fileExists(hooksJsonPath)).toBe(true);
const hooks = await loadJson(hooksJsonPath);
const result = claudeHooksSchema.safeParse(hooks);
if (!result.success) {
console.error('Validation errors:', result.error.format());
}
expect(result.success).toBe(true);
});
it('all hook scripts exist', async () => {
const hooks = await loadJson<{
hooks: Record<string, Array<{ hooks: Array<{ command: string }> }>>;
}>(hooksJsonPath);
for (const eventHooks of Object.values(hooks.hooks)) {
for (const hookGroup of eventHooks) {
for (const hook of hookGroup.hooks) {
const scriptPaths = extractScriptPaths(hook.command);
for (const scriptPath of scriptPaths) {
expect(await fileExists(scriptPath)).toBe(true);
}
}
}
}
});
it('Python hook scripts are executable', async () => {
const hooksDir = path.join(PROJECT_ROOT, 'hooks');
const files = await fs.readdir(hooksDir);
const pythonScripts = files.filter((f: string) => f.endsWith('.py'));
for (const script of pythonScripts) {
const scriptPath = path.join(hooksDir, script);
expect(await isExecutable(scriptPath)).toBe(true);
}
});
});
// Note: skills directory was removed in fe6992c5 - skills are now user-level, not plugin-bundled
});
// =============================================================================
// Server Validation
// =============================================================================
describe('Server Build', () => {
it('server dist/index.js exists', async () => {
const serverPath = path.join(PROJECT_ROOT, 'server', 'dist', 'index.js');
expect(await fileExists(serverPath)).toBe(true);
});
});