Files
2025-11-30 09:02:16 +08:00

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);
});
});