Files
2025-11-30 09:02:16 +08:00

179 lines
5.9 KiB
TypeScript

// plugin/hooks/hooks-app/__tests__/gate-loader.test.ts
import { executeShellCommand, executeGate, loadPluginGate } from '../src/gate-loader';
import { GateConfig, HookInput } from '../src/types';
import * as os from 'os';
import * as fs from 'fs/promises';
import * as path from 'path';
describe('Gate Loader - Shell Commands', () => {
test('executes shell command and returns exit code', async () => {
const result = await executeShellCommand('echo "test"', process.cwd());
expect(result.exitCode).toBe(0);
expect(result.output).toContain('test');
});
test('captures non-zero exit code', async () => {
const result = await executeShellCommand('exit 1', process.cwd());
expect(result.exitCode).toBe(1);
});
test('captures stdout', async () => {
const result = await executeShellCommand('echo "hello world"', process.cwd());
expect(result.output).toContain('hello world');
});
test('captures stderr', async () => {
const result = await executeShellCommand('echo "error" >&2', process.cwd());
expect(result.output).toContain('error');
});
test('executes in specified directory', async () => {
const tmpDir = os.tmpdir();
const result = await executeShellCommand('pwd', tmpDir);
// macOS may prepend /private to paths
expect(result.output.trim()).toMatch(new RegExp(tmpDir.replace('/var/', '(/private)?/var/')));
});
test('timeout returns exit code 124 and timeout message', async () => {
const result = await executeShellCommand('sleep 1', process.cwd(), 100);
expect(result.exitCode).toBe(124);
expect(result.output).toContain('timed out');
});
});
describe('Gate Loader - executeGate', () => {
const mockInput: HookInput = {
hook_event_name: 'PostToolUse',
cwd: process.cwd()
};
test('shell command gate with exit 0 returns passed=true', async () => {
const gateConfig: GateConfig = {
command: 'echo "success"'
};
const result = await executeGate('test-gate', gateConfig, mockInput);
expect(result.passed).toBe(true);
expect(result.result.additionalContext).toContain('success');
});
test('shell command gate with exit 1 returns passed=false', async () => {
const gateConfig: GateConfig = {
command: 'exit 1'
};
const result = await executeGate('test-gate', gateConfig, mockInput);
expect(result.passed).toBe(false);
});
test('built-in gate throws error when gate not found', async () => {
const gateConfig: GateConfig = {
// No command = built-in gate
};
await expect(executeGate('nonexistent-gate', gateConfig, mockInput)).rejects.toThrow(
'Failed to load built-in gate nonexistent-gate'
);
});
});
describe('Plugin Gate Loading', () => {
let mockPluginDir: string;
let originalEnv: string | undefined;
beforeEach(async () => {
// Create mock plugin directory structure
mockPluginDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mock-plugins-'));
const cipherpowersDir = path.join(mockPluginDir, 'cipherpowers', 'hooks');
await fs.mkdir(cipherpowersDir, { recursive: true });
// Create mock gates.json for cipherpowers
const gatesConfig = {
hooks: {},
gates: {
'plan-compliance': {
command: 'node dist/gates/plan-compliance.js',
on_fail: 'BLOCK'
}
}
};
await fs.writeFile(
path.join(cipherpowersDir, 'gates.json'),
JSON.stringify(gatesConfig)
);
// Set CLAUDE_PLUGIN_ROOT to point to turboshovel sibling
originalEnv = process.env.CLAUDE_PLUGIN_ROOT;
process.env.CLAUDE_PLUGIN_ROOT = path.join(mockPluginDir, 'turboshovel');
});
afterEach(async () => {
process.env.CLAUDE_PLUGIN_ROOT = originalEnv;
await fs.rm(mockPluginDir, { recursive: true, force: true });
});
test('loads gate config from plugin', async () => {
const result = await loadPluginGate('cipherpowers', 'plan-compliance');
expect(result.gateConfig.command).toBe('node dist/gates/plan-compliance.js');
expect(result.gateConfig.on_fail).toBe('BLOCK');
expect(result.pluginRoot).toBe(path.join(mockPluginDir, 'cipherpowers'));
});
test('throws when plugin gates.json not found', async () => {
await expect(loadPluginGate('nonexistent', 'some-gate')).rejects.toThrow(
"Cannot find gates.json for plugin 'nonexistent'"
);
});
test('throws when gate not found in plugin', async () => {
await expect(loadPluginGate('cipherpowers', 'nonexistent-gate')).rejects.toThrow(
"Gate 'nonexistent-gate' not found in plugin 'cipherpowers'"
);
});
test('validates loaded plugin config structure', async () => {
// Create plugin with malformed gates.json
const malformedDir = path.join(mockPluginDir, 'malformed', 'hooks');
await fs.mkdir(malformedDir, { recursive: true });
await fs.writeFile(
path.join(malformedDir, 'gates.json'),
JSON.stringify({
hooks: {},
gates: {
'bad-gate': {
// Missing required fields (no command, plugin, or gate)
}
}
})
);
// This should succeed loading but the gate config is invalid
// Validation happens when the gate is used, not when loading
const result = await loadPluginGate('malformed', 'bad-gate');
expect(result.gateConfig).toBeDefined();
});
test('executeGate handles plugin gate reference', async () => {
const gateConfig: GateConfig = {
plugin: 'cipherpowers',
gate: 'plan-compliance'
};
const mockInput: HookInput = {
hook_event_name: 'SubagentStop',
cwd: '/some/project'
};
// The command from cipherpowers will be executed in cipherpowers plugin dir
// For this test, the mock plugin has 'node dist/gates/plan-compliance.js'
// which won't exist, so it will fail - but we can verify the flow
const result = await executeGate('my-gate', gateConfig, mockInput);
// Command execution will fail (file doesn't exist) but flow is correct
expect(result.passed).toBe(false);
});
});