File size: 12,671 Bytes
7a1ad33
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
/**
 * @license
 * Copyright 2025 Google LLC
 * SPDX-License-Identifier: Apache-2.0
 */

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';

// 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 - use vi.hoisted to define mock before it's used in vi.mock
const mockDebugLogger = vi.hoisted(() => ({
  debug: vi.fn(),
  log: vi.fn(),
  warn: vi.fn(),
  error: 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('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 });

    // Create a real config with simple command hook configurations for testing
    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,
              },
            ],
          },
        ],
      },
    });

    // Provide getMessageBus mock for MessageBus integration tests
    (config as unknown as { getMessageBus: () => unknown }).getMessageBus =
      () => undefined;

    hookSystem = new HookSystem(config);

    // Set up spawn mock 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(async () => {
    // No cleanup needed
  });

  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(); // Second call should be no-op

      // The system logs both registry initialization and system initialization
      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 });

      // Create a config with invalid hooks to trigger initialization errors
      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, // Invalid hook type for testing
                  command: './test.sh',
                  // eslint-disable-next-line @typescript-eslint/no-explicit-any
                } as any,
              ],
            },
          ],
        },
      });

      const invalidHookSystem = new HookSystem(invalidConfig);

      // Should not throw, but should log warnings via debugLogger
      await invalidHookSystem.initialize();

      expect(mockDebugLogger.warn).toHaveBeenCalled();
    });
  });

  describe('getEventHandler', () => {
    it('should return event bus when initialized', async () => {
      await hookSystem.initialize();

      // Set up spawn mock behavior for successful execution
      mockSpawn.mockStdoutOn.mockImplementation(
        (event: string, callback: (data: Buffer) => void) => {
          if (event === 'data') {
            setTimeout(() => callback(Buffer.from('')), 5); // echo outputs empty
          }
        },
      );

      mockSpawn.mockProcessOn.mockImplementation(
        (event: string, callback: (code: number) => void) => {
          if (event === 'close') {
            setTimeout(() => callback(0), 10);
          }
        },
      );

      const eventBus = hookSystem.getEventHandler();
      expect(eventBus).toBeDefined();

      // Test that the event bus can actually fire events
      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();

      // Set up spawn mock behavior for successful execution
      mockSpawn.mockStdoutOn.mockImplementation(
        (event: string, callback: (data: Buffer) => void) => {
          if (event === 'data') {
            setTimeout(() => callback(Buffer.from('')), 5); // echo outputs empty
          }
        },
      );

      mockSpawn.mockProcessOn.mockImplementation(
        (event: string, callback: (code: number) => void) => {
          if (event === 'close') {
            setTimeout(() => callback(0), 10);
          }
        },
      );

      const eventBus = hookSystem.getEventHandler();

      // Test BeforeTool event with command hook
      const result = await eventBus.fireBeforeToolEvent('TestTool', {
        test: 'data',
      });

      expect(result.success).toBe(true);
      // Command hooks with echo should succeed but may not have specific decisions
      expect(result.errors).toHaveLength(0);
    });

    it('should handle no matching hooks', async () => {
      await hookSystem.initialize();

      const eventBus = hookSystem.getEventHandler();

      // Test with a tool that doesn't match any hooks
      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 });

      // Create config with two hooks, one enabled and one disabled via settings
      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"'], // Disable the second hook
      });

      (
        configWithDisabled as unknown as { getMessageBus: () => unknown }
      ).getMessageBus = () => undefined;

      const systemWithDisabled = new HookSystem(configWithDisabled);
      await systemWithDisabled.initialize();

      // Set up spawn mock - only enabled hook should execute
      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);
      // Only the enabled hook should have executed
      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 });

      // Create config with a hook
      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();

      // First execution - hook should run
      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);

      // Disable the hook via setHookEnabled (simulating /hooks disable command)
      systemForDisabling.setHookEnabled('echo "will-be-disabled"', false);

      // Reset execution count
      executionCount = 0;

      // Second execution - hook should NOT run
      const result2 = await eventBus.fireBeforeToolEvent('TestTool', {
        test: 'data',
      });

      expect(result2.success).toBe(true);
      // Hook should not have executed
      expect(executionCount).toBe(0);

      // Re-enable the hook
      systemForDisabling.setHookEnabled('echo "will-be-disabled"', true);

      // Reset execution count
      executionCount = 0;

      // Third execution - hook should run again
      const result3 = await eventBus.fireBeforeToolEvent('TestTool', {
        test: 'data',
      });

      expect(result3.success).toBe(true);
      expect(executionCount).toBe(1);
    });
  });
});