264 lines
7.3 KiB
TypeScript
264 lines
7.3 KiB
TypeScript
// plugin/hooks/hooks-app/__tests__/dispatcher.test.ts
|
|
import { shouldProcessHook, dispatch, gateMatchesKeywords } from '../src/dispatcher';
|
|
import { HookInput, HookConfig, GateConfig } from '../src/types';
|
|
import * as fs from 'fs/promises';
|
|
import * as path from 'path';
|
|
import * as os from 'os';
|
|
|
|
describe('Dispatcher - Event Filtering', () => {
|
|
test('PostToolUse with enabled tool returns true', () => {
|
|
const input: HookInput = {
|
|
hook_event_name: 'PostToolUse',
|
|
cwd: '/test',
|
|
tool_name: 'Edit'
|
|
};
|
|
|
|
const hookConfig: HookConfig = {
|
|
enabled_tools: ['Edit', 'Write']
|
|
};
|
|
|
|
expect(shouldProcessHook(input, hookConfig)).toBe(true);
|
|
});
|
|
|
|
test('PostToolUse with disabled tool returns false', () => {
|
|
const input: HookInput = {
|
|
hook_event_name: 'PostToolUse',
|
|
cwd: '/test',
|
|
tool_name: 'Read'
|
|
};
|
|
|
|
const hookConfig: HookConfig = {
|
|
enabled_tools: ['Edit', 'Write']
|
|
};
|
|
|
|
expect(shouldProcessHook(input, hookConfig)).toBe(false);
|
|
});
|
|
|
|
test('SubagentStop with enabled agent returns true', () => {
|
|
const input: HookInput = {
|
|
hook_event_name: 'SubagentStop',
|
|
cwd: '/test',
|
|
agent_name: 'test-namespace:test-agent'
|
|
};
|
|
|
|
const hookConfig: HookConfig = {
|
|
enabled_agents: ['test-namespace:test-agent']
|
|
};
|
|
|
|
expect(shouldProcessHook(input, hookConfig)).toBe(true);
|
|
});
|
|
|
|
test('SubagentStop with disabled agent returns false', () => {
|
|
const input: HookInput = {
|
|
hook_event_name: 'SubagentStop',
|
|
cwd: '/test',
|
|
agent_name: 'other-agent'
|
|
};
|
|
|
|
const hookConfig: HookConfig = {
|
|
enabled_agents: ['test-namespace:test-agent']
|
|
};
|
|
|
|
expect(shouldProcessHook(input, hookConfig)).toBe(false);
|
|
});
|
|
|
|
test('SubagentStop checks subagent_name if agent_name missing', () => {
|
|
const input: HookInput = {
|
|
hook_event_name: 'SubagentStop',
|
|
cwd: '/test',
|
|
subagent_name: 'test-namespace:test-agent'
|
|
};
|
|
|
|
const hookConfig: HookConfig = {
|
|
enabled_agents: ['test-namespace:test-agent']
|
|
};
|
|
|
|
expect(shouldProcessHook(input, hookConfig)).toBe(true);
|
|
});
|
|
|
|
test('UserPromptSubmit always returns true', () => {
|
|
const input: HookInput = {
|
|
hook_event_name: 'UserPromptSubmit',
|
|
cwd: '/test'
|
|
};
|
|
|
|
const hookConfig: HookConfig = {};
|
|
|
|
expect(shouldProcessHook(input, hookConfig)).toBe(true);
|
|
});
|
|
|
|
test('No filtering config returns true', () => {
|
|
const input: HookInput = {
|
|
hook_event_name: 'PostToolUse',
|
|
cwd: '/test',
|
|
tool_name: 'Edit'
|
|
};
|
|
|
|
const hookConfig: HookConfig = {};
|
|
|
|
expect(shouldProcessHook(input, hookConfig)).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('Dispatcher - Gate Chaining', () => {
|
|
let testDir: string;
|
|
|
|
beforeEach(async () => {
|
|
// Create temporary directory for test config
|
|
testDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gates-test-'));
|
|
});
|
|
|
|
afterEach(async () => {
|
|
// Clean up
|
|
await fs.rm(testDir, { recursive: true, force: true });
|
|
});
|
|
|
|
test('gate chaining works - gate-a chains to gate-b on pass', async () => {
|
|
// Create gates.json with chaining config
|
|
const gatesConfig = {
|
|
hooks: {
|
|
PostToolUse: {
|
|
gates: ['gate-a']
|
|
}
|
|
},
|
|
gates: {
|
|
'gate-a': {
|
|
command: 'echo "gate-a passed"',
|
|
on_pass: 'gate-b' // Chain to gate-b on pass
|
|
},
|
|
'gate-b': {
|
|
command: 'echo "gate-b passed"',
|
|
on_pass: 'CONTINUE'
|
|
}
|
|
}
|
|
};
|
|
|
|
await fs.writeFile(path.join(testDir, 'gates.json'), JSON.stringify(gatesConfig, null, 2));
|
|
|
|
const input: HookInput = {
|
|
hook_event_name: 'PostToolUse',
|
|
cwd: testDir,
|
|
tool_name: 'Edit'
|
|
};
|
|
|
|
const result = await dispatch(input);
|
|
|
|
// Should contain output from both gates
|
|
expect(result.context).toContain('gate-a passed');
|
|
expect(result.context).toContain('gate-b passed');
|
|
expect(result.blockReason).toBeUndefined();
|
|
});
|
|
|
|
test('circular chain prevention - exceeds max gate depth', async () => {
|
|
// Create gates.json with circular chain
|
|
const gatesConfig = {
|
|
hooks: {
|
|
PostToolUse: {
|
|
gates: ['gate-a']
|
|
}
|
|
},
|
|
gates: {
|
|
'gate-a': {
|
|
command: 'echo "gate-a"',
|
|
on_pass: 'gate-b'
|
|
},
|
|
'gate-b': {
|
|
command: 'echo "gate-b"',
|
|
on_pass: 'gate-a' // Circular chain back to gate-a
|
|
}
|
|
}
|
|
};
|
|
|
|
await fs.writeFile(path.join(testDir, 'gates.json'), JSON.stringify(gatesConfig, null, 2));
|
|
|
|
const input: HookInput = {
|
|
hook_event_name: 'PostToolUse',
|
|
cwd: testDir,
|
|
tool_name: 'Edit'
|
|
};
|
|
|
|
const result = await dispatch(input);
|
|
|
|
// Should hit circuit breaker
|
|
expect(result.blockReason).toContain('Exceeded max gate chain depth');
|
|
expect(result.blockReason).toContain('circular');
|
|
});
|
|
});
|
|
|
|
describe('Keyword Matching', () => {
|
|
test('no keywords - gate always runs', () => {
|
|
const gateConfig: GateConfig = {
|
|
command: 'npm test'
|
|
};
|
|
|
|
expect(gateMatchesKeywords(gateConfig, 'hello world')).toBe(true);
|
|
expect(gateMatchesKeywords(gateConfig, undefined)).toBe(true);
|
|
expect(gateMatchesKeywords(gateConfig, '')).toBe(true);
|
|
});
|
|
|
|
test('empty keywords array - gate always runs', () => {
|
|
const gateConfig: GateConfig = {
|
|
command: 'npm test',
|
|
keywords: []
|
|
};
|
|
|
|
expect(gateMatchesKeywords(gateConfig, 'hello world')).toBe(true);
|
|
expect(gateMatchesKeywords(gateConfig, undefined)).toBe(true);
|
|
});
|
|
|
|
test('no user message with keywords - gate does not run', () => {
|
|
const gateConfig: GateConfig = {
|
|
command: 'npm test',
|
|
keywords: ['test', 'testing']
|
|
};
|
|
|
|
expect(gateMatchesKeywords(gateConfig, undefined)).toBe(false);
|
|
expect(gateMatchesKeywords(gateConfig, '')).toBe(false);
|
|
});
|
|
|
|
test('keyword match - case insensitive', () => {
|
|
const gateConfig: GateConfig = {
|
|
command: 'npm test',
|
|
keywords: ['test']
|
|
};
|
|
|
|
expect(gateMatchesKeywords(gateConfig, 'run the TEST')).toBe(true);
|
|
expect(gateMatchesKeywords(gateConfig, 'RUN THE Test')).toBe(true);
|
|
expect(gateMatchesKeywords(gateConfig, 'test this')).toBe(true);
|
|
});
|
|
|
|
test('multiple keywords - any matches', () => {
|
|
const gateConfig: GateConfig = {
|
|
command: 'npm test',
|
|
keywords: ['test', 'testing', 'spec', 'verify']
|
|
};
|
|
|
|
expect(gateMatchesKeywords(gateConfig, 'run the tests')).toBe(true);
|
|
expect(gateMatchesKeywords(gateConfig, 'verify this works')).toBe(true);
|
|
expect(gateMatchesKeywords(gateConfig, 'check the spec')).toBe(true);
|
|
expect(gateMatchesKeywords(gateConfig, 'we are testing')).toBe(true);
|
|
});
|
|
|
|
test('no keyword match - gate does not run', () => {
|
|
const gateConfig: GateConfig = {
|
|
command: 'npm test',
|
|
keywords: ['test', 'testing']
|
|
};
|
|
|
|
expect(gateMatchesKeywords(gateConfig, 'hello world')).toBe(false);
|
|
expect(gateMatchesKeywords(gateConfig, 'run the linter')).toBe(false);
|
|
});
|
|
|
|
test('substring matching - partial word matches', () => {
|
|
const gateConfig: GateConfig = {
|
|
command: 'npm test',
|
|
keywords: ['test']
|
|
};
|
|
|
|
// Intentional substring matching (not word-boundary)
|
|
expect(gateMatchesKeywords(gateConfig, 'latest version')).toBe(true);
|
|
expect(gateMatchesKeywords(gateConfig, 'contest results')).toBe(true);
|
|
expect(gateMatchesKeywords(gateConfig, 'testing')).toBe(true);
|
|
});
|
|
});
|