Files
gemini-cli/packages/core/src/hooks/hookRegistry.test.ts

784 lines
22 KiB
TypeScript

/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import * as fs from 'node:fs';
import { HookRegistry } from './hookRegistry.js';
import type { Storage } from '../config/storage.js';
import {
ConfigSource,
HookEventName,
HookType,
HOOKS_CONFIG_FIELDS,
} from './types.js';
import type { Config } from '../config/config.js';
import type { HookDefinition } from './types.js';
// Mock fs
vi.mock('fs', () => ({
existsSync: vi.fn(),
readFileSync: 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,
}));
const { mockTrustedHooksManager, mockCoreEvents } = vi.hoisted(() => ({
mockTrustedHooksManager: {
getUntrustedHooks: vi.fn().mockReturnValue([]),
trustHooks: vi.fn(),
},
mockCoreEvents: {
emitConsoleLog: vi.fn(),
emitFeedback: vi.fn(),
},
}));
vi.mock('./trustedHooks.js', () => ({
TrustedHooksManager: vi.fn(() => mockTrustedHooksManager),
}));
vi.mock('../utils/events.js', () => ({
coreEvents: mockCoreEvents,
}));
describe('HookRegistry', () => {
let hookRegistry: HookRegistry;
let mockConfig: Config;
let mockStorage: Storage;
beforeEach(() => {
vi.resetAllMocks();
mockStorage = {
getGeminiDir: vi.fn().mockReturnValue('/project/.gemini'),
} as unknown as Storage;
mockConfig = {
storage: mockStorage,
getExtensions: vi.fn().mockReturnValue([]),
getHooks: vi.fn().mockReturnValue({}),
getProjectHooks: vi.fn().mockReturnValue({}),
getDisabledHooks: vi.fn().mockReturnValue([]),
isTrustedFolder: vi.fn().mockReturnValue(true),
getProjectRoot: vi.fn().mockReturnValue('/project'),
} as unknown as Config;
hookRegistry = new HookRegistry(mockConfig);
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('initialize', () => {
it('should initialize successfully with no hooks', async () => {
vi.mocked(fs.existsSync).mockReturnValue(false);
await hookRegistry.initialize();
expect(hookRegistry.getAllHooks()).toHaveLength(0);
expect(mockDebugLogger.debug).toHaveBeenCalledWith(
'Hook registry initialized with 0 hook entries',
);
});
it('should not load hooks if folder is not trusted', async () => {
vi.mocked(mockConfig.isTrustedFolder).mockReturnValue(false);
const mockHooksConfig = {
BeforeTool: [
{
hooks: [
{
type: 'command',
command: './hooks/test.sh',
},
],
},
],
};
vi.mocked(mockConfig.getHooks).mockReturnValue(
mockHooksConfig as unknown as {
[K in HookEventName]?: HookDefinition[];
},
);
await hookRegistry.initialize();
expect(hookRegistry.getAllHooks()).toHaveLength(0);
expect(mockDebugLogger.warn).toHaveBeenCalledWith(
'Project hooks disabled because the folder is not trusted.',
);
});
it('should load hooks from project configuration', async () => {
const mockHooksConfig = {
BeforeTool: [
{
matcher: 'EditTool',
hooks: [
{
type: 'command',
command: './hooks/check_style.sh',
timeout: 60,
},
],
},
],
};
// Update mock to return the hooks configuration
vi.mocked(mockConfig.getHooks).mockReturnValue(
mockHooksConfig as unknown as {
[K in HookEventName]?: HookDefinition[];
},
);
await hookRegistry.initialize();
const hooks = hookRegistry.getAllHooks();
expect(hooks).toHaveLength(1);
expect(hooks[0].eventName).toBe(HookEventName.BeforeTool);
expect(hooks[0].config.type).toBe(HookType.Command);
expect(hooks[0].config.command).toBe('./hooks/check_style.sh');
expect(hooks[0].matcher).toBe('EditTool');
expect(hooks[0].source).toBe(ConfigSource.Project);
});
it('should load plugin hooks', async () => {
const mockHooksConfig = {
AfterTool: [
{
hooks: [
{
type: 'command',
command: './hooks/after-tool.sh',
timeout: 30,
},
],
},
],
};
// Update mock to return the hooks configuration
vi.mocked(mockConfig.getHooks).mockReturnValue(
mockHooksConfig as unknown as {
[K in HookEventName]?: HookDefinition[];
},
);
await hookRegistry.initialize();
const hooks = hookRegistry.getAllHooks();
expect(hooks).toHaveLength(1);
expect(hooks[0].eventName).toBe(HookEventName.AfterTool);
expect(hooks[0].config.type).toBe(HookType.Command);
expect(hooks[0].config.command).toBe('./hooks/after-tool.sh');
});
it('should handle invalid configuration gracefully', async () => {
const invalidHooksConfig = {
BeforeTool: [
{
hooks: [
{
type: 'invalid-type', // Invalid hook type
command: './hooks/test.sh',
},
],
},
],
};
// Update mock to return invalid configuration
vi.mocked(mockConfig.getHooks).mockReturnValue(
invalidHooksConfig as unknown as {
[K in HookEventName]?: HookDefinition[];
},
);
await hookRegistry.initialize();
expect(hookRegistry.getAllHooks()).toHaveLength(0);
expect(mockDebugLogger.warn).toHaveBeenCalled();
});
it('should validate hook configurations', async () => {
const mockHooksConfig = {
BeforeTool: [
{
hooks: [
{
type: 'invalid',
command: './hooks/test.sh',
},
{
type: 'command',
// Missing command field
},
],
},
],
};
// Update mock to return invalid configuration
vi.mocked(mockConfig.getHooks).mockReturnValue(
mockHooksConfig as unknown as {
[K in HookEventName]?: HookDefinition[];
},
);
await hookRegistry.initialize();
expect(hookRegistry.getAllHooks()).toHaveLength(0);
expect(mockDebugLogger.warn).toHaveBeenCalled(); // At least some warnings should be logged
});
it('should respect disabled hooks using friendly name', async () => {
const mockHooksConfig = {
BeforeTool: [
{
hooks: [
{
name: 'disabled-hook',
type: 'command',
command: './hooks/test.sh',
},
],
},
],
};
// Update mock to return the hooks configuration
vi.mocked(mockConfig.getHooks).mockReturnValue(
mockHooksConfig as unknown as {
[K in HookEventName]?: HookDefinition[];
},
);
vi.mocked(mockConfig.getDisabledHooks).mockReturnValue(['disabled-hook']);
await hookRegistry.initialize();
const hooks = hookRegistry.getAllHooks();
expect(hooks).toHaveLength(1);
expect(hooks[0].enabled).toBe(false);
expect(
hookRegistry.getHooksForEvent(HookEventName.BeforeTool),
).toHaveLength(0);
});
});
describe('getHooksForEvent', () => {
beforeEach(async () => {
const mockHooksConfig = {
BeforeTool: [
{
matcher: 'EditTool',
hooks: [
{
type: 'command',
command: './hooks/edit_check.sh',
},
],
},
{
hooks: [
{
type: 'command',
command: './hooks/general_check.sh',
},
],
},
],
AfterTool: [
{
hooks: [
{
type: 'command',
command: './hooks/after-tool.sh',
},
],
},
],
};
// Update mock to return the hooks configuration
vi.mocked(mockConfig.getHooks).mockReturnValue(
mockHooksConfig as unknown as {
[K in HookEventName]?: HookDefinition[];
},
);
await hookRegistry.initialize();
});
it('should return hooks for specific event', () => {
const beforeToolHooks = hookRegistry.getHooksForEvent(
HookEventName.BeforeTool,
);
expect(beforeToolHooks).toHaveLength(2);
const afterToolHooks = hookRegistry.getHooksForEvent(
HookEventName.AfterTool,
);
expect(afterToolHooks).toHaveLength(1);
});
it('should return empty array for events with no hooks', () => {
const notificationHooks = hookRegistry.getHooksForEvent(
HookEventName.Notification,
);
expect(notificationHooks).toHaveLength(0);
});
});
describe('setHookEnabled', () => {
beforeEach(async () => {
const mockHooksConfig = {
BeforeTool: [
{
hooks: [
{
type: 'command',
command: './hooks/test.sh',
},
],
},
],
};
// Update mock to return the hooks configuration
vi.mocked(mockConfig.getHooks).mockReturnValue(
mockHooksConfig as unknown as {
[K in HookEventName]?: HookDefinition[];
},
);
await hookRegistry.initialize();
});
it('should enable and disable hooks', () => {
const hookName = './hooks/test.sh';
// Initially enabled
let hooks = hookRegistry.getHooksForEvent(HookEventName.BeforeTool);
expect(hooks).toHaveLength(1);
// Disable
hookRegistry.setHookEnabled(hookName, false);
hooks = hookRegistry.getHooksForEvent(HookEventName.BeforeTool);
expect(hooks).toHaveLength(0);
// Re-enable
hookRegistry.setHookEnabled(hookName, true);
hooks = hookRegistry.getHooksForEvent(HookEventName.BeforeTool);
expect(hooks).toHaveLength(1);
});
it('should warn when hook not found', () => {
hookRegistry.setHookEnabled('non-existent-hook', false);
expect(mockDebugLogger.warn).toHaveBeenCalledWith(
'No hooks found matching "non-existent-hook"',
);
});
it('should prefer hook name over command for identification', async () => {
const mockHooksConfig = {
BeforeTool: [
{
hooks: [
{
name: 'friendly-name',
type: 'command',
command: './hooks/test.sh',
},
],
},
],
};
vi.mocked(mockConfig.getHooks).mockReturnValue(
mockHooksConfig as unknown as {
[K in HookEventName]?: HookDefinition[];
},
);
await hookRegistry.initialize();
// Should be enabled initially
let hooks = hookRegistry.getHooksForEvent(HookEventName.BeforeTool);
expect(hooks).toHaveLength(1);
// Disable using friendly name
hookRegistry.setHookEnabled('friendly-name', false);
hooks = hookRegistry.getHooksForEvent(HookEventName.BeforeTool);
expect(hooks).toHaveLength(0);
// Identification by command should NOT work when name is present
hookRegistry.setHookEnabled('./hooks/test.sh', true);
expect(mockDebugLogger.warn).toHaveBeenCalledWith(
'No hooks found matching "./hooks/test.sh"',
);
});
it('should use command as identifier when name is missing', async () => {
const mockHooksConfig = {
BeforeTool: [
{
hooks: [
{
type: 'command',
command: './hooks/no-name.sh',
},
],
},
],
};
vi.mocked(mockConfig.getHooks).mockReturnValue(
mockHooksConfig as unknown as {
[K in HookEventName]?: HookDefinition[];
},
);
await hookRegistry.initialize();
// Should be enabled initially
let hooks = hookRegistry.getHooksForEvent(HookEventName.BeforeTool);
expect(hooks).toHaveLength(1);
// Disable using command
hookRegistry.setHookEnabled('./hooks/no-name.sh', false);
hooks = hookRegistry.getHooksForEvent(HookEventName.BeforeTool);
expect(hooks).toHaveLength(0);
});
});
describe('malformed configuration handling', () => {
it('should handle non-array definitions gracefully', async () => {
const malformedConfig = {
BeforeTool: 'not-an-array', // Should be an array of HookDefinition
};
vi.mocked(mockConfig.getHooks).mockReturnValue(
malformedConfig as unknown as {
[K in HookEventName]?: HookDefinition[];
},
);
await hookRegistry.initialize();
expect(hookRegistry.getAllHooks()).toHaveLength(0);
expect(mockDebugLogger.warn).toHaveBeenCalledWith(
expect.stringContaining('is not an array'),
);
});
it('should handle object instead of array for definitions', async () => {
const malformedConfig = {
AfterTool: { hooks: [] }, // Should be an array, not a single object
};
vi.mocked(mockConfig.getHooks).mockReturnValue(
malformedConfig as unknown as {
[K in HookEventName]?: HookDefinition[];
},
);
await hookRegistry.initialize();
expect(hookRegistry.getAllHooks()).toHaveLength(0);
expect(mockDebugLogger.warn).toHaveBeenCalledWith(
expect.stringContaining('is not an array'),
);
});
it('should handle null definition gracefully', async () => {
const malformedConfig = {
BeforeTool: [null], // Invalid: null definition
};
vi.mocked(mockConfig.getHooks).mockReturnValue(
malformedConfig as unknown as {
[K in HookEventName]?: HookDefinition[];
},
);
await hookRegistry.initialize();
expect(hookRegistry.getAllHooks()).toHaveLength(0);
expect(mockDebugLogger.warn).toHaveBeenCalledWith(
expect.stringContaining('Discarding invalid hook definition'),
null,
);
});
it('should handle definition without hooks array', async () => {
const malformedConfig = {
BeforeTool: [
{
matcher: 'EditTool',
// Missing hooks array
},
],
};
vi.mocked(mockConfig.getHooks).mockReturnValue(
malformedConfig as unknown as {
[K in HookEventName]?: HookDefinition[];
},
);
await hookRegistry.initialize();
expect(hookRegistry.getAllHooks()).toHaveLength(0);
expect(mockDebugLogger.warn).toHaveBeenCalledWith(
expect.stringContaining('Discarding invalid hook definition'),
expect.objectContaining({ matcher: 'EditTool' }),
);
});
it('should handle non-array hooks property', async () => {
const malformedConfig = {
BeforeTool: [
{
matcher: 'EditTool',
hooks: 'not-an-array', // Should be an array
},
],
};
vi.mocked(mockConfig.getHooks).mockReturnValue(
malformedConfig as unknown as {
[K in HookEventName]?: HookDefinition[];
},
);
await hookRegistry.initialize();
expect(hookRegistry.getAllHooks()).toHaveLength(0);
expect(mockDebugLogger.warn).toHaveBeenCalledWith(
expect.stringContaining('Discarding invalid hook definition'),
expect.objectContaining({ hooks: 'not-an-array', matcher: 'EditTool' }),
);
});
it('should handle non-object hookConfig in hooks array', async () => {
const malformedConfig = {
BeforeTool: [
{
hooks: [
'not-an-object', // Should be an object
42, // Should be an object
null, // Should be an object
],
},
],
};
mockTrustedHooksManager.getUntrustedHooks.mockReturnValue([]);
vi.mocked(mockConfig.getHooks).mockReturnValue(
malformedConfig as unknown as {
[K in HookEventName]?: HookDefinition[];
},
);
await hookRegistry.initialize();
expect(hookRegistry.getAllHooks()).toHaveLength(0);
expect(mockDebugLogger.warn).toHaveBeenCalledTimes(3); // One warning for each invalid hookConfig
});
it('should handle mixed valid and invalid hook configurations', async () => {
const mixedConfig = {
BeforeTool: [
{
hooks: [
{
type: 'command',
command: './valid-hook.sh',
},
'invalid-string',
{
type: 'invalid-type',
command: './invalid-type.sh',
},
],
},
],
};
vi.mocked(mockConfig.getHooks).mockReturnValue(
mixedConfig as unknown as {
[K in HookEventName]?: HookDefinition[];
},
);
await hookRegistry.initialize();
// Should only load the valid hook
const hooks = hookRegistry.getAllHooks();
expect(hooks).toHaveLength(1);
expect(hooks[0].config.command).toBe('./valid-hook.sh');
// Verify the warnings for invalid configurations
// 1st warning: non-object hookConfig ('invalid-string')
expect(mockDebugLogger.warn).toHaveBeenCalledWith(
expect.stringContaining('Discarding invalid hook configuration'),
'invalid-string',
);
// 2nd warning: validateHookConfig logs invalid type
expect(mockDebugLogger.warn).toHaveBeenCalledWith(
expect.stringContaining('Invalid hook BeforeTool from project type'),
);
// 3rd warning: processHookDefinition logs the failed hookConfig
expect(mockDebugLogger.warn).toHaveBeenCalledWith(
expect.stringContaining('Discarding invalid hook configuration'),
expect.objectContaining({ type: 'invalid-type' }),
);
});
it('should skip known config fields and warn on invalid event names', async () => {
const configWithExtras: Record<string, unknown> = {
InvalidEvent: [],
BeforeTool: [
{
hooks: [
{
type: 'command',
command: './test.sh',
},
],
},
],
};
// Add all known config fields dynamically
for (const field of HOOKS_CONFIG_FIELDS) {
configWithExtras[field] = field === 'disabled' ? [] : true;
}
vi.mocked(mockConfig.getHooks).mockReturnValue(
configWithExtras as unknown as {
[K in HookEventName]?: HookDefinition[];
},
);
await hookRegistry.initialize();
// Should only load the valid hook
expect(hookRegistry.getAllHooks()).toHaveLength(1);
// Should skip all known config fields without warnings
for (const field of HOOKS_CONFIG_FIELDS) {
expect(mockDebugLogger.warn).not.toHaveBeenCalledWith(
expect.stringContaining(`Invalid hook event name: ${field}`),
);
}
// Should warn on truly invalid event name
expect(mockCoreEvents.emitFeedback).toHaveBeenCalledWith(
'warning',
expect.stringContaining('Invalid hook event name: "InvalidEvent"'),
);
});
});
describe('project hook warnings', () => {
it('should check for untrusted project hooks when folder is trusted', async () => {
const projectHooks = {
BeforeTool: [
{
hooks: [
{
type: 'command',
command: './hooks/untrusted.sh',
},
],
},
],
};
vi.mocked(mockConfig.getHooks).mockReturnValue(
projectHooks as unknown as { [K in HookEventName]?: HookDefinition[] },
);
vi.mocked(mockConfig.getProjectHooks).mockReturnValue(
projectHooks as unknown as { [K in HookEventName]?: HookDefinition[] },
);
// Simulate untrusted hooks found
mockTrustedHooksManager.getUntrustedHooks.mockReturnValue([
'./hooks/untrusted.sh',
]);
await hookRegistry.initialize();
expect(mockTrustedHooksManager.getUntrustedHooks).toHaveBeenCalledWith(
'/project',
projectHooks,
);
expect(mockCoreEvents.emitFeedback).toHaveBeenCalledWith(
'warning',
expect.stringContaining(
'WARNING: The following project-level hooks have been detected',
),
);
expect(mockTrustedHooksManager.trustHooks).toHaveBeenCalledWith(
'/project',
projectHooks,
);
});
it('should not warn if hooks are already trusted', async () => {
const projectHooks = {
BeforeTool: [
{
hooks: [
{
type: 'command',
command: './hooks/trusted.sh',
},
],
},
],
};
vi.mocked(mockConfig.getHooks).mockReturnValue(
projectHooks as unknown as { [K in HookEventName]?: HookDefinition[] },
);
vi.mocked(mockConfig.getProjectHooks).mockReturnValue(
projectHooks as unknown as { [K in HookEventName]?: HookDefinition[] },
);
// Simulate no untrusted hooks
mockTrustedHooksManager.getUntrustedHooks.mockReturnValue([]);
await hookRegistry.initialize();
expect(mockCoreEvents.emitFeedback).not.toHaveBeenCalled();
expect(mockTrustedHooksManager.trustHooks).not.toHaveBeenCalled();
});
it('should not check for untrusted hooks if folder is not trusted', async () => {
vi.mocked(mockConfig.isTrustedFolder).mockReturnValue(false);
await hookRegistry.initialize();
expect(mockTrustedHooksManager.getUntrustedHooks).not.toHaveBeenCalled();
});
});
});