/** * Hook 系统测试 */ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import * as path from 'path'; import * as fs from 'fs/promises'; import * as os from 'os'; import { HookManager, initHookManager, getHookManager, resetHookManager, loadHookConfig, loadProjectConfig, type Hooks, type HookConfig, } from '../../src/hooks/index.js'; describe('HookManager', () => { let tempDir: string; let manager: HookManager; beforeEach(async () => { tempDir = path.join(os.tmpdir(), `hooks-test-${Date.now()}`); await fs.mkdir(tempDir, { recursive: true }); manager = new HookManager(tempDir, 'test-session'); }); afterEach(async () => { resetHookManager(); try { await fs.rm(tempDir, { recursive: true, force: true }); } catch { // ignore cleanup errors } }); describe('Plugin Registration', () => { it('should register hooks from plugin', async () => { const hooks: Hooks = { 'tool.execute.before': async (input, output) => { output.args = { ...output.args, injected: true }; }, }; manager.registerHooks(hooks); expect(manager.getHookCount()).toBe(1); }); it('should register multiple plugins', async () => { const hooks1: Hooks = { 'tool.execute.before': async () => {}, }; const hooks2: Hooks = { 'tool.execute.after': async () => {}, }; manager.registerHooks(hooks1); manager.registerHooks(hooks2); expect(manager.getHookCount()).toBe(2); }); }); describe('Tool Execute Before Hook', () => { it('should trigger tool.execute.before hook', async () => { let triggered = false; const hooks: Hooks = { 'tool.execute.before': async (input, output) => { triggered = true; expect(input.tool).toBe('test_tool'); expect(input.sessionId).toBe('test-session'); expect(input.args).toEqual({ foo: 'bar' }); }, }; manager.registerHooks(hooks); await manager.triggerToolExecuteBefore({ tool: 'test_tool', sessionId: 'test-session', callId: 'call-1', args: { foo: 'bar' }, }); expect(triggered).toBe(true); }); it('should allow hook to modify args', async () => { const hooks: Hooks = { 'tool.execute.before': async (input, output) => { output.args = { ...output.args, modified: true }; }, }; manager.registerHooks(hooks); const result = await manager.triggerToolExecuteBefore({ tool: 'test_tool', sessionId: 'test-session', callId: 'call-1', args: { original: true }, }); expect(result.args).toEqual({ original: true, modified: true }); }); it('should allow hook to skip execution', async () => { const hooks: Hooks = { 'tool.execute.before': async (input, output) => { output.skip = true; output.skipResult = { success: false, output: '', error: 'Blocked by hook', }; }, }; manager.registerHooks(hooks); const result = await manager.triggerToolExecuteBefore({ tool: 'test_tool', sessionId: 'test-session', callId: 'call-1', args: {}, }); expect(result.skip).toBe(true); expect(result.skipResult?.error).toBe('Blocked by hook'); }); }); describe('Tool Execute After Hook', () => { it('should trigger tool.execute.after hook', async () => { let triggered = false; const hooks: Hooks = { 'tool.execute.after': async (input, output) => { triggered = true; expect(input.tool).toBe('test_tool'); expect(input.duration).toBeGreaterThanOrEqual(0); }, }; manager.registerHooks(hooks); await manager.triggerToolExecuteAfter( { tool: 'test_tool', sessionId: 'test-session', callId: 'call-1', args: {}, duration: 100, }, { success: true, output: 'test output' } ); expect(triggered).toBe(true); }); it('should allow hook to modify result', async () => { const hooks: Hooks = { 'tool.execute.after': async (input, output) => { output.result = { ...output.result, output: output.result.output + ' (modified)', }; }, }; manager.registerHooks(hooks); const result = await manager.triggerToolExecuteAfter( { tool: 'test_tool', sessionId: 'test-session', callId: 'call-1', args: {}, duration: 100, }, { success: true, output: 'original' } ); expect(result.result.output).toBe('original (modified)'); }); }); describe('Session Hooks', () => { it('should trigger session.start hook', async () => { let triggered = false; const hooks: Hooks = { 'session.start': async (input) => { triggered = true; expect(input.sessionId).toBe('session-123'); expect(input.workdir).toBe('/test/dir'); }, }; manager.registerHooks(hooks); await manager.triggerSessionStart({ sessionId: 'session-123', workdir: '/test/dir', }); expect(triggered).toBe(true); }); it('should trigger session.end hook', async () => { let triggered = false; const hooks: Hooks = { 'session.end': async (input) => { triggered = true; expect(input.messageCount).toBe(10); expect(input.duration).toBe(5000); }, }; manager.registerHooks(hooks); await manager.triggerSessionEnd({ sessionId: 'session-123', messageCount: 10, duration: 5000, }); expect(triggered).toBe(true); }); }); describe('Message Hooks', () => { it('should trigger message.before hook', async () => { const hooks: Hooks = { 'message.before': async (input, output) => { output.content = input.content.toUpperCase(); }, }; manager.registerHooks(hooks); const result = await manager.triggerMessageBefore({ sessionId: 'test-session', content: 'hello world', }); expect(result.content).toBe('HELLO WORLD'); }); it('should allow message.before to skip', async () => { const hooks: Hooks = { 'message.before': async (input, output) => { if (input.content.includes('forbidden')) { output.skip = true; } }, }; manager.registerHooks(hooks); const result = await manager.triggerMessageBefore({ sessionId: 'test-session', content: 'this is forbidden', }); expect(result.skip).toBe(true); }); it('should trigger message.after hook', async () => { let triggered = false; const hooks: Hooks = { 'message.after': async (input) => { triggered = true; expect(input.toolCalls).toBe(3); }, }; manager.registerHooks(hooks); await manager.triggerMessageAfter({ sessionId: 'test-session', content: 'response', toolCalls: 3, }); expect(triggered).toBe(true); }); }); describe('File Change Hooks', () => { it('should trigger file.edited hook', async () => { let triggered = false; const hooks: Hooks = { 'file.edited': async (input, output) => { triggered = true; expect(input.path).toBe('/test/file.ts'); expect(input.tool).toBe('edit_file'); }, }; manager.registerHooks(hooks); await manager.triggerFileEdited({ path: '/test/file.ts', tool: 'edit_file', sessionId: 'test-session', }); expect(triggered).toBe(true); }); it('should trigger file.created hook', async () => { let triggered = false; const hooks: Hooks = { 'file.created': async (input, output) => { triggered = true; expect(input.path).toBe('/test/new-file.ts'); }, }; manager.registerHooks(hooks); await manager.triggerFileCreated({ path: '/test/new-file.ts', tool: 'write_file', sessionId: 'test-session', }); expect(triggered).toBe(true); }); }); describe('Config Hooks', () => { it('should execute file hooks matching glob pattern', async () => { const config: HookConfig = { file_edited: { '*.ts': [ { command: ['echo', 'TypeScript file edited'], timeout: 5000, }, ], }, }; manager.setConfigHooks(config); const result = await manager.triggerFileEdited({ path: 'test.ts', tool: 'edit_file', sessionId: 'test-session', }); expect(result.commandResults).toBeDefined(); expect(result.commandResults?.length).toBe(1); expect(result.commandResults?.[0].success).toBe(true); expect(result.commandResults?.[0].output).toContain('TypeScript file edited'); }); it('should not execute hooks for non-matching patterns', async () => { const config: HookConfig = { file_edited: { '*.ts': [ { command: ['echo', 'TypeScript'], }, ], }, }; manager.setConfigHooks(config); const result = await manager.triggerFileEdited({ path: 'test.js', tool: 'edit_file', sessionId: 'test-session', }); expect(result.commandResults).toBeDefined(); expect(result.commandResults?.length).toBe(0); }); }); describe('Event Listeners', () => { it('should emit events to listeners', async () => { const events: any[] = []; manager.addEventListener((event) => { events.push(event); }); await manager.triggerToolExecuteBefore({ tool: 'test_tool', sessionId: 'test-session', callId: 'call-1', args: {}, }); expect(events.length).toBe(1); expect(events[0].type).toBe('tool.execute.before'); expect(events[0].timestamp).toBeGreaterThan(0); }); it('should remove event listener', async () => { const events: any[] = []; const listener = (event: any) => events.push(event); manager.addEventListener(listener); manager.removeEventListener(listener); await manager.triggerToolExecuteBefore({ tool: 'test_tool', sessionId: 'test-session', callId: 'call-1', args: {}, }); expect(events.length).toBe(0); }); }); describe('Error Handling', () => { it('should continue execution when hook throws error', async () => { const hooks: Hooks = { 'tool.execute.before': async () => { throw new Error('Hook error'); }, }; manager.registerHooks(hooks); // Should not throw const result = await manager.triggerToolExecuteBefore({ tool: 'test_tool', sessionId: 'test-session', callId: 'call-1', args: { foo: 'bar' }, }); // Args should remain unchanged expect(result.args).toEqual({ foo: 'bar' }); }); }); }); describe('Global Hook Manager', () => { afterEach(() => { resetHookManager(); }); it('should initialize global hook manager', () => { const manager = initHookManager('/test/dir', 'session-1'); expect(manager).toBeInstanceOf(HookManager); expect(getHookManager()).toBe(manager); }); it('should return null before initialization', () => { expect(getHookManager()).toBeNull(); }); it('should reset global hook manager', () => { initHookManager('/test/dir'); resetHookManager(); expect(getHookManager()).toBeNull(); }); }); describe('Config Loader', () => { let tempDir: string; beforeEach(async () => { tempDir = path.join(os.tmpdir(), `config-test-${Date.now()}`); await fs.mkdir(tempDir, { recursive: true }); }); afterEach(async () => { try { await fs.rm(tempDir, { recursive: true, force: true }); } catch { // ignore cleanup errors } }); it('should load project config from .ai-assistant.json', async () => { const config = { hooks: { file_edited: { '*.ts': [{ command: ['echo', 'test'] }], }, }, plugins: ['plugin-a', 'plugin-b'], }; await fs.writeFile( path.join(tempDir, '.ai-assistant.json'), JSON.stringify(config) ); const loaded = await loadProjectConfig(tempDir); expect(loaded).not.toBeNull(); expect(loaded?.hooks?.file_edited).toBeDefined(); expect(loaded?.plugins).toEqual(['plugin-a', 'plugin-b']); }); it('should load hook config', async () => { const config = { hooks: { file_edited: { '*.ts': [{ command: ['npm', 'run', 'lint'] }], }, session_completed: [{ command: ['echo', 'done'] }], }, }; await fs.writeFile( path.join(tempDir, '.ai-assistant.json'), JSON.stringify(config) ); const hookConfig = await loadHookConfig(tempDir); expect(hookConfig).not.toBeNull(); expect(hookConfig?.file_edited?.['*.ts']).toHaveLength(1); expect(hookConfig?.session_completed).toHaveLength(1); }); it('should return null for missing config', async () => { const config = await loadProjectConfig(tempDir); expect(config).toBeNull(); }); it('should support JSONC format with comments', async () => { const configContent = `{ // This is a comment "hooks": { /* Multi-line comment */ "file_edited": { "*.ts": [{ "command": ["echo", "test"] }] } } }`; await fs.writeFile( path.join(tempDir, '.ai-assistant.jsonc'), configContent ); const loaded = await loadProjectConfig(tempDir); expect(loaded).not.toBeNull(); expect(loaded?.hooks?.file_edited?.['*.ts']).toBeDefined(); }); });