mirror of
https://github.com/affaan-m/everything-claude-code.git
synced 2026-02-16 19:33:11 +08:00
On Windows, os.homedir() uses USERPROFILE env var instead of HOME. Tests that override HOME to a temp dir must also set USERPROFILE for the session-manager, session-aliases, and session-start hook tests to find files in the correct directory.
845 lines
33 KiB
JavaScript
845 lines
33 KiB
JavaScript
/**
|
|
* 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++;
|
|
|
|
// 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++;
|
|
|
|
// 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();
|