| |
| |
| |
| |
| |
|
|
| import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; |
| import { HookSystem } from './hookSystem.js'; |
| import { Config } from '../config/config.js'; |
| import { HookType } from './types.js'; |
| import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'; |
| import * as fs from 'node:fs'; |
| import * as os from 'node:os'; |
| import * as path from 'node:path'; |
| import type { Readable, Writable } from 'node:stream'; |
|
|
| |
| type MockChildProcessWithoutNullStreams = ChildProcessWithoutNullStreams & { |
| mockStdoutOn: ReturnType<typeof vi.fn>; |
| mockStderrOn: ReturnType<typeof vi.fn>; |
| mockProcessOn: ReturnType<typeof vi.fn>; |
| }; |
|
|
| |
| vi.mock('node:child_process', async (importOriginal) => { |
| const actual = await importOriginal(); |
| return { |
| ...(actual as object), |
| spawn: vi.fn(), |
| }; |
| }); |
|
|
| |
| const mockDebugLogger = vi.hoisted(() => ({ |
| debug: vi.fn(), |
| log: vi.fn(), |
| warn: vi.fn(), |
| error: vi.fn(), |
| })); |
|
|
| vi.mock('../utils/debugLogger.js', () => ({ |
| debugLogger: mockDebugLogger, |
| })); |
|
|
| |
| const mockConsole = { |
| log: vi.fn(), |
| warn: vi.fn(), |
| error: vi.fn(), |
| debug: vi.fn(), |
| }; |
|
|
| vi.stubGlobal('console', mockConsole); |
|
|
| describe('HookSystem Integration', () => { |
| let hookSystem: HookSystem; |
| let config: Config; |
| let mockSpawn: MockChildProcessWithoutNullStreams; |
|
|
| beforeEach(() => { |
| vi.resetAllMocks(); |
|
|
| const testDir = path.join(os.tmpdir(), 'test-hooks'); |
| fs.mkdirSync(testDir, { recursive: true }); |
|
|
| |
| config = new Config({ |
| model: 'gemini-1.5-flash', |
| targetDir: testDir, |
| sessionId: 'test-session', |
| debugMode: false, |
| cwd: testDir, |
| hooks: { |
| BeforeTool: [ |
| { |
| matcher: 'TestTool', |
| hooks: [ |
| { |
| type: HookType.Command as const, |
| command: 'echo', |
| timeout: 5000, |
| }, |
| ], |
| }, |
| ], |
| }, |
| }); |
|
|
| |
| (config as unknown as { getMessageBus: () => unknown }).getMessageBus = |
| () => undefined; |
|
|
| hookSystem = new HookSystem(config); |
|
|
| |
| const mockStdoutOn = vi.fn(); |
| const mockStderrOn = vi.fn(); |
| const mockProcessOn = vi.fn(); |
|
|
| mockSpawn = { |
| stdin: { |
| write: vi.fn(), |
| end: vi.fn(), |
| on: vi.fn(), |
| } as unknown as Writable, |
| stdout: { |
| on: mockStdoutOn, |
| } as unknown as Readable, |
| stderr: { |
| on: mockStderrOn, |
| } as unknown as Readable, |
| on: mockProcessOn, |
| kill: vi.fn(), |
| killed: false, |
| mockStdoutOn, |
| mockStderrOn, |
| mockProcessOn, |
| } as unknown as MockChildProcessWithoutNullStreams; |
|
|
| vi.mocked(spawn).mockReturnValue(mockSpawn); |
| }); |
|
|
| afterEach(async () => { |
| |
| }); |
|
|
| describe('initialize', () => { |
| it('should initialize successfully', async () => { |
| await hookSystem.initialize(); |
|
|
| expect(mockDebugLogger.debug).toHaveBeenCalledWith( |
| 'Hook system initialized successfully', |
| ); |
|
|
| expect(hookSystem.getAllHooks().length).toBe(1); |
| }); |
|
|
| it('should not initialize twice', async () => { |
| await hookSystem.initialize(); |
| await hookSystem.initialize(); |
|
|
| |
| expect(mockDebugLogger.debug).toHaveBeenCalledWith( |
| 'Hook system initialized successfully', |
| ); |
| }); |
|
|
| it('should handle initialization errors gracefully', async () => { |
| const invalidDir = path.join(os.tmpdir(), 'test-hooks-invalid'); |
| fs.mkdirSync(invalidDir, { recursive: true }); |
|
|
| |
| const invalidConfig = new Config({ |
| model: 'gemini-1.5-flash', |
| targetDir: invalidDir, |
| sessionId: 'test-session-invalid', |
| debugMode: false, |
| cwd: invalidDir, |
| hooks: { |
| BeforeTool: [ |
| { |
| hooks: [ |
| { |
| type: 'invalid-type' as HookType, |
| command: './test.sh', |
| |
| } as any, |
| ], |
| }, |
| ], |
| }, |
| }); |
|
|
| const invalidHookSystem = new HookSystem(invalidConfig); |
|
|
| |
| await invalidHookSystem.initialize(); |
|
|
| expect(mockDebugLogger.warn).toHaveBeenCalled(); |
| }); |
| }); |
|
|
| describe('getEventHandler', () => { |
| it('should return event bus when initialized', async () => { |
| await hookSystem.initialize(); |
|
|
| |
| mockSpawn.mockStdoutOn.mockImplementation( |
| (event: string, callback: (data: Buffer) => void) => { |
| if (event === 'data') { |
| setTimeout(() => callback(Buffer.from('')), 5); |
| } |
| }, |
| ); |
|
|
| mockSpawn.mockProcessOn.mockImplementation( |
| (event: string, callback: (code: number) => void) => { |
| if (event === 'close') { |
| setTimeout(() => callback(0), 10); |
| } |
| }, |
| ); |
|
|
| const eventBus = hookSystem.getEventHandler(); |
| expect(eventBus).toBeDefined(); |
|
|
| |
| const result = await eventBus.fireBeforeToolEvent('TestTool', { |
| test: 'data', |
| }); |
| expect(result.success).toBe(true); |
| }); |
| }); |
|
|
| describe('hook execution', () => { |
| it('should execute hooks and return results', async () => { |
| await hookSystem.initialize(); |
|
|
| |
| mockSpawn.mockStdoutOn.mockImplementation( |
| (event: string, callback: (data: Buffer) => void) => { |
| if (event === 'data') { |
| setTimeout(() => callback(Buffer.from('')), 5); |
| } |
| }, |
| ); |
|
|
| mockSpawn.mockProcessOn.mockImplementation( |
| (event: string, callback: (code: number) => void) => { |
| if (event === 'close') { |
| setTimeout(() => callback(0), 10); |
| } |
| }, |
| ); |
|
|
| const eventBus = hookSystem.getEventHandler(); |
|
|
| |
| const result = await eventBus.fireBeforeToolEvent('TestTool', { |
| test: 'data', |
| }); |
|
|
| expect(result.success).toBe(true); |
| |
| expect(result.errors).toHaveLength(0); |
| }); |
|
|
| it('should handle no matching hooks', async () => { |
| await hookSystem.initialize(); |
|
|
| const eventBus = hookSystem.getEventHandler(); |
|
|
| |
| const result = await eventBus.fireBeforeToolEvent('UnmatchedTool', { |
| test: 'data', |
| }); |
|
|
| expect(result.success).toBe(true); |
| expect(result.allOutputs).toHaveLength(0); |
| expect(result.finalOutput).toBeUndefined(); |
| }); |
| }); |
|
|
| describe('hook disabling via settings', () => { |
| it('should not execute disabled hooks from settings', async () => { |
| const disabledDir = path.join(os.tmpdir(), 'test-hooks-disabled'); |
| fs.mkdirSync(disabledDir, { recursive: true }); |
|
|
| |
| const configWithDisabled = new Config({ |
| model: 'gemini-1.5-flash', |
| targetDir: disabledDir, |
| sessionId: 'test-session-disabled', |
| debugMode: false, |
| cwd: disabledDir, |
| hooks: { |
| BeforeTool: [ |
| { |
| matcher: 'TestTool', |
| hooks: [ |
| { |
| type: HookType.Command as const, |
| command: 'echo "enabled-hook"', |
| timeout: 5000, |
| }, |
| { |
| type: HookType.Command as const, |
| command: 'echo "disabled-hook"', |
| timeout: 5000, |
| }, |
| ], |
| }, |
| ], |
| }, |
| disabledHooks: ['echo "disabled-hook"'], |
| }); |
|
|
| ( |
| configWithDisabled as unknown as { getMessageBus: () => unknown } |
| ).getMessageBus = () => undefined; |
|
|
| const systemWithDisabled = new HookSystem(configWithDisabled); |
| await systemWithDisabled.initialize(); |
|
|
| |
| let executionCount = 0; |
| mockSpawn.mockStdoutOn.mockImplementation( |
| (event: string, callback: (data: Buffer) => void) => { |
| if (event === 'data') { |
| executionCount++; |
| setTimeout(() => callback(Buffer.from('output')), 5); |
| } |
| }, |
| ); |
|
|
| mockSpawn.mockProcessOn.mockImplementation( |
| (event: string, callback: (code: number) => void) => { |
| if (event === 'close') { |
| setTimeout(() => callback(0), 10); |
| } |
| }, |
| ); |
|
|
| const eventBus = systemWithDisabled.getEventHandler(); |
| const result = await eventBus.fireBeforeToolEvent('TestTool', { |
| test: 'data', |
| }); |
|
|
| expect(result.success).toBe(true); |
| |
| expect(executionCount).toBe(1); |
| }); |
| }); |
|
|
| describe('hook disabling via command', () => { |
| it('should disable hook when setHookEnabled is called', async () => { |
| const setEnabledDir = path.join(os.tmpdir(), 'test-hooks-setEnabled'); |
| fs.mkdirSync(setEnabledDir, { recursive: true }); |
|
|
| |
| const configForDisabling = new Config({ |
| model: 'gemini-1.5-flash', |
| targetDir: setEnabledDir, |
| sessionId: 'test-session-setEnabled', |
| debugMode: false, |
| cwd: setEnabledDir, |
| hooks: { |
| BeforeTool: [ |
| { |
| matcher: 'TestTool', |
| hooks: [ |
| { |
| type: HookType.Command as const, |
| command: 'echo "will-be-disabled"', |
| timeout: 5000, |
| }, |
| ], |
| }, |
| ], |
| }, |
| }); |
|
|
| ( |
| configForDisabling as unknown as { getMessageBus: () => unknown } |
| ).getMessageBus = () => undefined; |
|
|
| const systemForDisabling = new HookSystem(configForDisabling); |
| await systemForDisabling.initialize(); |
|
|
| |
| let executionCount = 0; |
| mockSpawn.mockStdoutOn.mockImplementation( |
| (event: string, callback: (data: Buffer) => void) => { |
| if (event === 'data') { |
| executionCount++; |
| setTimeout(() => callback(Buffer.from('output')), 5); |
| } |
| }, |
| ); |
|
|
| mockSpawn.mockProcessOn.mockImplementation( |
| (event: string, callback: (code: number) => void) => { |
| if (event === 'close') { |
| setTimeout(() => callback(0), 10); |
| } |
| }, |
| ); |
|
|
| const eventBus = systemForDisabling.getEventHandler(); |
| const result1 = await eventBus.fireBeforeToolEvent('TestTool', { |
| test: 'data', |
| }); |
|
|
| expect(result1.success).toBe(true); |
| expect(executionCount).toBe(1); |
|
|
| |
| systemForDisabling.setHookEnabled('echo "will-be-disabled"', false); |
|
|
| |
| executionCount = 0; |
|
|
| |
| const result2 = await eventBus.fireBeforeToolEvent('TestTool', { |
| test: 'data', |
| }); |
|
|
| expect(result2.success).toBe(true); |
| |
| expect(executionCount).toBe(0); |
|
|
| |
| systemForDisabling.setHookEnabled('echo "will-be-disabled"', true); |
|
|
| |
| executionCount = 0; |
|
|
| |
| const result3 = await eventBus.fireBeforeToolEvent('TestTool', { |
| test: 'data', |
| }); |
|
|
| expect(result3.success).toBe(true); |
| expect(executionCount).toBe(1); |
| }); |
| }); |
| }); |
|
|