Download packages/core/src/hooks/hookRunner.test.ts from SaylorTwift/gemini-cli: direct link, hf CLI and curl.
- Browser
- Download file 30.6 kB
-
https://huggingface.co/SaylorTwift/gemini-cli/resolve/main/packages/core/src/hooks/hookRunner.test.ts
- Command line
-
hf download hf://SaylorTwift/gemini-cli/packages/core/src/hooks/hookRunner.test.ts
-
curl -L -o hookRunner.test.ts https://huggingface.co/SaylorTwift/gemini-cli/resolve/main/packages/core/src/hooks/hookRunner.test.ts
30.6 kB
| /** | |
| * @license | |
| * Copyright 2025 Google LLC | |
| * SPDX-License-Identifier: Apache-2.0 | |
| */ | |
| import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; | |
| import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'; | |
| import { HookRunner } from './hookRunner.js'; | |
| import { | |
| HookEventName, | |
| HookType, | |
| ConfigSource, | |
| type HookConfig, | |
| type HookInput, | |
| } from './types.js'; | |
| import type { Readable, Writable } from 'node:stream'; | |
| import type { Config } from '../config/config.js'; | |
| // Mock type for the child_process spawn | |
| type MockChildProcessWithoutNullStreams = ChildProcessWithoutNullStreams & { | |
| mockStdoutOn: ReturnType<typeof vi.fn>; | |
| mockStderrOn: ReturnType<typeof vi.fn>; | |
| mockProcessOn: ReturnType<typeof vi.fn>; | |
| }; | |
| // Mock child_process with importOriginal for partial mocking | |
| vi.mock('node:child_process', async (importOriginal) => { | |
| const actual = await importOriginal(); | |
| return { | |
| ...(actual as object), | |
| spawn: vi.fn(), | |
| }; | |
| }); | |
| // Mock debugLogger using vi.hoisted | |
| const mockDebugLogger = vi.hoisted(() => ({ | |
| log: vi.fn(), | |
| warn: vi.fn(), | |
| error: vi.fn(), | |
| debug: vi.fn(), | |
| })); | |
| vi.mock('../utils/debugLogger.js', () => ({ | |
| debugLogger: mockDebugLogger, | |
| })); | |
| // Mock console methods | |
| const mockConsole = { | |
| log: vi.fn(), | |
| warn: vi.fn(), | |
| error: vi.fn(), | |
| debug: vi.fn(), | |
| }; | |
| vi.stubGlobal('console', mockConsole); | |
| describe('HookRunner', () => { | |
| let hookRunner: HookRunner; | |
| let mockSpawn: MockChildProcessWithoutNullStreams; | |
| let mockConfig: Config; | |
| const mockInput: HookInput = { | |
| session_id: 'test-session', | |
| transcript_path: '/path/to/transcript', | |
| cwd: '/test/project', | |
| hook_event_name: 'BeforeTool', | |
| timestamp: '2025-01-01T00:00:00.000Z', | |
| }; | |
| beforeEach(() => { | |
| vi.resetAllMocks(); | |
| mockConfig = { | |
| isTrustedFolder: vi.fn().mockReturnValue(true), | |
| sanitizationConfig: { | |
| enableEnvironmentVariableRedaction: true, | |
| }, | |
| storage: { | |
| getPlansDir: vi.fn().mockReturnValue('/test/project/plans'), | |
| }, | |
| } as unknown as Config; | |
| hookRunner = new HookRunner(mockConfig); | |
| // Mock spawn with accessible mock functions | |
| 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(() => { | |
| vi.restoreAllMocks(); | |
| }); | |
| describe('executeHook', () => { | |
| describe('security checks', () => { | |
| it('should block project hooks in untrusted folders', async () => { | |
| vi.mocked(mockConfig.isTrustedFolder).mockReturnValue(false); | |
| const projectHookConfig: HookConfig = { | |
| type: HookType.Command, | |
| command: './hooks/test.sh', | |
| source: ConfigSource.Project, | |
| }; | |
| const result = await hookRunner.executeHook( | |
| projectHookConfig, | |
| HookEventName.BeforeTool, | |
| mockInput, | |
| ); | |
| expect(result.success).toBe(false); | |
| expect(result.error?.message).toContain( | |
| 'Security: Blocked execution of project hook in untrusted folder', | |
| ); | |
| expect(mockDebugLogger.warn).toHaveBeenCalledWith( | |
| expect.stringContaining('Security: Blocked execution'), | |
| ); | |
| expect(spawn).not.toHaveBeenCalled(); | |
| }); | |
| it('should allow project hooks in trusted folders', async () => { | |
| vi.mocked(mockConfig.isTrustedFolder).mockReturnValue(true); | |
| const projectHookConfig: HookConfig = { | |
| type: HookType.Command, | |
| command: './hooks/test.sh', | |
| source: ConfigSource.Project, | |
| }; | |
| // Mock successful execution | |
| mockSpawn.mockProcessOn.mockImplementation( | |
| (event: string, callback: (code: number) => void) => { | |
| if (event === 'close') { | |
| setTimeout(() => callback(0), 10); | |
| } | |
| }, | |
| ); | |
| const result = await hookRunner.executeHook( | |
| projectHookConfig, | |
| HookEventName.BeforeTool, | |
| mockInput, | |
| ); | |
| expect(result.success).toBe(true); | |
| expect(spawn).toHaveBeenCalled(); | |
| }); | |
| it('should allow non-project hooks even in untrusted folders', async () => { | |
| vi.mocked(mockConfig.isTrustedFolder).mockReturnValue(false); | |
| const systemHookConfig: HookConfig = { | |
| type: HookType.Command, | |
| command: './hooks/test.sh', | |
| source: ConfigSource.System, | |
| }; | |
| // Mock successful execution | |
| mockSpawn.mockProcessOn.mockImplementation( | |
| (event: string, callback: (code: number) => void) => { | |
| if (event === 'close') { | |
| setTimeout(() => callback(0), 10); | |
| } | |
| }, | |
| ); | |
| const result = await hookRunner.executeHook( | |
| systemHookConfig, | |
| HookEventName.BeforeTool, | |
| mockInput, | |
| ); | |
| expect(result.success).toBe(true); | |
| expect(spawn).toHaveBeenCalled(); | |
| }); | |
| }); | |
| describe('command hooks', () => { | |
| const commandConfig: HookConfig = { | |
| type: HookType.Command, | |
| command: './hooks/test.sh', | |
| timeout: 5000, | |
| }; | |
| it('should execute command hook successfully', async () => { | |
| const mockOutput = { | |
| decision: 'allow', | |
| reason: 'All good', | |
| format: 'json', | |
| }; | |
| // Mock successful execution | |
| mockSpawn.mockStdoutOn.mockImplementation( | |
| (event: string, callback: (data: Buffer) => void) => { | |
| if (event === 'data') { | |
| setImmediate(() => | |
| callback(Buffer.from(JSON.stringify(mockOutput))), | |
| ); | |
| } | |
| }, | |
| ); | |
| mockSpawn.mockProcessOn.mockImplementation( | |
| (event: string, callback: (code: number) => void) => { | |
| if (event === 'close') { | |
| setImmediate(() => callback(0)); | |
| } | |
| }, | |
| ); | |
| const result = await hookRunner.executeHook( | |
| commandConfig, | |
| HookEventName.BeforeTool, | |
| mockInput, | |
| ); | |
| expect(result.success).toBe(true); | |
| expect(result.output).toEqual(mockOutput); | |
| expect(result.exitCode).toBe(0); | |
| expect(mockSpawn.stdin.write).toHaveBeenCalledWith( | |
| JSON.stringify(mockInput), | |
| ); | |
| }); | |
| it('should handle command hook failure', async () => { | |
| const errorMessage = 'Command failed'; | |
| mockSpawn.mockStderrOn.mockImplementation( | |
| (event: string, callback: (data: Buffer) => void) => { | |
| if (event === 'data') { | |
| setImmediate(() => callback(Buffer.from(errorMessage))); | |
| } | |
| }, | |
| ); | |
| mockSpawn.mockProcessOn.mockImplementation( | |
| (event: string, callback: (code: number) => void) => { | |
| if (event === 'close') { | |
| setImmediate(() => callback(1)); | |
| } | |
| }, | |
| ); | |
| const result = await hookRunner.executeHook( | |
| commandConfig, | |
| HookEventName.BeforeTool, | |
| mockInput, | |
| ); | |
| expect(result.success).toBe(false); | |
| expect(result.exitCode).toBe(1); | |
| expect(result.stderr).toBe(errorMessage); | |
| }); | |
| it('should use hook name in error messages if available', async () => { | |
| const namedConfig: HookConfig = { | |
| name: 'my-friendly-hook', | |
| type: HookType.Command, | |
| command: './hooks/fail.sh', | |
| }; | |
| // Mock error during spawn | |
| vi.mocked(spawn).mockImplementationOnce(() => { | |
| throw new Error('Spawn error'); | |
| }); | |
| await hookRunner.executeHook( | |
| namedConfig, | |
| HookEventName.BeforeTool, | |
| mockInput, | |
| ); | |
| expect(mockDebugLogger.warn).toHaveBeenCalledWith( | |
| expect.stringContaining( | |
| '(hook: my-friendly-hook): Error: Spawn error', | |
| ), | |
| ); | |
| }); | |
| it('should handle command hook timeout', async () => { | |
| const shortTimeoutConfig: HookConfig = { | |
| type: HookType.Command, | |
| command: './hooks/slow.sh', | |
| timeout: 50, // Very short timeout for testing | |
| }; | |
| let closeCallback: ((code: number) => void) | undefined; | |
| let killWasCalled = false; | |
| // Mock a hanging process that registers the close handler but doesn't call it initially | |
| mockSpawn.mockProcessOn.mockImplementation( | |
| (event: string, callback: (code: number) => void) => { | |
| if (event === 'close') { | |
| closeCallback = callback; // Store the callback but don't call it yet | |
| } | |
| }, | |
| ); | |
| // Mock the kill method to simulate the process being killed | |
| mockSpawn.kill = vi.fn().mockImplementation((_signal: string) => { | |
| killWasCalled = true; | |
| // Simulate that killing the process triggers the close event | |
| if (closeCallback) { | |
| setImmediate(() => { | |
| closeCallback!(128); // Exit code 128 indicates process was killed by signal | |
| }); | |
| } | |
| return true; | |
| }); | |
| const result = await hookRunner.executeHook( | |
| shortTimeoutConfig, | |
| HookEventName.BeforeTool, | |
| mockInput, | |
| ); | |
| expect(result.success).toBe(false); | |
| expect(killWasCalled).toBe(true); | |
| expect(result.error?.message).toContain('timed out'); | |
| expect(mockSpawn.kill).toHaveBeenCalledWith('SIGTERM'); | |
| }); | |
| it('should expand environment variables in commands', async () => { | |
| const configWithEnvVar: HookConfig = { | |
| type: HookType.Command, | |
| command: '$GEMINI_PROJECT_DIR/hooks/test.sh', | |
| }; | |
| mockSpawn.mockProcessOn.mockImplementation( | |
| (event: string, callback: (code: number) => void) => { | |
| if (event === 'close') { | |
| setImmediate(() => callback(0)); | |
| } | |
| }, | |
| ); | |
| await hookRunner.executeHook( | |
| configWithEnvVar, | |
| HookEventName.BeforeTool, | |
| mockInput, | |
| ); | |
| expect(spawn).toHaveBeenCalledWith( | |
| expect.stringMatching(/bash|pwsh|powershell/), | |
| expect.arrayContaining([ | |
| expect.stringMatching(/['"]?\/test\/project['"]?\/hooks\/test\.sh/), | |
| ]), | |
| expect.objectContaining({ | |
| shell: false, | |
| env: expect.objectContaining({ | |
| GEMINI_PROJECT_DIR: '/test/project', | |
| GEMINI_PLANS_DIR: '/test/project/plans', | |
| GEMINI_CWD: '/test/project', | |
| GEMINI_SESSION_ID: 'test-session', | |
| CLAUDE_PROJECT_DIR: '/test/project', | |
| }), | |
| }), | |
| ); | |
| }); | |
| it('should expand and escape GEMINI_PLANS_DIR in commands', async () => { | |
| const configWithEnvVar: HookConfig = { | |
| type: HookType.Command, | |
| command: 'ls $GEMINI_PLANS_DIR', | |
| }; | |
| // Change plans dir to one with spaces | |
| vi.mocked(mockConfig.storage.getPlansDir).mockReturnValue( | |
| '/test/project/plans with spaces', | |
| ); | |
| mockSpawn.mockProcessOn.mockImplementation( | |
| (event: string, callback: (code: number) => void) => { | |
| if (event === 'close') { | |
| setImmediate(() => callback(0)); | |
| } | |
| }, | |
| ); | |
| await hookRunner.executeHook( | |
| configWithEnvVar, | |
| HookEventName.BeforeTool, | |
| mockInput, | |
| ); | |
| expect(spawn).toHaveBeenCalledWith( | |
| expect.stringMatching(/bash|pwsh|powershell/), | |
| expect.arrayContaining([ | |
| expect.stringMatching( | |
| /ls ['"]\/test\/project\/plans with spaces['"]/, | |
| ), | |
| ]), | |
| expect.any(Object), | |
| ); | |
| }); | |
| it('should not allow command injection via GEMINI_PROJECT_DIR', async () => { | |
| const maliciousCwd = '/test/project; echo "pwned" > /tmp/pwned'; | |
| const mockMaliciousInput: HookInput = { | |
| ...mockInput, | |
| cwd: maliciousCwd, | |
| }; | |
| const config: HookConfig = { | |
| type: HookType.Command, | |
| command: 'ls $GEMINI_PROJECT_DIR', | |
| }; | |
| // Mock the process closing immediately | |
| mockSpawn.mockProcessOn.mockImplementation( | |
| (event: string, callback: (code: number) => void) => { | |
| if (event === 'close') { | |
| setImmediate(() => callback(0)); | |
| } | |
| }, | |
| ); | |
| await hookRunner.executeHook( | |
| config, | |
| HookEventName.BeforeTool, | |
| mockMaliciousInput, | |
| ); | |
| // If secure, spawn will be called with the shell executable and escaped command | |
| expect(spawn).toHaveBeenCalledWith( | |
| expect.stringMatching(/bash|pwsh|powershell/), | |
| expect.arrayContaining([ | |
| expect.stringMatching(/ls (['"]).*echo.*pwned.*\1/), | |
| ]), | |
| expect.objectContaining({ shell: false }), | |
| ); | |
| }); | |
| }); | |
| }); | |
| describe('executeHooksParallel', () => { | |
| it('should execute multiple hooks in parallel', async () => { | |
| const configs: HookConfig[] = [ | |
| { type: HookType.Command, command: './hook1.sh' }, | |
| { type: HookType.Command, command: './hook2.sh' }, | |
| ]; | |
| // Mock both commands to succeed | |
| mockSpawn.mockProcessOn.mockImplementation( | |
| (event: string, callback: (code: number) => void) => { | |
| if (event === 'close') { | |
| setImmediate(() => callback(0)); | |
| } | |
| }, | |
| ); | |
| const results = await hookRunner.executeHooksParallel( | |
| configs, | |
| HookEventName.BeforeTool, | |
| mockInput, | |
| ); | |
| expect(results).toHaveLength(2); | |
| expect(results.every((r) => r.success)).toBe(true); | |
| expect(spawn).toHaveBeenCalledTimes(2); | |
| }); | |
| it('should call onHookStart and onHookEnd callbacks', async () => { | |
| const configs: HookConfig[] = [ | |
| { name: 'hook1', type: HookType.Command, command: './hook1.sh' }, | |
| ]; | |
| mockSpawn.mockProcessOn.mockImplementation( | |
| (event: string, callback: (code: number) => void) => { | |
| if (event === 'close') { | |
| setImmediate(() => callback(0)); | |
| } | |
| }, | |
| ); | |
| const onStart = vi.fn(); | |
| const onEnd = vi.fn(); | |
| await hookRunner.executeHooksParallel( | |
| configs, | |
| HookEventName.BeforeTool, | |
| mockInput, | |
| onStart, | |
| onEnd, | |
| ); | |
| expect(onStart).toHaveBeenCalledWith(configs[0], 0); | |
| expect(onEnd).toHaveBeenCalledWith( | |
| configs[0], | |
| expect.objectContaining({ success: true }), | |
| ); | |
| }); | |
| it('should handle mixed success and failure', async () => { | |
| const configs: HookConfig[] = [ | |
| { type: HookType.Command, command: './hook1.sh' }, | |
| { type: HookType.Command, command: './hook2.sh' }, | |
| ]; | |
| let callCount = 0; | |
| mockSpawn.mockProcessOn.mockImplementation( | |
| (event: string, callback: (code: number) => void) => { | |
| if (event === 'close') { | |
| const exitCode = callCount++ === 0 ? 0 : 1; // First succeeds, second fails | |
| setImmediate(() => callback(exitCode)); | |
| } | |
| }, | |
| ); | |
| const results = await hookRunner.executeHooksParallel( | |
| configs, | |
| HookEventName.BeforeTool, | |
| mockInput, | |
| ); | |
| expect(results).toHaveLength(2); | |
| expect(results[0].success).toBe(true); | |
| expect(results[1].success).toBe(false); | |
| }); | |
| }); | |
| describe('executeHooksSequential', () => { | |
| it('should execute multiple hooks in sequence', async () => { | |
| const configs: HookConfig[] = [ | |
| { type: HookType.Command, command: './hook1.sh' }, | |
| { type: HookType.Command, command: './hook2.sh' }, | |
| ]; | |
| const executionOrder: string[] = []; | |
| // Mock both commands to succeed | |
| mockSpawn.mockProcessOn.mockImplementation( | |
| (event: string, callback: (code: number) => void) => { | |
| if (event === 'close') { | |
| const args = vi.mocked(spawn).mock.calls[ | |
| executionOrder.length | |
| ][1] as string[]; | |
| let command = args[args.length - 1]; | |
| // On Windows, the command is wrapped in PowerShell syntax | |
| if (command.includes('; if ($LASTEXITCODE -ne 0)')) { | |
| command = command.split(';')[0]; | |
| } | |
| executionOrder.push(command); | |
| setImmediate(() => callback(0)); | |
| } | |
| }, | |
| ); | |
| const results = await hookRunner.executeHooksSequential( | |
| configs, | |
| HookEventName.BeforeTool, | |
| mockInput, | |
| ); | |
| expect(results).toHaveLength(2); | |
| expect(results.every((r) => r.success)).toBe(true); | |
| expect(spawn).toHaveBeenCalledTimes(2); | |
| // Verify they were called sequentially | |
| expect(executionOrder).toEqual(['./hook1.sh', './hook2.sh']); | |
| }); | |
| it('should call onHookStart and onHookEnd callbacks sequentially', async () => { | |
| const configs: HookConfig[] = [ | |
| { name: 'hook1', type: HookType.Command, command: './hook1.sh' }, | |
| { name: 'hook2', type: HookType.Command, command: './hook2.sh' }, | |
| ]; | |
| mockSpawn.mockProcessOn.mockImplementation( | |
| (event: string, callback: (code: number) => void) => { | |
| if (event === 'close') { | |
| setImmediate(() => callback(0)); | |
| } | |
| }, | |
| ); | |
| const onStart = vi.fn(); | |
| const onEnd = vi.fn(); | |
| await hookRunner.executeHooksSequential( | |
| configs, | |
| HookEventName.BeforeTool, | |
| mockInput, | |
| onStart, | |
| onEnd, | |
| ); | |
| expect(onStart).toHaveBeenCalledTimes(2); | |
| expect(onEnd).toHaveBeenCalledTimes(2); | |
| expect(onStart).toHaveBeenNthCalledWith(1, configs[0], 0); | |
| expect(onStart).toHaveBeenNthCalledWith(2, configs[1], 1); | |
| }); | |
| it('should continue execution even if a hook fails', async () => { | |
| const configs: HookConfig[] = [ | |
| { type: HookType.Command, command: './hook1.sh' }, | |
| { type: HookType.Command, command: './hook2.sh' }, | |
| { type: HookType.Command, command: './hook3.sh' }, | |
| ]; | |
| let callCount = 0; | |
| mockSpawn.mockStderrOn.mockImplementation( | |
| (event: string, callback: (data: Buffer) => void) => { | |
| if (event === 'data' && callCount === 1) { | |
| // Second hook fails | |
| setImmediate(() => callback(Buffer.from('Hook 2 failed'))); | |
| } | |
| }, | |
| ); | |
| mockSpawn.mockProcessOn.mockImplementation( | |
| (event: string, callback: (code: number) => void) => { | |
| if (event === 'close') { | |
| const exitCode = callCount++ === 1 ? 1 : 0; // Second fails, others succeed | |
| setImmediate(() => callback(exitCode)); | |
| } | |
| }, | |
| ); | |
| const results = await hookRunner.executeHooksSequential( | |
| configs, | |
| HookEventName.BeforeTool, | |
| mockInput, | |
| ); | |
| expect(results).toHaveLength(3); | |
| expect(results[0].success).toBe(true); | |
| expect(results[1].success).toBe(false); | |
| expect(results[2].success).toBe(true); | |
| expect(spawn).toHaveBeenCalledTimes(3); | |
| }); | |
| it('should pass modified input from one hook to the next for BeforeAgent', async () => { | |
| const configs: HookConfig[] = [ | |
| { type: HookType.Command, command: './hook1.sh' }, | |
| { type: HookType.Command, command: './hook2.sh' }, | |
| ]; | |
| const mockBeforeAgentInput = { | |
| ...mockInput, | |
| prompt: 'Original prompt', | |
| }; | |
| const mockOutput1 = { | |
| decision: 'allow' as const, | |
| hookSpecificOutput: { | |
| additionalContext: 'Context from hook 1', | |
| }, | |
| format: 'json', | |
| }; | |
| let hookCallCount = 0; | |
| mockSpawn.mockStdoutOn.mockImplementation( | |
| (event: string, callback: (data: Buffer) => void) => { | |
| if (event === 'data') { | |
| if (hookCallCount === 0) { | |
| setImmediate(() => | |
| callback(Buffer.from(JSON.stringify(mockOutput1))), | |
| ); | |
| } | |
| } | |
| }, | |
| ); | |
| mockSpawn.mockProcessOn.mockImplementation( | |
| (event: string, callback: (code: number) => void) => { | |
| if (event === 'close') { | |
| hookCallCount++; | |
| setImmediate(() => callback(0)); | |
| } | |
| }, | |
| ); | |
| const results = await hookRunner.executeHooksSequential( | |
| configs, | |
| HookEventName.BeforeAgent, | |
| mockBeforeAgentInput, | |
| ); | |
| expect(results).toHaveLength(2); | |
| expect(results[0].success).toBe(true); | |
| expect(results[0].output).toEqual(mockOutput1); | |
| // Verify that the second hook received modified input | |
| const secondHookInput = JSON.parse( | |
| vi.mocked(mockSpawn.stdin.write).mock.calls[1][0], | |
| ); | |
| expect(secondHookInput.prompt).toContain('Original prompt'); | |
| expect(secondHookInput.prompt).toContain('Context from hook 1'); | |
| }); | |
| it('should pass modified LLM request from one hook to the next for BeforeModel', async () => { | |
| const configs: HookConfig[] = [ | |
| { type: HookType.Command, command: './hook1.sh' }, | |
| { type: HookType.Command, command: './hook2.sh' }, | |
| ]; | |
| const mockBeforeModelInput = { | |
| ...mockInput, | |
| llm_request: { | |
| model: 'gemini-1.5-pro', | |
| messages: [{ role: 'user', content: 'Hello' }], | |
| }, | |
| }; | |
| const mockOutput1 = { | |
| decision: 'allow' as const, | |
| hookSpecificOutput: { | |
| llm_request: { | |
| temperature: 0.7, | |
| }, | |
| }, | |
| }; | |
| let hookCallCount = 0; | |
| mockSpawn.mockStdoutOn.mockImplementation( | |
| (event: string, callback: (data: Buffer) => void) => { | |
| if (event === 'data') { | |
| if (hookCallCount === 0) { | |
| setImmediate(() => | |
| callback(Buffer.from(JSON.stringify(mockOutput1))), | |
| ); | |
| } | |
| } | |
| }, | |
| ); | |
| mockSpawn.mockProcessOn.mockImplementation( | |
| (event: string, callback: (code: number) => void) => { | |
| if (event === 'close') { | |
| hookCallCount++; | |
| setImmediate(() => callback(0)); | |
| } | |
| }, | |
| ); | |
| const results = await hookRunner.executeHooksSequential( | |
| configs, | |
| HookEventName.BeforeModel, | |
| mockBeforeModelInput, | |
| ); | |
| expect(results).toHaveLength(2); | |
| expect(results[0].success).toBe(true); | |
| // Verify that the second hook received modified input | |
| const secondHookInput = JSON.parse( | |
| vi.mocked(mockSpawn.stdin.write).mock.calls[1][0], | |
| ); | |
| expect(secondHookInput.llm_request.model).toBe('gemini-1.5-pro'); | |
| expect(secondHookInput.llm_request.temperature).toBe(0.7); | |
| }); | |
| it('should not modify input if hook fails', async () => { | |
| const configs: HookConfig[] = [ | |
| { type: HookType.Command, command: './hook1.sh' }, | |
| { type: HookType.Command, command: './hook2.sh' }, | |
| ]; | |
| mockSpawn.mockStderrOn.mockImplementation( | |
| (event: string, callback: (data: Buffer) => void) => { | |
| if (event === 'data') { | |
| setImmediate(() => callback(Buffer.from('Hook failed'))); | |
| } | |
| }, | |
| ); | |
| mockSpawn.mockProcessOn.mockImplementation( | |
| (event: string, callback: (code: number) => void) => { | |
| if (event === 'close') { | |
| setImmediate(() => callback(1)); // All hooks fail | |
| } | |
| }, | |
| ); | |
| const results = await hookRunner.executeHooksSequential( | |
| configs, | |
| HookEventName.BeforeTool, | |
| mockInput, | |
| ); | |
| expect(results).toHaveLength(2); | |
| expect(results.every((r) => !r.success)).toBe(true); | |
| // Verify that both hooks received the same original input | |
| const firstHookInput = JSON.parse( | |
| vi.mocked(mockSpawn.stdin.write).mock.calls[0][0], | |
| ); | |
| const secondHookInput = JSON.parse( | |
| vi.mocked(mockSpawn.stdin.write).mock.calls[1][0], | |
| ); | |
| expect(firstHookInput).toEqual(secondHookInput); | |
| }); | |
| }); | |
| describe('invalid JSON handling', () => { | |
| const commandConfig: HookConfig = { | |
| type: HookType.Command, | |
| command: './hooks/test.sh', | |
| }; | |
| it('should handle invalid JSON output gracefully', async () => { | |
| const invalidJson = '{ "decision": "allow", incomplete'; | |
| mockSpawn.mockStdoutOn.mockImplementation( | |
| (event: string, callback: (data: Buffer) => void) => { | |
| if (event === 'data') { | |
| setImmediate(() => callback(Buffer.from(invalidJson))); | |
| } | |
| }, | |
| ); | |
| mockSpawn.mockProcessOn.mockImplementation( | |
| (event: string, callback: (code: number) => void) => { | |
| if (event === 'close') { | |
| setImmediate(() => callback(0)); | |
| } | |
| }, | |
| ); | |
| const result = await hookRunner.executeHook( | |
| commandConfig, | |
| HookEventName.BeforeTool, | |
| mockInput, | |
| ); | |
| expect(result.success).toBe(true); | |
| expect(result.exitCode).toBe(0); | |
| // Should convert plain text to structured output | |
| expect(result.outputFormat).toBe('text'); | |
| expect(result.output).toEqual({ | |
| decision: 'allow', | |
| systemMessage: invalidJson, | |
| }); | |
| }); | |
| it('should handle malformed JSON with exit code 0', async () => { | |
| const malformedJson = 'not json at all'; | |
| mockSpawn.mockStdoutOn.mockImplementation( | |
| (event: string, callback: (data: Buffer) => void) => { | |
| if (event === 'data') { | |
| setImmediate(() => callback(Buffer.from(malformedJson))); | |
| } | |
| }, | |
| ); | |
| mockSpawn.mockProcessOn.mockImplementation( | |
| (event: string, callback: (code: number) => void) => { | |
| if (event === 'close') { | |
| setImmediate(() => callback(0)); | |
| } | |
| }, | |
| ); | |
| const result = await hookRunner.executeHook( | |
| commandConfig, | |
| HookEventName.BeforeTool, | |
| mockInput, | |
| ); | |
| expect(result.success).toBe(true); | |
| expect(result.outputFormat).toBe('text'); | |
| expect(result.output).toEqual({ | |
| decision: 'allow', | |
| systemMessage: malformedJson, | |
| }); | |
| }); | |
| it('should handle invalid JSON with exit code 1 (non-blocking error)', async () => { | |
| const invalidJson = '{ broken json'; | |
| mockSpawn.mockStderrOn.mockImplementation( | |
| (event: string, callback: (data: Buffer) => void) => { | |
| if (event === 'data') { | |
| setImmediate(() => callback(Buffer.from(invalidJson))); | |
| } | |
| }, | |
| ); | |
| mockSpawn.mockProcessOn.mockImplementation( | |
| (event: string, callback: (code: number) => void) => { | |
| if (event === 'close') { | |
| setImmediate(() => callback(1)); | |
| } | |
| }, | |
| ); | |
| const result = await hookRunner.executeHook( | |
| commandConfig, | |
| HookEventName.BeforeTool, | |
| mockInput, | |
| ); | |
| expect(result.success).toBe(false); | |
| expect(result.exitCode).toBe(1); | |
| expect(result.outputFormat).toBe('text'); | |
| expect(result.output).toEqual({ | |
| decision: 'allow', | |
| systemMessage: `Warning: ${invalidJson}`, | |
| }); | |
| }); | |
| it('should handle invalid JSON with exit code 2 (blocking error)', async () => { | |
| const invalidJson = '{ "error": incomplete'; | |
| mockSpawn.mockStderrOn.mockImplementation( | |
| (event: string, callback: (data: Buffer) => void) => { | |
| if (event === 'data') { | |
| setImmediate(() => callback(Buffer.from(invalidJson))); | |
| } | |
| }, | |
| ); | |
| mockSpawn.mockProcessOn.mockImplementation( | |
| (event: string, callback: (code: number) => void) => { | |
| if (event === 'close') { | |
| setImmediate(() => callback(2)); | |
| } | |
| }, | |
| ); | |
| const result = await hookRunner.executeHook( | |
| commandConfig, | |
| HookEventName.BeforeTool, | |
| mockInput, | |
| ); | |
| expect(result.success).toBe(false); | |
| expect(result.exitCode).toBe(2); | |
| expect(result.outputFormat).toBe('text'); | |
| expect(result.output).toEqual({ | |
| decision: 'deny', | |
| reason: invalidJson, | |
| }); | |
| }); | |
| it('should handle empty JSON output', async () => { | |
| mockSpawn.mockStdoutOn.mockImplementation( | |
| (event: string, callback: (data: Buffer) => void) => { | |
| if (event === 'data') { | |
| setImmediate(() => callback(Buffer.from(''))); | |
| } | |
| }, | |
| ); | |
| mockSpawn.mockProcessOn.mockImplementation( | |
| (event: string, callback: (code: number) => void) => { | |
| if (event === 'close') { | |
| setImmediate(() => callback(0)); | |
| } | |
| }, | |
| ); | |
| const result = await hookRunner.executeHook( | |
| commandConfig, | |
| HookEventName.BeforeTool, | |
| mockInput, | |
| ); | |
| expect(result.success).toBe(true); | |
| expect(result.exitCode).toBe(0); | |
| expect(result.output).toBeUndefined(); | |
| }); | |
| it('should handle double-encoded JSON string', async () => { | |
| const mockOutput = { | |
| decision: 'allow', | |
| reason: 'All good', | |
| format: 'json', | |
| }; | |
| const doubleEncodedJson = JSON.stringify(JSON.stringify(mockOutput)); | |
| mockSpawn.mockStdoutOn.mockImplementation( | |
| (event: string, callback: (data: Buffer) => void) => { | |
| if (event === 'data') { | |
| setImmediate(() => callback(Buffer.from(doubleEncodedJson))); | |
| } | |
| }, | |
| ); | |
| mockSpawn.mockProcessOn.mockImplementation( | |
| (event: string, callback: (code: number) => void) => { | |
| if (event === 'close') { | |
| setImmediate(() => callback(0)); | |
| } | |
| }, | |
| ); | |
| const result = await hookRunner.executeHook( | |
| commandConfig, | |
| HookEventName.BeforeTool, | |
| mockInput, | |
| ); | |
| expect(result.success).toBe(true); | |
| expect(result.output).toEqual(mockOutput); | |
| }); | |
| }); | |
| }); | |