// @lifecycle canonical - Integration tests for gate hook emission.
/**
* Gate Hooks Integration Tests
*
* Verifies that gate events are properly emitted through the pipeline:
* - HookRegistry receives gate evaluation events
* - McpNotificationEmitter sends MCP notifications
* - Events are emitted at correct points in ResponseCaptureStage
*/
import { describe, test, expect, beforeEach, afterEach, jest } from '@jest/globals';
import {
HookRegistry,
type GateHooks,
type HookExecutionContext,
} from '../../../src/infra/hooks/index.js';
import {
McpNotificationEmitter,
type McpNotificationServer,
} from '../../../src/infra/observability/notifications/index.js';
import { noopLogger } from '../../../src/infra/logging/index.js';
describe('Gate Hooks Integration', () => {
let hookRegistry: HookRegistry;
let notificationEmitter: McpNotificationEmitter;
let mockServer: jest.Mocked<McpNotificationServer>;
beforeEach(() => {
hookRegistry = new HookRegistry(noopLogger);
notificationEmitter = new McpNotificationEmitter(noopLogger);
// Create mock MCP server
mockServer = {
notification: jest.fn(),
};
notificationEmitter.setServer(mockServer);
});
afterEach(() => {
hookRegistry.clearAll();
});
describe('HookRegistry Gate Events', () => {
test('emits gate:evaluated event when gate passes', async () => {
const events: Array<{ gateId: string; passed: boolean }> = [];
hookRegistry.on('gate:evaluated', (event) => events.push(event));
const context: HookExecutionContext = {
executionId: 'test-exec-1',
executionType: 'chain',
chainId: 'test-chain-1',
currentStep: 1,
frameworkEnabled: false,
};
await hookRegistry.emitGateEvaluated(
{ id: 'code-quality' } as any,
{ passed: true, reason: 'All criteria met', blocksResponse: false },
context
);
expect(events).toHaveLength(1);
expect(events[0]).toEqual({ gateId: 'code-quality', passed: true });
});
test('emits gate:failed event when gate fails', async () => {
const events: Array<{ gateId: string; reason: string }> = [];
hookRegistry.on('gate:failed', (event) => events.push(event));
const context: HookExecutionContext = {
executionId: 'test-exec-2',
executionType: 'chain',
chainId: 'test-chain-1',
currentStep: 1,
frameworkEnabled: false,
};
await hookRegistry.emitGateFailed(
{ id: 'code-quality' } as any,
'Missing required documentation',
context
);
expect(events).toHaveLength(1);
expect(events[0]).toEqual({
gateId: 'code-quality',
reason: 'Missing required documentation',
});
});
test('emits gate:retryExhausted event when retries exhausted', async () => {
const events: Array<{ gateIds: string[]; chainId: string }> = [];
hookRegistry.on('gate:retryExhausted', (event) => events.push(event));
const context: HookExecutionContext = {
executionId: 'test-exec-3',
executionType: 'chain',
chainId: 'test-chain-1',
currentStep: 2,
frameworkEnabled: true,
frameworkId: 'CAGEERF',
};
await hookRegistry.emitRetryExhausted(
['code-quality', 'research-quality'],
'test-chain-1',
context
);
expect(events).toHaveLength(1);
expect(events[0]).toEqual({
gateIds: ['code-quality', 'research-quality'],
chainId: 'test-chain-1',
});
});
test('emits gate:responseBlocked event when response blocked', async () => {
const events: Array<{ gateIds: string[] }> = [];
hookRegistry.on('gate:responseBlocked', (event) => events.push(event));
const context: HookExecutionContext = {
executionId: 'test-exec-4',
executionType: 'single',
frameworkEnabled: false,
};
await hookRegistry.emitResponseBlocked(['critical-gate'], context);
expect(events).toHaveLength(1);
expect(events[0]).toEqual({ gateIds: ['critical-gate'] });
});
test('calls registered hook callbacks', async () => {
const onGateEvaluated = jest.fn();
const onGateFailed = jest.fn();
const onRetryExhausted = jest.fn();
const onResponseBlocked = jest.fn();
const hooks: GateHooks = {
onGateEvaluated,
onGateFailed,
onRetryExhausted,
onResponseBlocked,
};
hookRegistry.registerGateHooks(hooks);
const context: HookExecutionContext = {
executionId: 'test-exec-5',
executionType: 'chain',
chainId: 'chain-1',
currentStep: 1,
frameworkEnabled: true,
frameworkId: 'CAGEERF',
};
// Test each hook type
await hookRegistry.emitGateEvaluated(
{ id: 'gate-1' } as any,
{ passed: true, reason: 'passed', blocksResponse: false },
context
);
expect(onGateEvaluated).toHaveBeenCalledTimes(1);
expect(onGateEvaluated).toHaveBeenCalledWith(
{ id: 'gate-1' },
{ passed: true, reason: 'passed', blocksResponse: false },
context
);
await hookRegistry.emitGateFailed({ id: 'gate-2' } as any, 'failed reason', context);
expect(onGateFailed).toHaveBeenCalledWith({ id: 'gate-2' }, 'failed reason', context);
await hookRegistry.emitRetryExhausted(['gate-3'], 'chain-1', context);
expect(onRetryExhausted).toHaveBeenCalledWith(['gate-3'], 'chain-1', context);
await hookRegistry.emitResponseBlocked(['gate-4'], context);
expect(onResponseBlocked).toHaveBeenCalledWith(['gate-4'], context);
});
test('continues executing other hooks if one throws', async () => {
const hook1 = jest.fn().mockRejectedValue(new Error('Hook 1 error'));
const hook2 = jest.fn();
hookRegistry.registerGateHooks({ onGateFailed: hook1 });
hookRegistry.registerGateHooks({ onGateFailed: hook2 });
const context: HookExecutionContext = {
executionId: 'test-exec-6',
executionType: 'single',
frameworkEnabled: false,
};
// Should not throw, and should call both hooks
await hookRegistry.emitGateFailed({ id: 'gate' } as any, 'reason', context);
expect(hook1).toHaveBeenCalledTimes(1);
expect(hook2).toHaveBeenCalledTimes(1);
});
});
describe('McpNotificationEmitter', () => {
test('sends gate failed notification', () => {
notificationEmitter.emitGateFailed({
gateId: 'code-quality',
reason: 'Missing tests',
chainId: 'chain-1',
});
expect(mockServer.notification).toHaveBeenCalledWith({
method: 'notifications/gate/failed',
params: {
gateId: 'code-quality',
reason: 'Missing tests',
chainId: 'chain-1',
},
});
});
test('sends response blocked notification', () => {
notificationEmitter.emitResponseBlocked({
gateIds: ['critical-gate-1', 'critical-gate-2'],
chainId: 'chain-1',
});
expect(mockServer.notification).toHaveBeenCalledWith({
method: 'notifications/gate/response_blocked',
params: {
gateIds: ['critical-gate-1', 'critical-gate-2'],
chainId: 'chain-1',
},
});
});
test('sends retry exhausted notification', () => {
notificationEmitter.emitRetryExhausted({
gateIds: ['gate-1'],
chainId: 'chain-1',
maxAttempts: 3,
});
expect(mockServer.notification).toHaveBeenCalledWith({
method: 'notifications/gate/retry_exhausted',
params: {
gateIds: ['gate-1'],
chainId: 'chain-1',
maxAttempts: 3,
},
});
});
test('gracefully handles missing server', () => {
const emitterWithoutServer = new McpNotificationEmitter(noopLogger);
// Should not throw
expect(() => {
emitterWithoutServer.emitGateFailed({
gateId: 'gate',
reason: 'reason',
});
}).not.toThrow();
});
test('gracefully handles notification error', () => {
mockServer.notification.mockImplementation(() => {
throw new Error('Connection lost');
});
// Should not throw
expect(() => {
notificationEmitter.emitGateFailed({
gateId: 'gate',
reason: 'reason',
});
}).not.toThrow();
});
});
describe('Combined Hook and Notification Flow', () => {
test('supports both hooks and notifications for same event', async () => {
const hookCallback = jest.fn();
hookRegistry.registerGateHooks({ onGateFailed: hookCallback });
const context: HookExecutionContext = {
executionId: 'combined-test',
executionType: 'chain',
chainId: 'chain-1',
currentStep: 1,
frameworkEnabled: false,
};
// Emit via HookRegistry
await hookRegistry.emitGateFailed({ id: 'gate-1' } as any, 'Gate criteria not met', context);
// Also emit via NotificationEmitter
notificationEmitter.emitGateFailed({
gateId: 'gate-1',
reason: 'Gate criteria not met',
chainId: 'chain-1',
});
// Verify both were called
expect(hookCallback).toHaveBeenCalledTimes(1);
expect(mockServer.notification).toHaveBeenCalledTimes(1);
});
});
});