Initial commit
This commit is contained in:
57
hooks/hooks-app/__tests__/action-handler.test.ts
Normal file
57
hooks/hooks-app/__tests__/action-handler.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
33
hooks/hooks-app/__tests__/builtin-gates.test.ts
Normal file
33
hooks/hooks-app/__tests__/builtin-gates.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
226
hooks/hooks-app/__tests__/cli.integration.test.ts
Normal file
226
hooks/hooks-app/__tests__/cli.integration.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
250
hooks/hooks-app/__tests__/config.test.ts
Normal file
250
hooks/hooks-app/__tests__/config.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
69
hooks/hooks-app/__tests__/context.test.ts
Normal file
69
hooks/hooks-app/__tests__/context.test.ts
Normal 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'));
|
||||
});
|
||||
});
|
||||
263
hooks/hooks-app/__tests__/dispatcher.test.ts
Normal file
263
hooks/hooks-app/__tests__/dispatcher.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
178
hooks/hooks-app/__tests__/gate-loader.test.ts
Normal file
178
hooks/hooks-app/__tests__/gate-loader.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
164
hooks/hooks-app/__tests__/integration.test.ts
Normal file
164
hooks/hooks-app/__tests__/integration.test.ts
Normal 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');
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
239
hooks/hooks-app/__tests__/plugin-gates.integration.test.ts
Normal file
239
hooks/hooks-app/__tests__/plugin-gates.integration.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
198
hooks/hooks-app/__tests__/session.test.ts
Normal file
198
hooks/hooks-app/__tests__/session.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
63
hooks/hooks-app/__tests__/types.test.ts
Normal file
63
hooks/hooks-app/__tests__/types.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user