// plugin/hooks/hooks-app/__tests__/plugin-gates.integration.test.ts import { dispatch } from '../src/dispatcher'; import { HookInput } from '../src/types'; import * as fs from 'fs/promises'; import * as path from 'path'; import * as os from 'os'; describe('Plugin Gate Composition Integration', () => { let mockPluginsDir: string; let projectDir: string; let originalEnv: string | undefined; beforeEach(async () => { // Create mock plugins directory with two plugins mockPluginsDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mock-plugins-')); // Create mock cipherpowers plugin const cipherpowersHooksDir = path.join(mockPluginsDir, 'cipherpowers', 'hooks'); await fs.mkdir(cipherpowersHooksDir, { recursive: true }); await fs.writeFile( path.join(cipherpowersHooksDir, 'gates.json'), JSON.stringify({ hooks: {}, gates: { 'plan-compliance': { command: 'echo "plan-compliance check passed"', on_fail: 'BLOCK' } } }) ); // Create mock turboshovel plugin (current plugin) const turboshovelHooksDir = path.join(mockPluginsDir, 'turboshovel', 'hooks'); await fs.mkdir(turboshovelHooksDir, { recursive: true }); await fs.writeFile( path.join(turboshovelHooksDir, 'gates.json'), JSON.stringify({ hooks: {}, gates: {} }) ); // Create test project directory projectDir = await fs.mkdtemp(path.join(os.tmpdir(), 'test-project-')); const claudeDir = path.join(projectDir, '.claude'); await fs.mkdir(claudeDir); // Project config references cipherpowers gate await fs.writeFile( path.join(claudeDir, 'gates.json'), JSON.stringify({ hooks: { SubagentStop: { gates: ['plan-compliance', 'check'] } }, gates: { 'plan-compliance': { plugin: 'cipherpowers', gate: 'plan-compliance' }, 'check': { command: 'echo "project check passed"' } } }) ); // Set CLAUDE_PLUGIN_ROOT originalEnv = process.env.CLAUDE_PLUGIN_ROOT; process.env.CLAUDE_PLUGIN_ROOT = path.join(mockPluginsDir, 'turboshovel'); }); afterEach(async () => { process.env.CLAUDE_PLUGIN_ROOT = originalEnv; await fs.rm(mockPluginsDir, { recursive: true, force: true }); await fs.rm(projectDir, { recursive: true, force: true }); }); test('executes plugin gate followed by project gate', async () => { const input: HookInput = { hook_event_name: 'SubagentStop', cwd: projectDir, agent_name: 'test-agent' }; const result = await dispatch(input); // Both gates should pass (no blockReason or stopMessage) expect(result.blockReason).toBeUndefined(); expect(result.stopMessage).toBeUndefined(); // Should have output from both gates expect(result.context).toContain('plan-compliance check passed'); expect(result.context).toContain('project check passed'); }); test('plugin gate BLOCK stops execution', async () => { // Update cipherpowers gate to fail const cipherpowersHooksDir = path.join(mockPluginsDir, 'cipherpowers', 'hooks'); await fs.writeFile( path.join(cipherpowersHooksDir, 'gates.json'), JSON.stringify({ hooks: {}, gates: { 'plan-compliance': { command: 'exit 1', on_fail: 'BLOCK' } } }) ); const input: HookInput = { hook_event_name: 'SubagentStop', cwd: projectDir, agent_name: 'test-agent' }; const result = await dispatch(input); // Should be blocked (blockReason will be set) expect(result.blockReason).toBeDefined(); }); test('prevents circular gate references', async () => { // Create circular reference: pluginA -> pluginB -> pluginA const pluginADir = path.join(mockPluginsDir, 'pluginA', 'hooks'); const pluginBDir = path.join(mockPluginsDir, 'pluginB', 'hooks'); await fs.mkdir(pluginADir, { recursive: true }); await fs.mkdir(pluginBDir, { recursive: true }); // PluginA has gate that references pluginB await fs.writeFile( path.join(pluginADir, 'gates.json'), JSON.stringify({ hooks: {}, gates: { 'gateA': { plugin: 'pluginB', gate: 'gateB' } } }) ); // PluginB has gate that references pluginA (circular) await fs.writeFile( path.join(pluginBDir, 'gates.json'), JSON.stringify({ hooks: {}, gates: { 'gateB': { plugin: 'pluginA', gate: 'gateA' } } }) ); // Project config references pluginA gate const claudeDir = path.join(projectDir, '.claude'); await fs.writeFile( path.join(claudeDir, 'gates.json'), JSON.stringify({ hooks: { SubagentStop: { gates: ['test-circular'] } }, gates: { 'test-circular': { plugin: 'pluginA', gate: 'gateA' } } }) ); const input: HookInput = { hook_event_name: 'SubagentStop', cwd: projectDir, agent_name: 'test-agent' }; // Should error or handle gracefully (not infinite loop) // Implementation decision: error on circular reference await expect(dispatch(input)).rejects.toThrow(/circular|depth|recursion/i); }); test('handles plugin self-reference', async () => { // Plugin references its own gate const selfRefDir = path.join(mockPluginsDir, 'selfref', 'hooks'); await fs.mkdir(selfRefDir, { recursive: true }); await fs.writeFile( path.join(selfRefDir, 'gates.json'), JSON.stringify({ hooks: {}, gates: { 'gate1': { command: 'echo "gate1"' }, 'gate2': { plugin: 'selfref', gate: 'gate1' } } }) ); // Project references the self-referencing gate const claudeDir = path.join(projectDir, '.claude'); await fs.writeFile( path.join(claudeDir, 'gates.json'), JSON.stringify({ hooks: { SubagentStop: { gates: ['test-self'] } }, gates: { 'test-self': { plugin: 'selfref', gate: 'gate2' } } }) ); const input: HookInput = { hook_event_name: 'SubagentStop', cwd: projectDir, agent_name: 'test-agent' }; // Should work - self-reference to a different gate is valid const result = await dispatch(input); expect(result.blockReason).toBeUndefined(); expect(result.context).toContain('gate1'); }); });