Initial commit

This commit is contained in:
Zhongwei Li
2025-11-30 09:02:16 +08:00
commit 6ae6ce0730
49 changed files with 6362 additions and 0 deletions

View File

@@ -0,0 +1,57 @@
// plugin/hooks/hooks-app/__tests__/action-handler.test.ts
import { handleAction } from '../src/action-handler';
import { GateResult, GatesConfig } from '../src/types';
const mockConfig: GatesConfig = {
hooks: {},
gates: {
'next-gate': { command: 'echo "next"', on_pass: 'CONTINUE' }
}
};
const mockInput = {
hook_event_name: 'PostToolUse',
cwd: '/test'
};
describe('Action Handler', () => {
test('CONTINUE returns continue=true', async () => {
const result: GateResult = {};
const action = await handleAction('CONTINUE', result, mockConfig, mockInput);
expect(action.continue).toBe(true);
expect(action.context).toBeUndefined();
});
test('CONTINUE with context returns context', async () => {
const result: GateResult = { additionalContext: 'test context' };
const action = await handleAction('CONTINUE', result, mockConfig, mockInput);
expect(action.continue).toBe(true);
expect(action.context).toBe('test context');
});
test('BLOCK returns continue=false', async () => {
const result: GateResult = { decision: 'block', reason: 'test reason' };
const action = await handleAction('BLOCK', result, mockConfig, mockInput);
expect(action.continue).toBe(false);
expect(action.blockReason).toBe('test reason');
});
test('BLOCK with no reason uses default', async () => {
const result: GateResult = {};
const action = await handleAction('BLOCK', result, mockConfig, mockInput);
expect(action.continue).toBe(false);
expect(action.blockReason).toBe('Gate failed');
});
test('STOP returns continue=false with stop message', async () => {
const result: GateResult = { message: 'stop message' };
const action = await handleAction('STOP', result, mockConfig, mockInput);
expect(action.continue).toBe(false);
expect(action.stopMessage).toBe('stop message');
});
});

View File

@@ -0,0 +1,33 @@
// plugin/hooks/hooks-app/__tests__/builtin-gates.test.ts
import { executeBuiltinGate } from '../src/gate-loader';
import { HookInput } from '../src/types';
import * as path from 'path';
// Set CLAUDE_PLUGIN_ROOT for tests to point to plugin directory
process.env.CLAUDE_PLUGIN_ROOT = path.resolve(__dirname, '../../..');
describe('Built-in Gates', () => {
describe('plugin-path', () => {
test('logs plugin path when available', async () => {
const input: HookInput = {
hook_event_name: 'SessionStart',
cwd: '/test'
};
const result = await executeBuiltinGate('plugin-path', input);
// plugin-path gate should always continue
expect(result.decision).toBeUndefined();
});
test('handles SubagentStop hook', async () => {
const input: HookInput = {
hook_event_name: 'SubagentStop',
cwd: '/test',
agent_name: 'test-agent'
};
const result = await executeBuiltinGate('plugin-path', input);
expect(result.decision).toBeUndefined();
});
});
});

View File

@@ -0,0 +1,226 @@
// __tests__/cli.integration.test.ts
import { spawn } from 'child_process';
import { promises as fs } from 'fs';
import * as path from 'path';
import * as os from 'os';
describe('CLI Integration', () => {
let testDir: string;
beforeEach(async () => {
// Create temp directory for each test
testDir = await fs.mkdtemp(path.join(os.tmpdir(), 'cli-test-'));
});
afterEach(async () => {
// Clean up
await fs.rm(testDir, { recursive: true, force: true });
});
describe('Session Management Mode', () => {
const runCLI = (
args: string[]
): Promise<{ stdout: string; stderr: string; exitCode: number }> => {
return new Promise((resolve, reject) => {
const proc = spawn('node', ['dist/cli.js', ...args], {
cwd: path.resolve(__dirname, '..')
});
let stdout = '';
let stderr = '';
proc.stdout.on('data', (data) => {
stdout += data.toString();
});
proc.stderr.on('data', (data) => {
stderr += data.toString();
});
proc.on('close', (code) => {
resolve({ stdout, stderr, exitCode: code ?? 0 });
});
proc.on('error', reject);
});
};
test('should set and get active_command', async () => {
// Set
const setResult = await runCLI(['session', 'set', 'active_command', '/execute', testDir]);
expect(setResult.exitCode).toBe(0);
// Get
const getResult = await runCLI(['session', 'get', 'active_command', testDir]);
expect(getResult.exitCode).toBe(0);
expect(getResult.stdout.trim()).toBe('/execute');
});
test('should set and get active_skill', async () => {
// Set
const setResult = await runCLI(['session', 'set', 'active_skill', 'brainstorming', testDir]);
expect(setResult.exitCode).toBe(0);
// Get
const getResult = await runCLI(['session', 'get', 'active_skill', testDir]);
expect(getResult.exitCode).toBe(0);
expect(getResult.stdout.trim()).toBe('brainstorming');
});
test('should append to edited_files', async () => {
// Append
const append1 = await runCLI(['session', 'append', 'edited_files', 'file1.ts', testDir]);
expect(append1.exitCode).toBe(0);
const append2 = await runCLI(['session', 'append', 'edited_files', 'file2.ts', testDir]);
expect(append2.exitCode).toBe(0);
// Check contains
const contains1 = await runCLI(['session', 'contains', 'edited_files', 'file1.ts', testDir]);
expect(contains1.exitCode).toBe(0);
const contains2 = await runCLI(['session', 'contains', 'edited_files', 'file2.ts', testDir]);
expect(contains2.exitCode).toBe(0);
const notContains = await runCLI([
'session',
'contains',
'edited_files',
'file3.ts',
testDir
]);
expect(notContains.exitCode).toBe(1);
});
test('should append to file_extensions', async () => {
// Append
const append1 = await runCLI(['session', 'append', 'file_extensions', 'ts', testDir]);
expect(append1.exitCode).toBe(0);
const append2 = await runCLI(['session', 'append', 'file_extensions', 'js', testDir]);
expect(append2.exitCode).toBe(0);
// Check contains
const contains1 = await runCLI(['session', 'contains', 'file_extensions', 'ts', testDir]);
expect(contains1.exitCode).toBe(0);
const notContains = await runCLI(['session', 'contains', 'file_extensions', 'py', testDir]);
expect(notContains.exitCode).toBe(1);
});
test('should clear session', async () => {
// Set some data
await runCLI(['session', 'set', 'active_command', '/execute', testDir]);
await runCLI(['session', 'append', 'edited_files', 'file1.ts', testDir]);
// Clear
const clearResult = await runCLI(['session', 'clear', testDir]);
expect(clearResult.exitCode).toBe(0);
// Verify cleared
const getResult = await runCLI(['session', 'get', 'active_command', testDir]);
expect(getResult.stdout.trim()).toBe('');
});
test('should reject invalid session keys', async () => {
const result = await runCLI(['session', 'get', 'invalid_key', testDir]);
expect(result.exitCode).toBe(1);
expect(result.stderr).toContain('Invalid session key: invalid_key');
});
test('should reject invalid array keys for append', async () => {
const result = await runCLI(['session', 'append', 'session_id', 'value', testDir]);
expect(result.exitCode).toBe(1);
expect(result.stderr).toContain('Invalid array key');
});
test('should reject setting non-settable keys', async () => {
const result = await runCLI(['session', 'set', 'session_id', 'value', testDir]);
expect(result.exitCode).toBe(1);
expect(result.stderr).toContain('Cannot set session_id');
});
test('should show usage for missing arguments', async () => {
const result = await runCLI(['session']);
expect(result.exitCode).toBe(1);
expect(result.stderr).toContain('Usage:');
});
test('should show error for unknown session command', async () => {
const result = await runCLI(['session', 'unknown', testDir]);
expect(result.exitCode).toBe(1);
expect(result.stderr).toContain('Unknown session command');
});
});
describe('Hook Dispatch Mode', () => {
test('should handle hook dispatch with valid JSON input', (done) => {
const proc = spawn('node', ['dist/cli.js'], {
cwd: path.resolve(__dirname, '..')
});
const input = JSON.stringify({
hook_event_name: 'PostToolUse',
cwd: testDir,
tool_name: 'Edit',
tool_input: {}
});
let stdout = '';
proc.stdout.on('data', (data) => {
stdout += data.toString();
});
proc.on('close', (code) => {
expect(code).toBe(0);
// Should produce empty output or valid JSON
if (stdout.trim()) {
expect(() => JSON.parse(stdout)).not.toThrow();
}
done();
});
proc.stdin.write(input);
proc.stdin.end();
});
test('should handle graceful exit on missing required fields', (done) => {
const proc = spawn('node', ['dist/cli.js'], {
cwd: path.resolve(__dirname, '..')
});
const input = JSON.stringify({
// Missing hook_event_name and cwd
tool_name: 'Edit'
});
proc.on('close', (code) => {
expect(code).toBe(0); // Graceful exit
done();
});
proc.stdin.write(input);
proc.stdin.end();
});
test('should handle invalid JSON input', (done) => {
const proc = spawn('node', ['dist/cli.js'], {
cwd: path.resolve(__dirname, '..')
});
let stderr = '';
proc.stderr.on('data', (data) => {
stderr += data.toString();
});
proc.on('close', (code) => {
expect(code).toBe(1);
expect(stderr).toContain('Invalid JSON input');
done();
});
proc.stdin.write('not valid json');
proc.stdin.end();
});
});
});

View File

@@ -0,0 +1,250 @@
// plugin/hooks/hooks-app/__tests__/config.test.ts
import { loadConfig, resolvePluginPath } from '../src/config';
import * as fs from 'fs/promises';
import * as path from 'path';
import * as os from 'os';
describe('Config Loading', () => {
let testDir: string;
beforeEach(async () => {
testDir = await fs.mkdtemp(path.join(os.tmpdir(), 'hooks-test-'));
});
afterEach(async () => {
await fs.rm(testDir, { recursive: true, force: true });
});
test('returns plugin defaults when no project config exists', async () => {
// Config loader now returns plugin defaults when no project config exists
// This provides fallback behavior without requiring every project to have gates.json
const config = await loadConfig(testDir);
expect(config).not.toBeNull();
// Verify it's actually plugin defaults by checking for expected structure
expect(config?.hooks).toBeDefined();
expect(config?.gates).toBeDefined();
});
test('loads .claude/gates.json with highest priority', async () => {
const claudeDir = path.join(testDir, '.claude');
await fs.mkdir(claudeDir);
const config1 = { hooks: {}, gates: { test: { command: 'claude-config' } } };
const config2 = { hooks: {}, gates: { test: { command: 'root-config' } } };
await fs.writeFile(path.join(claudeDir, 'gates.json'), JSON.stringify(config1));
await fs.writeFile(path.join(testDir, 'gates.json'), JSON.stringify(config2));
const config = await loadConfig(testDir);
expect(config?.gates.test.command).toBe('claude-config');
});
test('loads gates.json from root when .claude does not exist', async () => {
const config1 = { hooks: {}, gates: { test: { command: 'root-config' } } };
await fs.writeFile(path.join(testDir, 'gates.json'), JSON.stringify(config1));
const config = await loadConfig(testDir);
expect(config?.gates.test.command).toBe('root-config');
});
test('parses valid JSON config', async () => {
const configObj = {
hooks: {
PostToolUse: {
enabled_tools: ['Edit', 'Write'],
gates: ['format', 'test']
}
},
gates: {
format: { command: 'npm run format', on_pass: 'CONTINUE' },
test: { command: 'npm test', on_pass: 'CONTINUE' }
}
};
await fs.writeFile(path.join(testDir, 'gates.json'), JSON.stringify(configObj));
const config = await loadConfig(testDir);
expect(config?.hooks.PostToolUse.enabled_tools).toEqual(['Edit', 'Write']);
expect(config?.gates.format.command).toBe('npm run format');
});
test('rejects unknown hook event', async () => {
const configObj = {
hooks: {
UnknownEvent: { gates: [] }
},
gates: {}
};
await fs.writeFile(path.join(testDir, 'gates.json'), JSON.stringify(configObj));
await expect(loadConfig(testDir)).rejects.toThrow('Unknown hook event');
});
test('rejects undefined gate reference', async () => {
const configObj = {
hooks: {
PostToolUse: { gates: ['nonexistent'] }
},
gates: {}
};
await fs.writeFile(path.join(testDir, 'gates.json'), JSON.stringify(configObj));
await expect(loadConfig(testDir)).rejects.toThrow('references undefined gate');
});
test('rejects invalid action', async () => {
const configObj = {
hooks: {
PostToolUse: { gates: ['test'] }
},
gates: {
test: { command: 'echo test', on_pass: 'INVALID' }
}
};
await fs.writeFile(path.join(testDir, 'gates.json'), JSON.stringify(configObj));
await expect(loadConfig(testDir)).rejects.toThrow(
'is not CONTINUE/BLOCK/STOP or valid gate name'
);
});
});
describe('Plugin Path Resolution', () => {
test('resolves sibling plugin using CLAUDE_PLUGIN_ROOT', () => {
const originalEnv = process.env.CLAUDE_PLUGIN_ROOT;
process.env.CLAUDE_PLUGIN_ROOT = '/home/user/.claude/plugins/turboshovel';
try {
const result = resolvePluginPath('cipherpowers');
expect(result).toBe('/home/user/.claude/plugins/cipherpowers');
} finally {
process.env.CLAUDE_PLUGIN_ROOT = originalEnv;
}
});
test('throws when CLAUDE_PLUGIN_ROOT not set', () => {
const originalEnv = process.env.CLAUDE_PLUGIN_ROOT;
delete process.env.CLAUDE_PLUGIN_ROOT;
try {
expect(() => resolvePluginPath('cipherpowers')).toThrow(
'Cannot resolve plugin path: CLAUDE_PLUGIN_ROOT not set'
);
} finally {
process.env.CLAUDE_PLUGIN_ROOT = originalEnv;
}
});
test('rejects plugin names with path separators', () => {
const originalEnv = process.env.CLAUDE_PLUGIN_ROOT;
process.env.CLAUDE_PLUGIN_ROOT = '/home/user/.claude/plugins/turboshovel';
try {
expect(() => resolvePluginPath('../etc')).toThrow(
"Invalid plugin name: '../etc' (must not contain path separators)"
);
expect(() => resolvePluginPath('foo/bar')).toThrow(
"Invalid plugin name: 'foo/bar' (must not contain path separators)"
);
expect(() => resolvePluginPath('foo\\bar')).toThrow(
"Invalid plugin name: 'foo\\bar' (must not contain path separators)"
);
} finally {
process.env.CLAUDE_PLUGIN_ROOT = originalEnv;
}
});
test('rejects plugin names with parent directory references', () => {
const originalEnv = process.env.CLAUDE_PLUGIN_ROOT;
process.env.CLAUDE_PLUGIN_ROOT = '/home/user/.claude/plugins/turboshovel';
try {
expect(() => resolvePluginPath('..')).toThrow(
"Invalid plugin name: '..' (must not contain path separators)"
);
expect(() => resolvePluginPath('..foo')).toThrow(
"Invalid plugin name: '..foo' (must not contain path separators)"
);
} finally {
process.env.CLAUDE_PLUGIN_ROOT = originalEnv;
}
});
});
describe('Gate Config Validation', () => {
let testDir: string;
beforeEach(async () => {
testDir = await fs.mkdtemp(path.join(os.tmpdir(), 'hooks-test-'));
});
afterEach(async () => {
await fs.rm(testDir, { recursive: true, force: true });
});
test('rejects gate with plugin but no gate name', async () => {
const configObj = {
hooks: { PostToolUse: { gates: ['test'] } },
gates: {
test: { plugin: 'cipherpowers' } // Missing gate field
}
};
await fs.writeFile(path.join(testDir, 'gates.json'), JSON.stringify(configObj));
await expect(loadConfig(testDir)).rejects.toThrow(
"Gate 'test' has 'plugin' but missing 'gate' field"
);
});
test('rejects gate with gate name but no plugin', async () => {
const configObj = {
hooks: { PostToolUse: { gates: ['test'] } },
gates: {
test: { gate: 'plan-compliance' } // Missing plugin field
}
};
await fs.writeFile(path.join(testDir, 'gates.json'), JSON.stringify(configObj));
await expect(loadConfig(testDir)).rejects.toThrow(
"Gate 'test' has 'gate' but missing 'plugin' field"
);
});
test('rejects gate with both command and plugin', async () => {
const configObj = {
hooks: { PostToolUse: { gates: ['test'] } },
gates: {
test: {
plugin: 'cipherpowers',
gate: 'plan-compliance',
command: 'npm run lint' // Conflicting
}
}
};
await fs.writeFile(path.join(testDir, 'gates.json'), JSON.stringify(configObj));
await expect(loadConfig(testDir)).rejects.toThrow(
"Gate 'test' cannot have both 'command' and 'plugin/gate'"
);
});
test('accepts valid plugin gate reference', async () => {
const configObj = {
hooks: {},
gates: {
test: { plugin: 'cipherpowers', gate: 'plan-compliance' }
}
};
await fs.writeFile(path.join(testDir, 'gates.json'), JSON.stringify(configObj));
// Should not throw validation error for structure
// (May fail later when trying to resolve plugin, which is acceptable)
const config = await loadConfig(testDir);
expect(config).not.toBeNull();
expect(config?.gates.test.plugin).toBe('cipherpowers');
expect(config?.gates.test.gate).toBe('plan-compliance');
});
});

View File

@@ -0,0 +1,69 @@
// plugin/hooks/hooks-app/__tests__/context.test.ts
import { discoverContextFile } from '../src/context';
import * as fs from 'fs/promises';
import * as path from 'path';
import * as os from 'os';
describe('Context Injection', () => {
let testDir: string;
beforeEach(async () => {
testDir = await fs.mkdtemp(path.join(os.tmpdir(), 'hooks-test-'));
});
afterEach(async () => {
await fs.rm(testDir, { recursive: true, force: true });
});
test('returns null when no context file exists', async () => {
const result = await discoverContextFile(testDir, 'test-command', 'start');
expect(result).toBeNull();
});
test('discovers flat context file', async () => {
const contextDir = path.join(testDir, '.claude', 'context');
await fs.mkdir(contextDir, { recursive: true });
await fs.writeFile(path.join(contextDir, 'test-command-start.md'), 'content');
const result = await discoverContextFile(testDir, 'test-command', 'start');
expect(result).toBe(path.join(contextDir, 'test-command-start.md'));
});
test('discovers slash-command subdirectory', async () => {
const contextDir = path.join(testDir, '.claude', 'context', 'slash-command');
await fs.mkdir(contextDir, { recursive: true });
await fs.writeFile(path.join(contextDir, 'test-command-start.md'), 'content');
const result = await discoverContextFile(testDir, 'test-command', 'start');
expect(result).toBe(path.join(contextDir, 'test-command-start.md'));
});
test('discovers nested slash-command directory', async () => {
const contextDir = path.join(testDir, '.claude', 'context', 'slash-command', 'test-command');
await fs.mkdir(contextDir, { recursive: true });
await fs.writeFile(path.join(contextDir, 'start.md'), 'content');
const result = await discoverContextFile(testDir, 'test-command', 'start');
expect(result).toBe(path.join(contextDir, 'start.md'));
});
test('discovers skill context', async () => {
const contextDir = path.join(testDir, '.claude', 'context', 'skill');
await fs.mkdir(contextDir, { recursive: true });
await fs.writeFile(path.join(contextDir, 'test-skill-start.md'), 'content');
const result = await discoverContextFile(testDir, 'test-skill', 'start');
expect(result).toBe(path.join(contextDir, 'test-skill-start.md'));
});
test('follows priority order - flat wins', async () => {
const contextBase = path.join(testDir, '.claude', 'context');
await fs.mkdir(path.join(contextBase, 'slash-command'), { recursive: true });
await fs.writeFile(path.join(contextBase, 'test-command-start.md'), 'flat');
await fs.writeFile(path.join(contextBase, 'slash-command', 'test-command-start.md'), 'subdir');
const result = await discoverContextFile(testDir, 'test-command', 'start');
expect(result).toBe(path.join(contextBase, 'test-command-start.md'));
});
});

View File

@@ -0,0 +1,263 @@
// 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);
});
});

View File

@@ -0,0 +1,178 @@
// plugin/hooks/hooks-app/__tests__/gate-loader.test.ts
import { executeShellCommand, executeGate, loadPluginGate } from '../src/gate-loader';
import { GateConfig, HookInput } from '../src/types';
import * as os from 'os';
import * as fs from 'fs/promises';
import * as path from 'path';
describe('Gate Loader - Shell Commands', () => {
test('executes shell command and returns exit code', async () => {
const result = await executeShellCommand('echo "test"', process.cwd());
expect(result.exitCode).toBe(0);
expect(result.output).toContain('test');
});
test('captures non-zero exit code', async () => {
const result = await executeShellCommand('exit 1', process.cwd());
expect(result.exitCode).toBe(1);
});
test('captures stdout', async () => {
const result = await executeShellCommand('echo "hello world"', process.cwd());
expect(result.output).toContain('hello world');
});
test('captures stderr', async () => {
const result = await executeShellCommand('echo "error" >&2', process.cwd());
expect(result.output).toContain('error');
});
test('executes in specified directory', async () => {
const tmpDir = os.tmpdir();
const result = await executeShellCommand('pwd', tmpDir);
// macOS may prepend /private to paths
expect(result.output.trim()).toMatch(new RegExp(tmpDir.replace('/var/', '(/private)?/var/')));
});
test('timeout returns exit code 124 and timeout message', async () => {
const result = await executeShellCommand('sleep 1', process.cwd(), 100);
expect(result.exitCode).toBe(124);
expect(result.output).toContain('timed out');
});
});
describe('Gate Loader - executeGate', () => {
const mockInput: HookInput = {
hook_event_name: 'PostToolUse',
cwd: process.cwd()
};
test('shell command gate with exit 0 returns passed=true', async () => {
const gateConfig: GateConfig = {
command: 'echo "success"'
};
const result = await executeGate('test-gate', gateConfig, mockInput);
expect(result.passed).toBe(true);
expect(result.result.additionalContext).toContain('success');
});
test('shell command gate with exit 1 returns passed=false', async () => {
const gateConfig: GateConfig = {
command: 'exit 1'
};
const result = await executeGate('test-gate', gateConfig, mockInput);
expect(result.passed).toBe(false);
});
test('built-in gate throws error when gate not found', async () => {
const gateConfig: GateConfig = {
// No command = built-in gate
};
await expect(executeGate('nonexistent-gate', gateConfig, mockInput)).rejects.toThrow(
'Failed to load built-in gate nonexistent-gate'
);
});
});
describe('Plugin Gate Loading', () => {
let mockPluginDir: string;
let originalEnv: string | undefined;
beforeEach(async () => {
// Create mock plugin directory structure
mockPluginDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mock-plugins-'));
const cipherpowersDir = path.join(mockPluginDir, 'cipherpowers', 'hooks');
await fs.mkdir(cipherpowersDir, { recursive: true });
// Create mock gates.json for cipherpowers
const gatesConfig = {
hooks: {},
gates: {
'plan-compliance': {
command: 'node dist/gates/plan-compliance.js',
on_fail: 'BLOCK'
}
}
};
await fs.writeFile(
path.join(cipherpowersDir, 'gates.json'),
JSON.stringify(gatesConfig)
);
// Set CLAUDE_PLUGIN_ROOT to point to turboshovel sibling
originalEnv = process.env.CLAUDE_PLUGIN_ROOT;
process.env.CLAUDE_PLUGIN_ROOT = path.join(mockPluginDir, 'turboshovel');
});
afterEach(async () => {
process.env.CLAUDE_PLUGIN_ROOT = originalEnv;
await fs.rm(mockPluginDir, { recursive: true, force: true });
});
test('loads gate config from plugin', async () => {
const result = await loadPluginGate('cipherpowers', 'plan-compliance');
expect(result.gateConfig.command).toBe('node dist/gates/plan-compliance.js');
expect(result.gateConfig.on_fail).toBe('BLOCK');
expect(result.pluginRoot).toBe(path.join(mockPluginDir, 'cipherpowers'));
});
test('throws when plugin gates.json not found', async () => {
await expect(loadPluginGate('nonexistent', 'some-gate')).rejects.toThrow(
"Cannot find gates.json for plugin 'nonexistent'"
);
});
test('throws when gate not found in plugin', async () => {
await expect(loadPluginGate('cipherpowers', 'nonexistent-gate')).rejects.toThrow(
"Gate 'nonexistent-gate' not found in plugin 'cipherpowers'"
);
});
test('validates loaded plugin config structure', async () => {
// Create plugin with malformed gates.json
const malformedDir = path.join(mockPluginDir, 'malformed', 'hooks');
await fs.mkdir(malformedDir, { recursive: true });
await fs.writeFile(
path.join(malformedDir, 'gates.json'),
JSON.stringify({
hooks: {},
gates: {
'bad-gate': {
// Missing required fields (no command, plugin, or gate)
}
}
})
);
// This should succeed loading but the gate config is invalid
// Validation happens when the gate is used, not when loading
const result = await loadPluginGate('malformed', 'bad-gate');
expect(result.gateConfig).toBeDefined();
});
test('executeGate handles plugin gate reference', async () => {
const gateConfig: GateConfig = {
plugin: 'cipherpowers',
gate: 'plan-compliance'
};
const mockInput: HookInput = {
hook_event_name: 'SubagentStop',
cwd: '/some/project'
};
// The command from cipherpowers will be executed in cipherpowers plugin dir
// For this test, the mock plugin has 'node dist/gates/plan-compliance.js'
// which won't exist, so it will fail - but we can verify the flow
const result = await executeGate('my-gate', gateConfig, mockInput);
// Command execution will fail (file doesn't exist) but flow is correct
expect(result.passed).toBe(false);
});
});

View File

@@ -0,0 +1,164 @@
import { exec } from 'child_process';
import { promisify } from 'util';
import { join, dirname } from 'path';
import { promises as fs } from 'fs';
import { tmpdir } from 'os';
const execAsync = promisify(exec);
describe('Integration Tests', () => {
let testDir: string;
let cliPath: string;
beforeEach(async () => {
testDir = join(tmpdir(), `integration-test-${Date.now()}`);
await fs.mkdir(testDir, { recursive: true });
cliPath = join(__dirname, '../dist/cli.js');
});
afterEach(async () => {
await fs.rm(testDir, { recursive: true, force: true });
});
describe('Session Management', () => {
test('set and get command', async () => {
await execAsync(`node ${cliPath} session set active_command /execute ${testDir}`);
const { stdout } = await execAsync(`node ${cliPath} session get active_command ${testDir}`);
expect(stdout.trim()).toBe('/execute');
});
test('append and check contains', async () => {
await execAsync(`node ${cliPath} session append file_extensions ts ${testDir}`);
const result = await execAsync(
`node ${cliPath} session contains file_extensions ts ${testDir}`
)
.then(() => true)
.catch(() => false);
expect(result).toBe(true);
});
test('clear removes state', async () => {
await execAsync(`node ${cliPath} session set active_command /plan ${testDir}`);
await execAsync(`node ${cliPath} session clear ${testDir}`);
const { stdout } = await execAsync(`node ${cliPath} session get active_command ${testDir}`);
expect(stdout.trim()).toBe('');
});
});
describe('Hook Dispatch with Session Tracking', () => {
test('PostToolUse updates session', async () => {
const hookInput = JSON.stringify({
hook_event_name: 'PostToolUse',
tool_name: 'Edit',
file_path: 'main.ts',
cwd: testDir
});
await execAsync(`echo '${hookInput}' | node ${cliPath}`);
const { stdout: files } = await execAsync(
`node ${cliPath} session get edited_files ${testDir}`
);
expect(files).toContain('main.ts');
const containsTs = await execAsync(
`node ${cliPath} session contains file_extensions ts ${testDir}`
)
.then(() => true)
.catch(() => false);
expect(containsTs).toBe(true);
});
test('SlashCommandStart/End updates session', async () => {
// Start command
const startInput = JSON.stringify({
hook_event_name: 'SlashCommandStart',
command: '/execute',
cwd: testDir
});
await execAsync(`echo '${startInput}' | node ${cliPath}`);
const { stdout: activeCmd } = await execAsync(
`node ${cliPath} session get active_command ${testDir}`
);
expect(activeCmd.trim()).toBe('/execute');
// End command
const endInput = JSON.stringify({
hook_event_name: 'SlashCommandEnd',
command: '/execute',
cwd: testDir
});
await execAsync(`echo '${endInput}' | node ${cliPath}`);
const { stdout: cleared } = await execAsync(
`node ${cliPath} session get active_command ${testDir}`
);
expect(cleared.trim()).toBe('');
});
test('SkillStart/End updates session', async () => {
// Start skill
const startInput = JSON.stringify({
hook_event_name: 'SkillStart',
skill: 'executing-plans',
cwd: testDir
});
await execAsync(`echo '${startInput}' | node ${cliPath}`);
const { stdout: activeSkill } = await execAsync(
`node ${cliPath} session get active_skill ${testDir}`
);
expect(activeSkill.trim()).toBe('executing-plans');
// End skill
const endInput = JSON.stringify({
hook_event_name: 'SkillEnd',
skill: 'executing-plans',
cwd: testDir
});
await execAsync(`echo '${endInput}' | node ${cliPath}`);
const { stdout: cleared } = await execAsync(
`node ${cliPath} session get active_skill ${testDir}`
);
expect(cleared.trim()).toBe('');
});
});
describe('Error Handling', () => {
test('handles corrupted state file gracefully', async () => {
const stateFile = join(testDir, '.claude', 'session', 'state.json');
await fs.mkdir(dirname(stateFile), { recursive: true });
await fs.writeFile(stateFile, '{invalid json', 'utf-8');
// Should reinitialize and work
await execAsync(`node ${cliPath} session set active_command /plan ${testDir}`);
const { stdout } = await execAsync(`node ${cliPath} session get active_command ${testDir}`);
expect(stdout.trim()).toBe('/plan');
});
test('rejects invalid session keys', async () => {
try {
await execAsync(`node ${cliPath} session get invalid_key ${testDir}`);
fail('Should have thrown error');
} catch (error) {
const err = error as { stderr?: string };
expect(err.stderr).toContain('Invalid session key');
}
});
test('rejects invalid array keys for append', async () => {
try {
await execAsync(`node ${cliPath} session append invalid_key value ${testDir}`);
fail('Should have thrown error');
} catch (error) {
const err = error as { stderr?: string };
expect(err.stderr).toContain('Invalid array key');
}
});
});
});

View File

@@ -0,0 +1,239 @@
// plugin/hooks/hooks-app/__tests__/plugin-gates.integration.test.ts
import { dispatch } from '../src/dispatcher';
import { HookInput } from '../src/types';
import * as fs from 'fs/promises';
import * as path from 'path';
import * as os from 'os';
describe('Plugin Gate Composition Integration', () => {
let mockPluginsDir: string;
let projectDir: string;
let originalEnv: string | undefined;
beforeEach(async () => {
// Create mock plugins directory with two plugins
mockPluginsDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mock-plugins-'));
// Create mock cipherpowers plugin
const cipherpowersHooksDir = path.join(mockPluginsDir, 'cipherpowers', 'hooks');
await fs.mkdir(cipherpowersHooksDir, { recursive: true });
await fs.writeFile(
path.join(cipherpowersHooksDir, 'gates.json'),
JSON.stringify({
hooks: {},
gates: {
'plan-compliance': {
command: 'echo "plan-compliance check passed"',
on_fail: 'BLOCK'
}
}
})
);
// Create mock turboshovel plugin (current plugin)
const turboshovelHooksDir = path.join(mockPluginsDir, 'turboshovel', 'hooks');
await fs.mkdir(turboshovelHooksDir, { recursive: true });
await fs.writeFile(
path.join(turboshovelHooksDir, 'gates.json'),
JSON.stringify({ hooks: {}, gates: {} })
);
// Create test project directory
projectDir = await fs.mkdtemp(path.join(os.tmpdir(), 'test-project-'));
const claudeDir = path.join(projectDir, '.claude');
await fs.mkdir(claudeDir);
// Project config references cipherpowers gate
await fs.writeFile(
path.join(claudeDir, 'gates.json'),
JSON.stringify({
hooks: {
SubagentStop: {
gates: ['plan-compliance', 'check']
}
},
gates: {
'plan-compliance': {
plugin: 'cipherpowers',
gate: 'plan-compliance'
},
'check': {
command: 'echo "project check passed"'
}
}
})
);
// Set CLAUDE_PLUGIN_ROOT
originalEnv = process.env.CLAUDE_PLUGIN_ROOT;
process.env.CLAUDE_PLUGIN_ROOT = path.join(mockPluginsDir, 'turboshovel');
});
afterEach(async () => {
process.env.CLAUDE_PLUGIN_ROOT = originalEnv;
await fs.rm(mockPluginsDir, { recursive: true, force: true });
await fs.rm(projectDir, { recursive: true, force: true });
});
test('executes plugin gate followed by project gate', async () => {
const input: HookInput = {
hook_event_name: 'SubagentStop',
cwd: projectDir,
agent_name: 'test-agent'
};
const result = await dispatch(input);
// Both gates should pass (no blockReason or stopMessage)
expect(result.blockReason).toBeUndefined();
expect(result.stopMessage).toBeUndefined();
// Should have output from both gates
expect(result.context).toContain('plan-compliance check passed');
expect(result.context).toContain('project check passed');
});
test('plugin gate BLOCK stops execution', async () => {
// Update cipherpowers gate to fail
const cipherpowersHooksDir = path.join(mockPluginsDir, 'cipherpowers', 'hooks');
await fs.writeFile(
path.join(cipherpowersHooksDir, 'gates.json'),
JSON.stringify({
hooks: {},
gates: {
'plan-compliance': {
command: 'exit 1',
on_fail: 'BLOCK'
}
}
})
);
const input: HookInput = {
hook_event_name: 'SubagentStop',
cwd: projectDir,
agent_name: 'test-agent'
};
const result = await dispatch(input);
// Should be blocked (blockReason will be set)
expect(result.blockReason).toBeDefined();
});
test('prevents circular gate references', async () => {
// Create circular reference: pluginA -> pluginB -> pluginA
const pluginADir = path.join(mockPluginsDir, 'pluginA', 'hooks');
const pluginBDir = path.join(mockPluginsDir, 'pluginB', 'hooks');
await fs.mkdir(pluginADir, { recursive: true });
await fs.mkdir(pluginBDir, { recursive: true });
// PluginA has gate that references pluginB
await fs.writeFile(
path.join(pluginADir, 'gates.json'),
JSON.stringify({
hooks: {},
gates: {
'gateA': {
plugin: 'pluginB',
gate: 'gateB'
}
}
})
);
// PluginB has gate that references pluginA (circular)
await fs.writeFile(
path.join(pluginBDir, 'gates.json'),
JSON.stringify({
hooks: {},
gates: {
'gateB': {
plugin: 'pluginA',
gate: 'gateA'
}
}
})
);
// Project config references pluginA gate
const claudeDir = path.join(projectDir, '.claude');
await fs.writeFile(
path.join(claudeDir, 'gates.json'),
JSON.stringify({
hooks: {
SubagentStop: {
gates: ['test-circular']
}
},
gates: {
'test-circular': {
plugin: 'pluginA',
gate: 'gateA'
}
}
})
);
const input: HookInput = {
hook_event_name: 'SubagentStop',
cwd: projectDir,
agent_name: 'test-agent'
};
// Should error or handle gracefully (not infinite loop)
// Implementation decision: error on circular reference
await expect(dispatch(input)).rejects.toThrow(/circular|depth|recursion/i);
});
test('handles plugin self-reference', async () => {
// Plugin references its own gate
const selfRefDir = path.join(mockPluginsDir, 'selfref', 'hooks');
await fs.mkdir(selfRefDir, { recursive: true });
await fs.writeFile(
path.join(selfRefDir, 'gates.json'),
JSON.stringify({
hooks: {},
gates: {
'gate1': {
command: 'echo "gate1"'
},
'gate2': {
plugin: 'selfref',
gate: 'gate1'
}
}
})
);
// Project references the self-referencing gate
const claudeDir = path.join(projectDir, '.claude');
await fs.writeFile(
path.join(claudeDir, 'gates.json'),
JSON.stringify({
hooks: {
SubagentStop: {
gates: ['test-self']
}
},
gates: {
'test-self': {
plugin: 'selfref',
gate: 'gate2'
}
}
})
);
const input: HookInput = {
hook_event_name: 'SubagentStop',
cwd: projectDir,
agent_name: 'test-agent'
};
// Should work - self-reference to a different gate is valid
const result = await dispatch(input);
expect(result.blockReason).toBeUndefined();
expect(result.context).toContain('gate1');
});
});

View File

@@ -0,0 +1,198 @@
import { Session } from '../src/session';
import { promises as fs } from 'fs';
import { join, dirname } from 'path';
import { tmpdir } from 'os';
describe('Session', () => {
let testDir: string;
beforeEach(async () => {
testDir = join(tmpdir(), `session-test-${Date.now()}`);
await fs.mkdir(testDir, { recursive: true });
});
afterEach(async () => {
await fs.rm(testDir, { recursive: true, force: true });
});
describe('constructor', () => {
test('sets state file path', () => {
const session = new Session(testDir);
expect(session['stateFile']).toBe(join(testDir, '.claude', 'session', 'state.json'));
});
});
describe('get/set', () => {
test('set and get scalar value', async () => {
const session = new Session(testDir);
await session.set('active_command', '/execute');
const value = await session.get('active_command');
expect(value).toBe('/execute');
});
test('get returns null for unset values', async () => {
const session = new Session(testDir);
const value = await session.get('active_skill');
expect(value).toBeNull();
});
test('set multiple values independently', async () => {
const session = new Session(testDir);
await session.set('active_command', '/execute');
await session.set('active_skill', 'executing-plans');
expect(await session.get('active_command')).toBe('/execute');
expect(await session.get('active_skill')).toBe('executing-plans');
});
});
describe('append/contains', () => {
test('append adds value to array', async () => {
const session = new Session(testDir);
await session.append('edited_files', 'main.ts');
await session.append('edited_files', 'lib.ts');
const files = await session.get('edited_files');
expect(files).toEqual(['main.ts', 'lib.ts']);
});
test('append deduplicates values', async () => {
const session = new Session(testDir);
await session.append('edited_files', 'main.ts');
await session.append('edited_files', 'lib.ts');
await session.append('edited_files', 'main.ts'); // Duplicate
const files = await session.get('edited_files');
expect(files).toEqual(['main.ts', 'lib.ts']);
});
test('contains returns true for existing value', async () => {
const session = new Session(testDir);
await session.append('file_extensions', 'ts');
await session.append('file_extensions', 'js');
expect(await session.contains('file_extensions', 'ts')).toBe(true);
expect(await session.contains('file_extensions', 'js')).toBe(true);
});
test('contains returns false for missing value', async () => {
const session = new Session(testDir);
await session.append('file_extensions', 'ts');
expect(await session.contains('file_extensions', 'rs')).toBe(false);
});
});
describe('clear', () => {
test('removes state file', async () => {
const session = new Session(testDir);
await session.set('active_command', '/execute');
const stateFile = join(testDir, '.claude', 'session', 'state.json');
const exists = await fs
.access(stateFile)
.then(() => true)
.catch(() => false);
expect(exists).toBe(true);
await session.clear();
const existsAfter = await fs
.access(stateFile)
.then(() => true)
.catch(() => false);
expect(existsAfter).toBe(false);
});
test('is safe when file does not exist', async () => {
const session = new Session(testDir);
await expect(session.clear()).resolves.not.toThrow();
});
});
describe('persistence', () => {
test('state persists across Session instances', async () => {
const session1 = new Session(testDir);
await session1.set('active_command', '/plan');
await session1.append('edited_files', 'main.ts');
const session2 = new Session(testDir);
expect(await session2.get('active_command')).toBe('/plan');
expect(await session2.get('edited_files')).toEqual(['main.ts']);
});
});
describe('atomic writes', () => {
test('uses atomic rename', async () => {
const session = new Session(testDir);
await session.set('active_command', '/execute');
const stateFile = join(testDir, '.claude', 'session', 'state.json');
const tempFile = stateFile + '.tmp';
// Temp file should not exist after save completes
const tempExists = await fs
.access(tempFile)
.then(() => true)
.catch(() => false);
expect(tempExists).toBe(false);
// State file should exist
const stateExists = await fs
.access(stateFile)
.then(() => true)
.catch(() => false);
expect(stateExists).toBe(true);
});
});
describe('error scenarios', () => {
test('handles corrupted JSON gracefully', async () => {
const session = new Session(testDir);
const stateFile = join(testDir, '.claude', 'session', 'state.json');
// Create directory and write corrupted JSON
await fs.mkdir(dirname(stateFile), { recursive: true });
await fs.writeFile(stateFile, '{invalid json', 'utf-8');
// Should reinitialize state on corruption
const value = await session.get('active_command');
expect(value).toBeNull();
});
test('handles cross-process persistence', async () => {
// Simulate separate process invocations
const session1 = new Session(testDir);
await session1.set('active_command', '/execute');
await session1.append('edited_files', 'main.ts');
// Create new session instance (simulates new process)
const session2 = new Session(testDir);
expect(await session2.get('active_command')).toBe('/execute');
expect(await session2.get('edited_files')).toEqual(['main.ts']);
});
test('handles concurrent writes via atomic rename', async () => {
const session = new Session(testDir);
// Rapid concurrent writes (atomic rename prevents corruption)
// Note: Some writes may fail due to temp file conflicts, but state file
// should never be corrupted (that's what atomic rename protects against)
const results = await Promise.allSettled([
session.append('edited_files', 'file1.ts'),
session.append('edited_files', 'file2.ts'),
session.append('edited_files', 'file3.ts')
]);
// At least one operation should succeed
const successCount = results.filter((r) => r.status === 'fulfilled').length;
expect(successCount).toBeGreaterThan(0);
// State file should be valid (not corrupted)
const files = await session.get('edited_files');
expect(Array.isArray(files)).toBe(true);
expect(files.length).toBeGreaterThan(0);
});
});
});

View File

@@ -0,0 +1,63 @@
// plugin/hooks/hooks-app/__tests__/types.test.ts
import { HookInput, GateResult, GateConfig } from '../src/types';
describe('Types', () => {
test('HookInput has required fields', () => {
const input: HookInput = {
hook_event_name: 'PostToolUse',
cwd: '/test/path'
};
expect(input.hook_event_name).toBe('PostToolUse');
expect(input.cwd).toBe('/test/path');
});
test('HookInput accepts optional PostToolUse fields', () => {
const input: HookInput = {
hook_event_name: 'PostToolUse',
cwd: '/test/path',
tool_name: 'Edit',
file_path: '/test/file.ts'
};
expect(input.tool_name).toBe('Edit');
expect(input.file_path).toBe('/test/file.ts');
});
test('GateResult can be empty object', () => {
const result: GateResult = {};
expect(result).toBeDefined();
});
test('GateResult can have additionalContext', () => {
const result: GateResult = {
additionalContext: 'Test context'
};
expect(result.additionalContext).toBe('Test context');
});
test('GateResult can have block decision', () => {
const result: GateResult = {
decision: 'block',
reason: 'Test reason'
};
expect(result.decision).toBe('block');
expect(result.reason).toBe('Test reason');
});
});
describe('GateConfig Type', () => {
test('accepts plugin gate reference', () => {
const config: GateConfig = {
plugin: 'cipherpowers',
gate: 'plan-compliance'
};
expect(config.plugin).toBe('cipherpowers');
expect(config.gate).toBe('plan-compliance');
});
test('accepts local command gate', () => {
const config: GateConfig = {
command: 'npm run lint'
};
expect(config.command).toBe('npm run lint');
});
});