/** * Tests for hook scripts * * Run with: node tests/hooks/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 script and capture output function runScript(scriptPath, input = '', env = {}) { 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); if (input) { proc.stdin.write(input); } proc.stdin.end(); proc.on('close', code => { resolve({ code, stdout, stderr }); }); proc.on('error', reject); }); } // Create a temporary test directory function createTestDir() { const testDir = path.join(os.tmpdir(), `hooks-test-${Date.now()}`); fs.mkdirSync(testDir, { recursive: true }); return testDir; } // Clean up test directory function cleanupTestDir(testDir) { fs.rmSync(testDir, { recursive: true, force: true }); } // Test suite async function runTests() { console.log('\n=== Testing Hook Scripts ===\n'); let passed = 0; let failed = 0; const scriptsDir = path.join(__dirname, '..', '..', 'scripts', 'hooks'); // session-start.js tests console.log('session-start.js:'); if (await asyncTest('runs without error', async () => { const result = await runScript(path.join(scriptsDir, 'session-start.js')); assert.strictEqual(result.code, 0, `Exit code should be 0, got ${result.code}`); })) passed++; else failed++; if (await asyncTest('outputs session info to stderr', async () => { const result = await runScript(path.join(scriptsDir, 'session-start.js')); assert.ok( result.stderr.includes('[SessionStart]') || result.stderr.includes('Package manager'), 'Should output session info' ); })) passed++; else failed++; // session-start.js edge cases console.log('\nsession-start.js (edge cases):'); if (await asyncTest('exits 0 even with isolated empty HOME', async () => { const isoHome = path.join(os.tmpdir(), `ecc-iso-start-${Date.now()}`); fs.mkdirSync(path.join(isoHome, '.claude', 'sessions'), { recursive: true }); fs.mkdirSync(path.join(isoHome, '.claude', 'skills', 'learned'), { recursive: true }); try { const result = await runScript(path.join(scriptsDir, 'session-start.js'), '', { HOME: isoHome, USERPROFILE: isoHome }); assert.strictEqual(result.code, 0, `Exit code should be 0, got ${result.code}`); } finally { fs.rmSync(isoHome, { recursive: true, force: true }); } })) passed++; else failed++; if (await asyncTest('reports package manager detection', async () => { const result = await runScript(path.join(scriptsDir, 'session-start.js')); assert.ok( result.stderr.includes('Package manager') || result.stderr.includes('[SessionStart]'), 'Should report package manager info' ); })) passed++; else failed++; if (await asyncTest('skips template session content', async () => { const isoHome = path.join(os.tmpdir(), `ecc-tpl-start-${Date.now()}`); const sessionsDir = path.join(isoHome, '.claude', 'sessions'); fs.mkdirSync(sessionsDir, { recursive: true }); fs.mkdirSync(path.join(isoHome, '.claude', 'skills', 'learned'), { recursive: true }); // Create a session file with template placeholder const sessionFile = path.join(sessionsDir, '2026-02-11-abcd1234-session.tmp'); fs.writeFileSync(sessionFile, '## Current State\n\n[Session context goes here]\n'); try { const result = await runScript(path.join(scriptsDir, 'session-start.js'), '', { HOME: isoHome, USERPROFILE: isoHome }); assert.strictEqual(result.code, 0); // stdout should NOT contain the template content assert.ok( !result.stdout.includes('Previous session summary'), 'Should not inject template session content' ); } finally { fs.rmSync(isoHome, { recursive: true, force: true }); } })) passed++; else failed++; if (await asyncTest('injects real session content', async () => { const isoHome = path.join(os.tmpdir(), `ecc-real-start-${Date.now()}`); const sessionsDir = path.join(isoHome, '.claude', 'sessions'); fs.mkdirSync(sessionsDir, { recursive: true }); fs.mkdirSync(path.join(isoHome, '.claude', 'skills', 'learned'), { recursive: true }); // Create a real session file const sessionFile = path.join(sessionsDir, '2026-02-11-efgh5678-session.tmp'); fs.writeFileSync(sessionFile, '# Real Session\n\nI worked on authentication refactor.\n'); try { const result = await runScript(path.join(scriptsDir, 'session-start.js'), '', { HOME: isoHome, USERPROFILE: isoHome }); assert.strictEqual(result.code, 0); assert.ok( result.stdout.includes('Previous session summary'), 'Should inject real session content' ); assert.ok( result.stdout.includes('authentication refactor'), 'Should include session content text' ); } finally { fs.rmSync(isoHome, { recursive: true, force: true }); } })) passed++; else failed++; if (await asyncTest('reports learned skills count', async () => { const isoHome = path.join(os.tmpdir(), `ecc-skills-start-${Date.now()}`); const learnedDir = path.join(isoHome, '.claude', 'skills', 'learned'); fs.mkdirSync(learnedDir, { recursive: true }); fs.mkdirSync(path.join(isoHome, '.claude', 'sessions'), { recursive: true }); // Create learned skill files fs.writeFileSync(path.join(learnedDir, 'testing-patterns.md'), '# Testing'); fs.writeFileSync(path.join(learnedDir, 'debugging.md'), '# Debugging'); try { const result = await runScript(path.join(scriptsDir, 'session-start.js'), '', { HOME: isoHome, USERPROFILE: isoHome }); assert.strictEqual(result.code, 0); assert.ok( result.stderr.includes('2 learned skill(s)'), `Should report 2 learned skills, stderr: ${result.stderr}` ); } finally { fs.rmSync(isoHome, { recursive: true, force: true }); } })) passed++; else failed++; // check-console-log.js tests console.log('\ncheck-console-log.js:'); if (await asyncTest('passes through stdin data to stdout', async () => { const stdinData = JSON.stringify({ tool_name: 'Write', tool_input: {} }); const result = await runScript(path.join(scriptsDir, 'check-console-log.js'), stdinData); assert.strictEqual(result.code, 0); assert.ok(result.stdout.includes('tool_name'), 'Should pass through stdin data'); })) passed++; else failed++; if (await asyncTest('exits 0 with empty stdin', async () => { const result = await runScript(path.join(scriptsDir, 'check-console-log.js'), ''); assert.strictEqual(result.code, 0); })) passed++; else failed++; // session-end.js tests console.log('\nsession-end.js:'); if (await asyncTest('runs without error', async () => { const result = await runScript(path.join(scriptsDir, 'session-end.js')); assert.strictEqual(result.code, 0, `Exit code should be 0, got ${result.code}`); })) passed++; else failed++; if (await asyncTest('creates or updates session file', async () => { // Run the script await runScript(path.join(scriptsDir, 'session-end.js')); // Check if session file was created // Note: Without CLAUDE_SESSION_ID, falls back to project name (not 'default') // Use local time to match the script's getDateString() function const sessionsDir = path.join(os.homedir(), '.claude', 'sessions'); const now = new Date(); const today = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`; // Get the expected session ID (project name fallback) const utils = require('../../scripts/lib/utils'); const expectedId = utils.getSessionIdShort(); const sessionFile = path.join(sessionsDir, `${today}-${expectedId}-session.tmp`); assert.ok(fs.existsSync(sessionFile), `Session file should exist: ${sessionFile}`); })) passed++; else failed++; if (await asyncTest('includes session ID in filename', async () => { const testSessionId = 'test-session-abc12345'; const expectedShortId = 'abc12345'; // Last 8 chars // Run with custom session ID await runScript(path.join(scriptsDir, 'session-end.js'), '', { CLAUDE_SESSION_ID: testSessionId }); // Check if session file was created with session ID // Use local time to match the script's getDateString() function const sessionsDir = path.join(os.homedir(), '.claude', 'sessions'); const now = new Date(); const today = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`; const sessionFile = path.join(sessionsDir, `${today}-${expectedShortId}-session.tmp`); assert.ok(fs.existsSync(sessionFile), `Session file should exist: ${sessionFile}`); })) passed++; else failed++; // pre-compact.js tests console.log('\npre-compact.js:'); if (await asyncTest('runs without error', async () => { const result = await runScript(path.join(scriptsDir, 'pre-compact.js')); assert.strictEqual(result.code, 0, `Exit code should be 0, got ${result.code}`); })) passed++; else failed++; if (await asyncTest('outputs PreCompact message', async () => { const result = await runScript(path.join(scriptsDir, 'pre-compact.js')); assert.ok(result.stderr.includes('[PreCompact]'), 'Should output PreCompact message'); })) passed++; else failed++; if (await asyncTest('creates compaction log', async () => { await runScript(path.join(scriptsDir, 'pre-compact.js')); const logFile = path.join(os.homedir(), '.claude', 'sessions', 'compaction-log.txt'); assert.ok(fs.existsSync(logFile), 'Compaction log should exist'); })) passed++; else failed++; // suggest-compact.js tests console.log('\nsuggest-compact.js:'); if (await asyncTest('runs without error', async () => { const result = await runScript(path.join(scriptsDir, 'suggest-compact.js'), '', { CLAUDE_SESSION_ID: 'test-session-' + Date.now() }); assert.strictEqual(result.code, 0, `Exit code should be 0, got ${result.code}`); })) passed++; else failed++; if (await asyncTest('increments counter on each call', async () => { const sessionId = 'test-counter-' + Date.now(); // Run multiple times for (let i = 0; i < 3; i++) { await runScript(path.join(scriptsDir, 'suggest-compact.js'), '', { CLAUDE_SESSION_ID: sessionId }); } // Check counter file const counterFile = path.join(os.tmpdir(), `claude-tool-count-${sessionId}`); const count = parseInt(fs.readFileSync(counterFile, 'utf8').trim(), 10); assert.strictEqual(count, 3, `Counter should be 3, got ${count}`); // Cleanup fs.unlinkSync(counterFile); })) passed++; else failed++; if (await asyncTest('suggests compact at threshold', async () => { const sessionId = 'test-threshold-' + Date.now(); const counterFile = path.join(os.tmpdir(), `claude-tool-count-${sessionId}`); // Set counter to threshold - 1 fs.writeFileSync(counterFile, '49'); const result = await runScript(path.join(scriptsDir, 'suggest-compact.js'), '', { CLAUDE_SESSION_ID: sessionId, COMPACT_THRESHOLD: '50' }); assert.ok( result.stderr.includes('50 tool calls reached'), 'Should suggest compact at threshold' ); // Cleanup fs.unlinkSync(counterFile); })) passed++; else failed++; if (await asyncTest('does not suggest below threshold', async () => { const sessionId = 'test-below-' + Date.now(); const counterFile = path.join(os.tmpdir(), `claude-tool-count-${sessionId}`); fs.writeFileSync(counterFile, '10'); const result = await runScript(path.join(scriptsDir, 'suggest-compact.js'), '', { CLAUDE_SESSION_ID: sessionId, COMPACT_THRESHOLD: '50' }); assert.ok( !result.stderr.includes('tool calls'), 'Should not suggest compact below threshold' ); fs.unlinkSync(counterFile); })) passed++; else failed++; if (await asyncTest('suggests at regular intervals after threshold', async () => { const sessionId = 'test-interval-' + Date.now(); const counterFile = path.join(os.tmpdir(), `claude-tool-count-${sessionId}`); // Set counter to 74 (next will be 75, which is >50 and 75%25==0) fs.writeFileSync(counterFile, '74'); const result = await runScript(path.join(scriptsDir, 'suggest-compact.js'), '', { CLAUDE_SESSION_ID: sessionId, COMPACT_THRESHOLD: '50' }); assert.ok( result.stderr.includes('75 tool calls'), 'Should suggest at 25-call intervals after threshold' ); fs.unlinkSync(counterFile); })) passed++; else failed++; if (await asyncTest('handles corrupted counter file', async () => { const sessionId = 'test-corrupt-' + Date.now(); const counterFile = path.join(os.tmpdir(), `claude-tool-count-${sessionId}`); fs.writeFileSync(counterFile, 'not-a-number'); const result = await runScript(path.join(scriptsDir, 'suggest-compact.js'), '', { CLAUDE_SESSION_ID: sessionId }); assert.strictEqual(result.code, 0, 'Should handle corrupted counter gracefully'); // Counter should be reset to 1 const newCount = parseInt(fs.readFileSync(counterFile, 'utf8').trim(), 10); assert.strictEqual(newCount, 1, 'Should reset counter to 1 on corrupt data'); fs.unlinkSync(counterFile); })) passed++; else failed++; if (await asyncTest('uses default session ID when no env var', async () => { const result = await runScript(path.join(scriptsDir, 'suggest-compact.js'), '', { CLAUDE_SESSION_ID: '' // Empty, should use 'default' }); assert.strictEqual(result.code, 0, 'Should work with default session ID'); // Cleanup the default counter file const counterFile = path.join(os.tmpdir(), 'claude-tool-count-default'); if (fs.existsSync(counterFile)) fs.unlinkSync(counterFile); })) passed++; else failed++; if (await asyncTest('validates threshold bounds', async () => { const sessionId = 'test-bounds-' + Date.now(); const counterFile = path.join(os.tmpdir(), `claude-tool-count-${sessionId}`); // Invalid threshold should fall back to 50 fs.writeFileSync(counterFile, '49'); const result = await runScript(path.join(scriptsDir, 'suggest-compact.js'), '', { CLAUDE_SESSION_ID: sessionId, COMPACT_THRESHOLD: '-5' // Invalid: negative }); assert.ok( result.stderr.includes('50 tool calls'), 'Should use default threshold (50) for invalid value' ); fs.unlinkSync(counterFile); })) passed++; else failed++; // evaluate-session.js tests console.log('\nevaluate-session.js:'); if (await asyncTest('runs without error when no transcript', async () => { const result = await runScript(path.join(scriptsDir, 'evaluate-session.js')); assert.strictEqual(result.code, 0, `Exit code should be 0, got ${result.code}`); })) passed++; else failed++; if (await asyncTest('skips short sessions', async () => { const testDir = createTestDir(); const transcriptPath = path.join(testDir, 'transcript.jsonl'); // Create a short transcript (less than 10 user messages) const transcript = Array(5).fill('{"type":"user","content":"test"}\n').join(''); fs.writeFileSync(transcriptPath, transcript); const stdinJson = JSON.stringify({ transcript_path: transcriptPath }); const result = await runScript(path.join(scriptsDir, 'evaluate-session.js'), stdinJson); assert.ok( result.stderr.includes('Session too short'), 'Should indicate session is too short' ); cleanupTestDir(testDir); })) passed++; else failed++; if (await asyncTest('processes sessions with enough messages', async () => { const testDir = createTestDir(); const transcriptPath = path.join(testDir, 'transcript.jsonl'); // Create a longer transcript (more than 10 user messages) const transcript = Array(15).fill('{"type":"user","content":"test"}\n').join(''); fs.writeFileSync(transcriptPath, transcript); const stdinJson = JSON.stringify({ transcript_path: transcriptPath }); const result = await runScript(path.join(scriptsDir, 'evaluate-session.js'), stdinJson); assert.ok( result.stderr.includes('15 messages'), 'Should report message count' ); cleanupTestDir(testDir); })) passed++; else failed++; // evaluate-session.js: whitespace tolerance regression test if (await asyncTest('counts user messages with whitespace in JSON (regression)', async () => { const testDir = createTestDir(); const transcriptPath = path.join(testDir, 'transcript.jsonl'); // Create transcript with whitespace around colons (pretty-printed style) const lines = []; for (let i = 0; i < 15; i++) { lines.push('{ "type" : "user", "content": "message ' + i + '" }'); } fs.writeFileSync(transcriptPath, lines.join('\n')); const stdinJson = JSON.stringify({ transcript_path: transcriptPath }); const result = await runScript(path.join(scriptsDir, 'evaluate-session.js'), stdinJson); assert.ok( result.stderr.includes('15 messages'), 'Should count user messages with whitespace in JSON, got: ' + result.stderr.trim() ); cleanupTestDir(testDir); })) passed++; else failed++; // session-end.js: content array with null elements regression test if (await asyncTest('handles transcript with null content array elements (regression)', async () => { const testDir = createTestDir(); const transcriptPath = path.join(testDir, 'transcript.jsonl'); // Create transcript with null elements in content array const lines = [ '{"type":"user","content":[null,{"text":"hello"},null,{"text":"world"}]}', '{"type":"user","content":"simple string message"}', '{"type":"user","content":[{"text":"normal"},{"text":"array"}]}', '{"type":"tool_use","tool_name":"Edit","tool_input":{"file_path":"/test.js"}}', ]; fs.writeFileSync(transcriptPath, lines.join('\n')); const stdinJson = JSON.stringify({ transcript_path: transcriptPath }); const result = await runScript(path.join(scriptsDir, 'session-end.js'), stdinJson); // Should not crash (exit 0) assert.strictEqual(result.code, 0, 'Should handle null content elements without crash'); })) passed++; else failed++; // post-edit-console-warn.js tests console.log('\npost-edit-console-warn.js:'); if (await asyncTest('warns about console.log in JS files', async () => { const testDir = createTestDir(); const testFile = path.join(testDir, 'test.js'); fs.writeFileSync(testFile, 'const x = 1;\nconsole.log(x);\nreturn x;'); const stdinJson = JSON.stringify({ tool_input: { file_path: testFile } }); const result = await runScript(path.join(scriptsDir, 'post-edit-console-warn.js'), stdinJson); assert.ok(result.stderr.includes('console.log'), 'Should warn about console.log'); cleanupTestDir(testDir); })) passed++; else failed++; if (await asyncTest('does not warn for non-JS files', async () => { const testDir = createTestDir(); const testFile = path.join(testDir, 'test.md'); fs.writeFileSync(testFile, 'Use console.log for debugging'); const stdinJson = JSON.stringify({ tool_input: { file_path: testFile } }); const result = await runScript(path.join(scriptsDir, 'post-edit-console-warn.js'), stdinJson); assert.ok(!result.stderr.includes('console.log'), 'Should not warn for non-JS files'); cleanupTestDir(testDir); })) passed++; else failed++; if (await asyncTest('does not warn for clean JS files', async () => { const testDir = createTestDir(); const testFile = path.join(testDir, 'clean.ts'); fs.writeFileSync(testFile, 'const x = 1;\nreturn x;'); const stdinJson = JSON.stringify({ tool_input: { file_path: testFile } }); const result = await runScript(path.join(scriptsDir, 'post-edit-console-warn.js'), stdinJson); assert.ok(!result.stderr.includes('WARNING'), 'Should not warn for clean files'); cleanupTestDir(testDir); })) passed++; else failed++; if (await asyncTest('handles missing file gracefully', async () => { const stdinJson = JSON.stringify({ tool_input: { file_path: '/nonexistent/file.ts' } }); const result = await runScript(path.join(scriptsDir, 'post-edit-console-warn.js'), stdinJson); assert.strictEqual(result.code, 0, 'Should not crash on missing file'); })) passed++; else failed++; if (await asyncTest('passes through original data on stdout', async () => { const stdinJson = JSON.stringify({ tool_input: { file_path: '/test.py' } }); const result = await runScript(path.join(scriptsDir, 'post-edit-console-warn.js'), stdinJson); assert.ok(result.stdout.includes('tool_input'), 'Should pass through stdin data'); })) passed++; else failed++; // post-edit-format.js tests console.log('\npost-edit-format.js:'); if (await asyncTest('runs without error on empty stdin', async () => { const result = await runScript(path.join(scriptsDir, 'post-edit-format.js')); assert.strictEqual(result.code, 0, 'Should exit 0 on empty stdin'); })) passed++; else failed++; if (await asyncTest('skips non-JS/TS files', async () => { const stdinJson = JSON.stringify({ tool_input: { file_path: '/test.py' } }); const result = await runScript(path.join(scriptsDir, 'post-edit-format.js'), stdinJson); assert.strictEqual(result.code, 0, 'Should exit 0 for non-JS files'); assert.ok(result.stdout.includes('tool_input'), 'Should pass through stdin data'); })) passed++; else failed++; if (await asyncTest('passes through data for invalid JSON', async () => { const result = await runScript(path.join(scriptsDir, 'post-edit-format.js'), 'not json'); assert.strictEqual(result.code, 0, 'Should exit 0 for invalid JSON'); })) passed++; else failed++; // post-edit-typecheck.js tests console.log('\npost-edit-typecheck.js:'); if (await asyncTest('runs without error on empty stdin', async () => { const result = await runScript(path.join(scriptsDir, 'post-edit-typecheck.js')); assert.strictEqual(result.code, 0, 'Should exit 0 on empty stdin'); })) passed++; else failed++; if (await asyncTest('skips non-TypeScript files', async () => { const stdinJson = JSON.stringify({ tool_input: { file_path: '/test.js' } }); const result = await runScript(path.join(scriptsDir, 'post-edit-typecheck.js'), stdinJson); assert.strictEqual(result.code, 0, 'Should exit 0 for non-TS files'); assert.ok(result.stdout.includes('tool_input'), 'Should pass through stdin data'); })) passed++; else failed++; if (await asyncTest('handles nonexistent TS file gracefully', async () => { const stdinJson = JSON.stringify({ tool_input: { file_path: '/nonexistent/file.ts' } }); const result = await runScript(path.join(scriptsDir, 'post-edit-typecheck.js'), stdinJson); assert.strictEqual(result.code, 0, 'Should exit 0 for missing file'); })) passed++; else failed++; if (await asyncTest('handles TS file with no tsconfig gracefully', async () => { const testDir = createTestDir(); const testFile = path.join(testDir, 'test.ts'); fs.writeFileSync(testFile, 'const x: number = 1;'); const stdinJson = JSON.stringify({ tool_input: { file_path: testFile } }); const result = await runScript(path.join(scriptsDir, 'post-edit-typecheck.js'), stdinJson); assert.strictEqual(result.code, 0, 'Should exit 0 when no tsconfig found'); cleanupTestDir(testDir); })) passed++; else failed++; if (await asyncTest('stops tsconfig walk at max depth (20)', async () => { // Create a deeply nested directory (>20 levels) with no tsconfig anywhere const testDir = createTestDir(); let deepDir = testDir; for (let i = 0; i < 25; i++) { deepDir = path.join(deepDir, `d${i}`); } fs.mkdirSync(deepDir, { recursive: true }); const testFile = path.join(deepDir, 'deep.ts'); fs.writeFileSync(testFile, 'const x: number = 1;'); const stdinJson = JSON.stringify({ tool_input: { file_path: testFile } }); const startTime = Date.now(); const result = await runScript(path.join(scriptsDir, 'post-edit-typecheck.js'), stdinJson); const elapsed = Date.now() - startTime; assert.strictEqual(result.code, 0, 'Should not hang at depth limit'); assert.ok(elapsed < 5000, `Should complete quickly at depth limit, took ${elapsed}ms`); cleanupTestDir(testDir); })) passed++; else failed++; // session-end.js extractSessionSummary tests console.log('\nsession-end.js (extractSessionSummary):'); if (await asyncTest('extracts user messages from transcript', async () => { const testDir = createTestDir(); const transcriptPath = path.join(testDir, 'transcript.jsonl'); const lines = [ '{"type":"user","content":"Fix the login bug"}', '{"type":"assistant","content":"I will fix it"}', '{"type":"user","content":"Also add tests"}', ]; fs.writeFileSync(transcriptPath, lines.join('\n')); const stdinJson = JSON.stringify({ transcript_path: transcriptPath }); const result = await runScript(path.join(scriptsDir, 'session-end.js'), stdinJson); assert.strictEqual(result.code, 0); cleanupTestDir(testDir); })) passed++; else failed++; if (await asyncTest('handles transcript with array content fields', async () => { const testDir = createTestDir(); const transcriptPath = path.join(testDir, 'transcript.jsonl'); const lines = [ '{"type":"user","content":[{"text":"Part 1"},{"text":"Part 2"}]}', '{"type":"user","content":"Simple message"}', ]; fs.writeFileSync(transcriptPath, lines.join('\n')); const stdinJson = JSON.stringify({ transcript_path: transcriptPath }); const result = await runScript(path.join(scriptsDir, 'session-end.js'), stdinJson); assert.strictEqual(result.code, 0, 'Should handle array content without crash'); cleanupTestDir(testDir); })) passed++; else failed++; if (await asyncTest('extracts tool names and file paths from transcript', async () => { const testDir = createTestDir(); const transcriptPath = path.join(testDir, 'transcript.jsonl'); const lines = [ '{"type":"user","content":"Edit the file"}', '{"type":"tool_use","tool_name":"Edit","tool_input":{"file_path":"/src/main.ts"}}', '{"type":"tool_use","tool_name":"Read","tool_input":{"file_path":"/src/utils.ts"}}', '{"type":"tool_use","tool_name":"Write","tool_input":{"file_path":"/src/new.ts"}}', ]; fs.writeFileSync(transcriptPath, lines.join('\n')); const stdinJson = JSON.stringify({ transcript_path: transcriptPath }); const result = await runScript(path.join(scriptsDir, 'session-end.js'), stdinJson); assert.strictEqual(result.code, 0); // Session file should contain summary with tools used assert.ok( result.stderr.includes('Created session file') || result.stderr.includes('Updated session file'), 'Should create/update session file' ); cleanupTestDir(testDir); })) passed++; else failed++; if (await asyncTest('handles transcript with malformed JSON lines', async () => { const testDir = createTestDir(); const transcriptPath = path.join(testDir, 'transcript.jsonl'); const lines = [ '{"type":"user","content":"Valid message"}', 'NOT VALID JSON', '{"broken json', '{"type":"user","content":"Another valid"}', ]; fs.writeFileSync(transcriptPath, lines.join('\n')); const stdinJson = JSON.stringify({ transcript_path: transcriptPath }); const result = await runScript(path.join(scriptsDir, 'session-end.js'), stdinJson); assert.strictEqual(result.code, 0, 'Should skip malformed lines gracefully'); assert.ok( result.stderr.includes('unparseable') || result.stderr.includes('Skipped'), `Should report parse errors, got: ${result.stderr.substring(0, 200)}` ); cleanupTestDir(testDir); })) passed++; else failed++; if (await asyncTest('handles empty transcript (no user messages)', async () => { const testDir = createTestDir(); const transcriptPath = path.join(testDir, 'transcript.jsonl'); // Only tool_use entries, no user messages const lines = [ '{"type":"tool_use","tool_name":"Read","tool_input":{}}', '{"type":"assistant","content":"done"}', ]; fs.writeFileSync(transcriptPath, lines.join('\n')); const stdinJson = JSON.stringify({ transcript_path: transcriptPath }); const result = await runScript(path.join(scriptsDir, 'session-end.js'), stdinJson); assert.strictEqual(result.code, 0, 'Should handle transcript with no user messages'); cleanupTestDir(testDir); })) passed++; else failed++; if (await asyncTest('truncates long user messages to 200 chars', async () => { const testDir = createTestDir(); const transcriptPath = path.join(testDir, 'transcript.jsonl'); const longMsg = 'x'.repeat(500); const lines = [ `{"type":"user","content":"${longMsg}"}`, ]; fs.writeFileSync(transcriptPath, lines.join('\n')); const stdinJson = JSON.stringify({ transcript_path: transcriptPath }); const result = await runScript(path.join(scriptsDir, 'session-end.js'), stdinJson); assert.strictEqual(result.code, 0, 'Should handle and truncate long messages'); cleanupTestDir(testDir); })) passed++; else failed++; if (await asyncTest('uses CLAUDE_TRANSCRIPT_PATH env var as fallback', async () => { const testDir = createTestDir(); const transcriptPath = path.join(testDir, 'transcript.jsonl'); const lines = [ '{"type":"user","content":"Fallback test message"}', ]; fs.writeFileSync(transcriptPath, lines.join('\n')); // Send invalid JSON to stdin so it falls back to env var const result = await runScript(path.join(scriptsDir, 'session-end.js'), 'not json', { CLAUDE_TRANSCRIPT_PATH: transcriptPath }); assert.strictEqual(result.code, 0, 'Should use env var fallback'); cleanupTestDir(testDir); })) passed++; else failed++; if (await asyncTest('escapes backticks in user messages in session file', async () => { const testDir = createTestDir(); const transcriptPath = path.join(testDir, 'transcript.jsonl'); // User messages with backticks that could break markdown const lines = [ '{"type":"user","content":"Fix the `handleAuth` function in `auth.ts`"}', '{"type":"user","content":"Run `npm test` to verify"}', ]; fs.writeFileSync(transcriptPath, lines.join('\n')); const stdinJson = JSON.stringify({ transcript_path: transcriptPath }); const result = await runScript(path.join(scriptsDir, 'session-end.js'), stdinJson, { HOME: testDir }); assert.strictEqual(result.code, 0, 'Should handle backticks without crash'); // Find the session file in the temp HOME const claudeDir = path.join(testDir, '.claude', 'sessions'); if (fs.existsSync(claudeDir)) { const files = fs.readdirSync(claudeDir).filter(f => f.endsWith('.tmp')); if (files.length > 0) { const content = fs.readFileSync(path.join(claudeDir, files[0]), 'utf8'); // Backticks should be escaped in the output assert.ok(content.includes('\\`'), 'Should escape backticks in session file'); assert.ok(!content.includes('`handleAuth`'), 'Raw backticks should be escaped'); } } cleanupTestDir(testDir); })) passed++; else failed++; if (await asyncTest('session file contains tools used and files modified', async () => { const testDir = createTestDir(); const transcriptPath = path.join(testDir, 'transcript.jsonl'); const lines = [ '{"type":"user","content":"Edit the config"}', '{"type":"tool_use","tool_name":"Edit","tool_input":{"file_path":"/src/config.ts"}}', '{"type":"tool_use","tool_name":"Read","tool_input":{"file_path":"/src/utils.ts"}}', '{"type":"tool_use","tool_name":"Write","tool_input":{"file_path":"/src/new-file.ts"}}', ]; fs.writeFileSync(transcriptPath, lines.join('\n')); const stdinJson = JSON.stringify({ transcript_path: transcriptPath }); const result = await runScript(path.join(scriptsDir, 'session-end.js'), stdinJson, { HOME: testDir }); assert.strictEqual(result.code, 0); const claudeDir = path.join(testDir, '.claude', 'sessions'); if (fs.existsSync(claudeDir)) { const files = fs.readdirSync(claudeDir).filter(f => f.endsWith('.tmp')); if (files.length > 0) { const content = fs.readFileSync(path.join(claudeDir, files[0]), 'utf8'); // Should contain files modified (Edit and Write, not Read) assert.ok(content.includes('/src/config.ts'), 'Should list edited file'); assert.ok(content.includes('/src/new-file.ts'), 'Should list written file'); // Should contain tools used assert.ok(content.includes('Edit'), 'Should list Edit tool'); assert.ok(content.includes('Read'), 'Should list Read tool'); } } cleanupTestDir(testDir); })) passed++; else failed++; if (await asyncTest('parses Claude Code JSONL format (entry.message.content)', async () => { const testDir = createTestDir(); const transcriptPath = path.join(testDir, 'transcript.jsonl'); // Claude Code v2.1.41+ JSONL format: user messages nested in entry.message const lines = [ '{"type":"user","message":{"role":"user","content":"Fix the build error"}}', '{"type":"user","message":{"role":"user","content":[{"type":"text","text":"Also update tests"}]}}', ]; fs.writeFileSync(transcriptPath, lines.join('\n')); const stdinJson = JSON.stringify({ transcript_path: transcriptPath }); const result = await runScript(path.join(scriptsDir, 'session-end.js'), stdinJson, { HOME: testDir }); assert.strictEqual(result.code, 0); const claudeDir = path.join(testDir, '.claude', 'sessions'); if (fs.existsSync(claudeDir)) { const files = fs.readdirSync(claudeDir).filter(f => f.endsWith('.tmp')); if (files.length > 0) { const content = fs.readFileSync(path.join(claudeDir, files[0]), 'utf8'); assert.ok(content.includes('Fix the build error'), 'Should extract string content from message'); assert.ok(content.includes('Also update tests'), 'Should extract array content from message'); } } cleanupTestDir(testDir); })) passed++; else failed++; if (await asyncTest('extracts tool_use from assistant message content blocks', async () => { const testDir = createTestDir(); const transcriptPath = path.join(testDir, 'transcript.jsonl'); // Claude Code JSONL: tool uses nested in assistant message content array const lines = [ '{"type":"user","content":"Edit the config"}', JSON.stringify({ type: 'assistant', message: { role: 'assistant', content: [ { type: 'text', text: 'I will edit the file.' }, { type: 'tool_use', name: 'Edit', input: { file_path: '/src/app.ts' } }, { type: 'tool_use', name: 'Write', input: { file_path: '/src/new.ts' } }, ] } }), ]; fs.writeFileSync(transcriptPath, lines.join('\n')); const stdinJson = JSON.stringify({ transcript_path: transcriptPath }); const result = await runScript(path.join(scriptsDir, 'session-end.js'), stdinJson, { HOME: testDir }); assert.strictEqual(result.code, 0); const claudeDir = path.join(testDir, '.claude', 'sessions'); if (fs.existsSync(claudeDir)) { const files = fs.readdirSync(claudeDir).filter(f => f.endsWith('.tmp')); if (files.length > 0) { const content = fs.readFileSync(path.join(claudeDir, files[0]), 'utf8'); assert.ok(content.includes('Edit'), 'Should extract Edit tool from content blocks'); assert.ok(content.includes('/src/app.ts'), 'Should extract file path from Edit block'); assert.ok(content.includes('/src/new.ts'), 'Should extract file path from Write block'); } } cleanupTestDir(testDir); })) passed++; else failed++; // hooks.json validation console.log('\nhooks.json Validation:'); if (test('hooks.json is valid JSON', () => { const hooksPath = path.join(__dirname, '..', '..', 'hooks', 'hooks.json'); const content = fs.readFileSync(hooksPath, 'utf8'); JSON.parse(content); // Will throw if invalid })) passed++; else failed++; if (test('hooks.json has required event types', () => { const hooksPath = path.join(__dirname, '..', '..', 'hooks', 'hooks.json'); const hooks = JSON.parse(fs.readFileSync(hooksPath, 'utf8')); assert.ok(hooks.hooks.PreToolUse, 'Should have PreToolUse hooks'); assert.ok(hooks.hooks.PostToolUse, 'Should have PostToolUse hooks'); assert.ok(hooks.hooks.SessionStart, 'Should have SessionStart hooks'); assert.ok(hooks.hooks.SessionEnd, 'Should have SessionEnd hooks'); assert.ok(hooks.hooks.Stop, 'Should have Stop hooks'); assert.ok(hooks.hooks.PreCompact, 'Should have PreCompact hooks'); })) passed++; else failed++; if (test('all hook commands use node', () => { const hooksPath = path.join(__dirname, '..', '..', 'hooks', 'hooks.json'); const hooks = JSON.parse(fs.readFileSync(hooksPath, 'utf8')); const checkHooks = (hookArray) => { for (const entry of hookArray) { for (const hook of entry.hooks) { if (hook.type === 'command') { assert.ok( hook.command.startsWith('node'), `Hook command should start with 'node': ${hook.command.substring(0, 50)}...` ); } } } }; for (const [, hookArray] of Object.entries(hooks.hooks)) { checkHooks(hookArray); } })) passed++; else failed++; if (test('script references use CLAUDE_PLUGIN_ROOT variable', () => { const hooksPath = path.join(__dirname, '..', '..', 'hooks', 'hooks.json'); const hooks = JSON.parse(fs.readFileSync(hooksPath, 'utf8')); const checkHooks = (hookArray) => { for (const entry of hookArray) { for (const hook of entry.hooks) { if (hook.type === 'command' && hook.command.includes('scripts/hooks/')) { // Check for the literal string "${CLAUDE_PLUGIN_ROOT}" in the command const hasPluginRoot = hook.command.includes('${CLAUDE_PLUGIN_ROOT}'); assert.ok( hasPluginRoot, `Script paths should use CLAUDE_PLUGIN_ROOT: ${hook.command.substring(0, 80)}...` ); } } } }; for (const [, hookArray] of Object.entries(hooks.hooks)) { checkHooks(hookArray); } })) passed++; else failed++; // plugin.json validation console.log('\nplugin.json Validation:'); if (test('plugin.json does NOT have explicit hooks declaration', () => { // Claude Code automatically loads hooks/hooks.json by convention. // Explicitly declaring it in plugin.json causes a duplicate detection error. // See: https://github.com/affaan-m/everything-claude-code/issues/103 const pluginPath = path.join(__dirname, '..', '..', '.claude-plugin', 'plugin.json'); const plugin = JSON.parse(fs.readFileSync(pluginPath, 'utf8')); assert.ok( !plugin.hooks, 'plugin.json should NOT have "hooks" field - Claude Code auto-loads hooks/hooks.json' ); })) 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();