mirror of
https://github.com/affaan-m/everything-claude-code.git
synced 2026-02-16 19:33:11 +08:00
Windows pipes raise EOF instead of EPIPE when the child process exits before stdin finishes flushing. Added EOF to the ignored error codes in runHookWithInput.
458 lines
14 KiB
JavaScript
458 lines
14 KiB
JavaScript
/**
|
|
* Integration tests for hook scripts
|
|
*
|
|
* Tests hook behavior in realistic scenarios with proper input/output handling.
|
|
*
|
|
* Run with: node tests/integration/hooks.test.js
|
|
*/
|
|
|
|
const assert = require('assert');
|
|
const path = require('path');
|
|
const fs = require('fs');
|
|
const os = require('os');
|
|
const { spawn } = require('child_process');
|
|
|
|
// Test helper
|
|
function _test(name, fn) {
|
|
try {
|
|
fn();
|
|
console.log(` ✓ ${name}`);
|
|
return true;
|
|
} catch (err) {
|
|
console.log(` ✗ ${name}`);
|
|
console.log(` Error: ${err.message}`);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// Async test helper
|
|
async function asyncTest(name, fn) {
|
|
try {
|
|
await fn();
|
|
console.log(` ✓ ${name}`);
|
|
return true;
|
|
} catch (err) {
|
|
console.log(` ✗ ${name}`);
|
|
console.log(` Error: ${err.message}`);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Run a hook script with simulated Claude Code input
|
|
* @param {string} scriptPath - Path to the hook script
|
|
* @param {object} input - Hook input object (will be JSON stringified)
|
|
* @param {object} env - Environment variables
|
|
* @returns {Promise<{code: number, stdout: string, stderr: string}>}
|
|
*/
|
|
function runHookWithInput(scriptPath, input = {}, env = {}, timeoutMs = 10000) {
|
|
return new Promise((resolve, reject) => {
|
|
const proc = spawn('node', [scriptPath], {
|
|
env: { ...process.env, ...env },
|
|
stdio: ['pipe', 'pipe', 'pipe']
|
|
});
|
|
|
|
let stdout = '';
|
|
let stderr = '';
|
|
|
|
proc.stdout.on('data', data => stdout += data);
|
|
proc.stderr.on('data', data => stderr += data);
|
|
|
|
// Ignore EPIPE/EOF errors (process may exit before we finish writing)
|
|
// Windows uses EOF instead of EPIPE for closed pipe writes
|
|
proc.stdin.on('error', (err) => {
|
|
if (err.code !== 'EPIPE' && err.code !== 'EOF') {
|
|
reject(err);
|
|
}
|
|
});
|
|
|
|
// Send JSON input on stdin (simulating Claude Code hook invocation)
|
|
if (input && Object.keys(input).length > 0) {
|
|
proc.stdin.write(JSON.stringify(input));
|
|
}
|
|
proc.stdin.end();
|
|
|
|
const timer = setTimeout(() => {
|
|
proc.kill('SIGKILL');
|
|
reject(new Error(`Hook timed out after ${timeoutMs}ms`));
|
|
}, timeoutMs);
|
|
|
|
proc.on('close', code => {
|
|
clearTimeout(timer);
|
|
resolve({ code, stdout, stderr });
|
|
});
|
|
|
|
proc.on('error', err => {
|
|
clearTimeout(timer);
|
|
reject(err);
|
|
});
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Run an inline hook command (like those in hooks.json)
|
|
* @param {string} command - The node -e "..." command
|
|
* @param {object} input - Hook input object
|
|
* @param {object} env - Environment variables
|
|
*/
|
|
function _runInlineHook(command, input = {}, env = {}, timeoutMs = 10000) {
|
|
return new Promise((resolve, reject) => {
|
|
// Extract the code from node -e "..."
|
|
const match = command.match(/^node -e "(.+)"$/s);
|
|
if (!match) {
|
|
reject(new Error('Invalid inline hook command format'));
|
|
return;
|
|
}
|
|
|
|
const proc = spawn('node', ['-e', match[1]], {
|
|
env: { ...process.env, ...env },
|
|
stdio: ['pipe', 'pipe', 'pipe']
|
|
});
|
|
|
|
let stdout = '';
|
|
let stderr = '';
|
|
let timer;
|
|
|
|
proc.stdout.on('data', data => stdout += data);
|
|
proc.stderr.on('data', data => stderr += data);
|
|
|
|
// Ignore EPIPE errors (process may exit before we finish writing)
|
|
proc.stdin.on('error', (err) => {
|
|
if (err.code !== 'EPIPE') {
|
|
if (timer) clearTimeout(timer);
|
|
reject(err);
|
|
}
|
|
});
|
|
|
|
if (input && Object.keys(input).length > 0) {
|
|
proc.stdin.write(JSON.stringify(input));
|
|
}
|
|
proc.stdin.end();
|
|
|
|
timer = setTimeout(() => {
|
|
proc.kill('SIGKILL');
|
|
reject(new Error(`Inline hook timed out after ${timeoutMs}ms`));
|
|
}, timeoutMs);
|
|
|
|
proc.on('close', code => {
|
|
clearTimeout(timer);
|
|
resolve({ code, stdout, stderr });
|
|
});
|
|
|
|
proc.on('error', err => {
|
|
clearTimeout(timer);
|
|
reject(err);
|
|
});
|
|
});
|
|
}
|
|
|
|
// Create a temporary test directory
|
|
function createTestDir() {
|
|
return fs.mkdtempSync(path.join(os.tmpdir(), 'hook-integration-test-'));
|
|
}
|
|
|
|
// Clean up test directory
|
|
function cleanupTestDir(testDir) {
|
|
fs.rmSync(testDir, { recursive: true, force: true });
|
|
}
|
|
|
|
// Test suite
|
|
async function runTests() {
|
|
console.log('\n=== Hook Integration Tests ===\n');
|
|
|
|
let passed = 0;
|
|
let failed = 0;
|
|
|
|
const scriptsDir = path.join(__dirname, '..', '..', 'scripts', 'hooks');
|
|
const hooksJsonPath = path.join(__dirname, '..', '..', 'hooks', 'hooks.json');
|
|
const hooks = JSON.parse(fs.readFileSync(hooksJsonPath, 'utf8'));
|
|
|
|
// ==========================================
|
|
// Input Format Tests
|
|
// ==========================================
|
|
console.log('Hook Input Format Handling:');
|
|
|
|
if (await asyncTest('hooks handle empty stdin gracefully', async () => {
|
|
const result = await runHookWithInput(path.join(scriptsDir, 'session-start.js'), {});
|
|
assert.strictEqual(result.code, 0, `Should exit 0, got ${result.code}`);
|
|
})) passed++; else failed++;
|
|
|
|
if (await asyncTest('hooks handle malformed JSON input', async () => {
|
|
const proc = spawn('node', [path.join(scriptsDir, 'session-start.js')], {
|
|
stdio: ['pipe', 'pipe', 'pipe']
|
|
});
|
|
|
|
let code = null;
|
|
proc.stdin.write('{ invalid json }');
|
|
proc.stdin.end();
|
|
|
|
await new Promise((resolve) => {
|
|
proc.on('close', (c) => {
|
|
code = c;
|
|
resolve();
|
|
});
|
|
});
|
|
|
|
// Hook should not crash on malformed input (exit 0)
|
|
assert.strictEqual(code, 0, 'Should handle malformed JSON gracefully');
|
|
})) passed++; else failed++;
|
|
|
|
if (await asyncTest('hooks parse valid tool_input correctly', async () => {
|
|
// Test the console.log warning hook with valid input
|
|
const command = 'node -e "const fs=require(\'fs\');let d=\'\';process.stdin.on(\'data\',c=>d+=c);process.stdin.on(\'end\',()=>{const i=JSON.parse(d);const p=i.tool_input?.file_path||\'\';console.log(\'Path:\',p)})"';
|
|
const match = command.match(/^node -e "(.+)"$/s);
|
|
|
|
const proc = spawn('node', ['-e', match[1]], {
|
|
stdio: ['pipe', 'pipe', 'pipe']
|
|
});
|
|
|
|
let stdout = '';
|
|
proc.stdout.on('data', data => stdout += data);
|
|
|
|
proc.stdin.write(JSON.stringify({
|
|
tool_input: { file_path: '/test/path.js' }
|
|
}));
|
|
proc.stdin.end();
|
|
|
|
await new Promise(resolve => proc.on('close', resolve));
|
|
|
|
assert.ok(stdout.includes('/test/path.js'), 'Should extract file_path from input');
|
|
})) passed++; else failed++;
|
|
|
|
// ==========================================
|
|
// Output Format Tests
|
|
// ==========================================
|
|
console.log('\nHook Output Format:');
|
|
|
|
if (await asyncTest('hooks output messages to stderr (not stdout)', async () => {
|
|
const result = await runHookWithInput(path.join(scriptsDir, 'session-start.js'), {});
|
|
// Session-start should write info to stderr
|
|
assert.ok(result.stderr.length > 0, 'Should have stderr output');
|
|
assert.ok(result.stderr.includes('[SessionStart]'), 'Should have [SessionStart] prefix');
|
|
})) passed++; else failed++;
|
|
|
|
if (await asyncTest('PreCompact hook logs to stderr', async () => {
|
|
const result = await runHookWithInput(path.join(scriptsDir, 'pre-compact.js'), {});
|
|
assert.ok(result.stderr.includes('[PreCompact]'), 'Should output to stderr with prefix');
|
|
})) passed++; else failed++;
|
|
|
|
if (await asyncTest('blocking hooks output BLOCKED message', async () => {
|
|
// Test the dev server blocking hook — must send a matching command
|
|
const blockingCommand = hooks.hooks.PreToolUse[0].hooks[0].command;
|
|
const match = blockingCommand.match(/^node -e "(.+)"$/s);
|
|
|
|
const proc = spawn('node', ['-e', match[1]], {
|
|
stdio: ['pipe', 'pipe', 'pipe']
|
|
});
|
|
|
|
let stderr = '';
|
|
let code = null;
|
|
proc.stderr.on('data', data => stderr += data);
|
|
|
|
// Send a dev server command so the hook triggers the block
|
|
proc.stdin.write(JSON.stringify({
|
|
tool_input: { command: 'npm run dev' }
|
|
}));
|
|
proc.stdin.end();
|
|
|
|
await new Promise(resolve => {
|
|
proc.on('close', (c) => {
|
|
code = c;
|
|
resolve();
|
|
});
|
|
});
|
|
|
|
assert.ok(stderr.includes('BLOCKED'), 'Blocking hook should output BLOCKED');
|
|
assert.strictEqual(code, 2, 'Blocking hook should exit with code 2');
|
|
})) passed++; else failed++;
|
|
|
|
// ==========================================
|
|
// Exit Code Tests
|
|
// ==========================================
|
|
console.log('\nHook Exit Codes:');
|
|
|
|
if (await asyncTest('non-blocking hooks exit with code 0', async () => {
|
|
const result = await runHookWithInput(path.join(scriptsDir, 'session-end.js'), {});
|
|
assert.strictEqual(result.code, 0, 'Non-blocking hook should exit 0');
|
|
})) passed++; else failed++;
|
|
|
|
if (await asyncTest('blocking hooks exit with code 2', async () => {
|
|
// The dev server blocker blocks when a dev server command is detected
|
|
const blockingCommand = hooks.hooks.PreToolUse[0].hooks[0].command;
|
|
const match = blockingCommand.match(/^node -e "(.+)"$/s);
|
|
|
|
const proc = spawn('node', ['-e', match[1]], {
|
|
stdio: ['pipe', 'pipe', 'pipe']
|
|
});
|
|
|
|
let code = null;
|
|
proc.stdin.write(JSON.stringify({
|
|
tool_input: { command: 'yarn dev' }
|
|
}));
|
|
proc.stdin.end();
|
|
|
|
await new Promise(resolve => {
|
|
proc.on('close', (c) => {
|
|
code = c;
|
|
resolve();
|
|
});
|
|
});
|
|
|
|
assert.strictEqual(code, 2, 'Blocking hook should exit 2');
|
|
})) passed++; else failed++;
|
|
|
|
if (await asyncTest('hooks handle missing files gracefully', async () => {
|
|
const testDir = createTestDir();
|
|
const transcriptPath = path.join(testDir, 'nonexistent.jsonl');
|
|
|
|
try {
|
|
const result = await runHookWithInput(
|
|
path.join(scriptsDir, 'evaluate-session.js'),
|
|
{ transcript_path: transcriptPath }
|
|
);
|
|
|
|
// Should not crash, just skip processing
|
|
assert.strictEqual(result.code, 0, 'Should exit 0 for missing file');
|
|
} finally {
|
|
cleanupTestDir(testDir);
|
|
}
|
|
})) passed++; else failed++;
|
|
|
|
// ==========================================
|
|
// Realistic Scenario Tests
|
|
// ==========================================
|
|
console.log('\nRealistic Scenarios:');
|
|
|
|
if (await asyncTest('suggest-compact increments and triggers at threshold', async () => {
|
|
const sessionId = 'integration-test-' + Date.now();
|
|
const counterFile = path.join(os.tmpdir(), `claude-tool-count-${sessionId}`);
|
|
|
|
try {
|
|
// Set counter just below threshold
|
|
fs.writeFileSync(counterFile, '49');
|
|
|
|
const result = await runHookWithInput(
|
|
path.join(scriptsDir, 'suggest-compact.js'),
|
|
{},
|
|
{ CLAUDE_SESSION_ID: sessionId, COMPACT_THRESHOLD: '50' }
|
|
);
|
|
|
|
assert.ok(
|
|
result.stderr.includes('50 tool calls'),
|
|
'Should suggest compact at threshold'
|
|
);
|
|
} finally {
|
|
if (fs.existsSync(counterFile)) fs.unlinkSync(counterFile);
|
|
}
|
|
})) passed++; else failed++;
|
|
|
|
if (await asyncTest('evaluate-session processes transcript with sufficient messages', async () => {
|
|
const testDir = createTestDir();
|
|
const transcriptPath = path.join(testDir, 'transcript.jsonl');
|
|
|
|
// Create a transcript with 15 user messages
|
|
const messages = Array(15).fill(null).map((_, i) => ({
|
|
type: 'user',
|
|
content: `Test message ${i + 1}`
|
|
}));
|
|
|
|
fs.writeFileSync(
|
|
transcriptPath,
|
|
messages.map(m => JSON.stringify(m)).join('\n')
|
|
);
|
|
|
|
try {
|
|
const result = await runHookWithInput(
|
|
path.join(scriptsDir, 'evaluate-session.js'),
|
|
{ transcript_path: transcriptPath }
|
|
);
|
|
|
|
assert.ok(result.stderr.includes('15 messages'), 'Should process session');
|
|
} finally {
|
|
cleanupTestDir(testDir);
|
|
}
|
|
})) passed++; else failed++;
|
|
|
|
if (await asyncTest('PostToolUse PR hook extracts PR URL', async () => {
|
|
// Find the PR logging hook
|
|
const prHook = hooks.hooks.PostToolUse.find(h =>
|
|
h.description && h.description.includes('PR URL')
|
|
);
|
|
|
|
assert.ok(prHook, 'PR hook should exist');
|
|
|
|
const match = prHook.hooks[0].command.match(/^node -e "(.+)"$/s);
|
|
|
|
const proc = spawn('node', ['-e', match[1]], {
|
|
stdio: ['pipe', 'pipe', 'pipe']
|
|
});
|
|
|
|
let stderr = '';
|
|
proc.stderr.on('data', data => stderr += data);
|
|
|
|
// Simulate gh pr create output
|
|
proc.stdin.write(JSON.stringify({
|
|
tool_input: { command: 'gh pr create --title "Test"' },
|
|
tool_output: { output: 'Creating pull request...\nhttps://github.com/owner/repo/pull/123' }
|
|
}));
|
|
proc.stdin.end();
|
|
|
|
await new Promise(resolve => proc.on('close', resolve));
|
|
|
|
assert.ok(
|
|
stderr.includes('PR created') || stderr.includes('github.com'),
|
|
'Should extract and log PR URL'
|
|
);
|
|
})) passed++; else failed++;
|
|
|
|
// ==========================================
|
|
// Error Handling Tests
|
|
// ==========================================
|
|
console.log('\nError Handling:');
|
|
|
|
if (await asyncTest('hooks do not crash on unexpected input structure', async () => {
|
|
const result = await runHookWithInput(
|
|
path.join(scriptsDir, 'suggest-compact.js'),
|
|
{ unexpected: { nested: { deeply: 'value' } } }
|
|
);
|
|
|
|
assert.strictEqual(result.code, 0, 'Should handle unexpected input structure');
|
|
})) passed++; else failed++;
|
|
|
|
if (await asyncTest('hooks handle null and missing values in input', async () => {
|
|
const result = await runHookWithInput(
|
|
path.join(scriptsDir, 'session-start.js'),
|
|
{ tool_input: null }
|
|
);
|
|
|
|
assert.strictEqual(result.code, 0, 'Should handle null/missing values gracefully');
|
|
})) passed++; else failed++;
|
|
|
|
if (await asyncTest('hooks handle very large input without hanging', async () => {
|
|
const largeInput = {
|
|
tool_input: { file_path: '/test.js' },
|
|
tool_output: { output: 'x'.repeat(100000) }
|
|
};
|
|
|
|
const startTime = Date.now();
|
|
const result = await runHookWithInput(
|
|
path.join(scriptsDir, 'session-start.js'),
|
|
largeInput
|
|
);
|
|
const elapsed = Date.now() - startTime;
|
|
|
|
assert.strictEqual(result.code, 0, 'Should complete successfully');
|
|
assert.ok(elapsed < 5000, `Should complete in <5s, took ${elapsed}ms`);
|
|
})) passed++; else failed++;
|
|
|
|
// Summary
|
|
console.log('\n=== Test Results ===');
|
|
console.log(`Passed: ${passed}`);
|
|
console.log(`Failed: ${failed}`);
|
|
console.log(`Total: ${passed + failed}\n`);
|
|
|
|
process.exit(failed > 0 ? 1 : 0);
|
|
}
|
|
|
|
runTests();
|