From e96b522af0e1b29e3d05220bf124187c66d0fbcb Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Fri, 13 Feb 2026 01:42:56 -0800 Subject: [PATCH] fix: calendar-accurate date validation in parseSessionFilename, add 22 tests - Fix parseSessionFilename to reject impossible dates (Feb 31, Apr 31, Feb 29 non-leap) using Date constructor month/day roundtrip check - Add 6 session-manager tests for calendar date validation edge cases - Add 3 session-manager tests for code blocks/special chars in getSessionStats - Add 10 package-manager tests for PM-specific command formats (getRunCommand and getExecCommand for pnpm, yarn, bun, npm) - Add 3 integration tests for session-end transcript parsing (mixed JSONL formats, malformed lines, nested user messages) --- scripts/lib/session-manager.js | 6 +- tests/integration/hooks.test.js | 119 +++++++++++++++++++++++++++++ tests/lib/package-manager.test.js | 120 ++++++++++++++++++++++++++++++ tests/lib/session-manager.test.js | 58 +++++++++++++++ 4 files changed, 302 insertions(+), 1 deletion(-) diff --git a/scripts/lib/session-manager.js b/scripts/lib/session-manager.js index 89e68dc7..f3507835 100644 --- a/scripts/lib/session-manager.js +++ b/scripts/lib/session-manager.js @@ -32,9 +32,13 @@ function parseSessionFilename(filename) { const dateStr = match[1]; - // Validate date components are in valid ranges (not just format) + // Validate date components are calendar-accurate (not just format) const [year, month, day] = dateStr.split('-').map(Number); if (month < 1 || month > 12 || day < 1 || day > 31) return null; + // Reject impossible dates like Feb 31, Apr 31 — Date constructor rolls + // over invalid days (e.g., Feb 31 → Mar 3), so check month roundtrips + const d = new Date(year, month - 1, day); + if (d.getMonth() !== month - 1 || d.getDate() !== day) return null; // match[2] is undefined for old format (no ID) const shortId = match[2] || 'no-id'; diff --git a/tests/integration/hooks.test.js b/tests/integration/hooks.test.js index 18610e95..229a6898 100644 --- a/tests/integration/hooks.test.js +++ b/tests/integration/hooks.test.js @@ -405,6 +405,125 @@ async function runTests() { ); })) passed++; else failed++; + // ========================================== + // Session End Transcript Parsing Tests + // ========================================== + console.log('\nSession End Transcript Parsing:'); + + if (await asyncTest('session-end extracts summary from mixed JSONL formats', async () => { + const testDir = createTestDir(); + const transcriptPath = path.join(testDir, 'mixed-transcript.jsonl'); + + // Create transcript with both direct tool_use and nested assistant message formats + const lines = [ + JSON.stringify({ type: 'user', content: 'Fix the login bug' }), + JSON.stringify({ type: 'tool_use', name: 'Read', input: { file_path: 'src/auth.ts' } }), + JSON.stringify({ type: 'assistant', message: { content: [ + { type: 'tool_use', name: 'Edit', input: { file_path: 'src/auth.ts' } } + ]}}), + JSON.stringify({ type: 'user', content: 'Now add tests' }), + JSON.stringify({ type: 'assistant', message: { content: [ + { type: 'tool_use', name: 'Write', input: { file_path: 'tests/auth.test.ts' } }, + { type: 'text', text: 'Here are the tests' } + ]}}), + JSON.stringify({ type: 'user', content: 'Looks good, commit' }) + ]; + fs.writeFileSync(transcriptPath, lines.join('\n')); + + try { + const result = await runHookWithInput( + path.join(scriptsDir, 'session-end.js'), + { transcript_path: transcriptPath }, + { HOME: testDir, USERPROFILE: testDir } + ); + + assert.strictEqual(result.code, 0, 'Should exit 0'); + assert.ok(result.stderr.includes('[SessionEnd]'), 'Should have SessionEnd log'); + + // Verify a session file was created + const sessionsDir = path.join(testDir, '.claude', 'sessions'); + if (fs.existsSync(sessionsDir)) { + const files = fs.readdirSync(sessionsDir).filter(f => f.endsWith('.tmp')); + assert.ok(files.length > 0, 'Should create a session file'); + + // Verify session content includes tasks from user messages + const content = fs.readFileSync(path.join(sessionsDir, files[0]), 'utf8'); + assert.ok(content.includes('Fix the login bug'), 'Should include first user message'); + assert.ok(content.includes('auth.ts'), 'Should include modified files'); + } + } finally { + cleanupTestDir(testDir); + } + })) passed++; else failed++; + + if (await asyncTest('session-end handles transcript with malformed lines gracefully', async () => { + const testDir = createTestDir(); + const transcriptPath = path.join(testDir, 'malformed-transcript.jsonl'); + + const lines = [ + JSON.stringify({ type: 'user', content: 'Task 1' }), + '{broken json here', + JSON.stringify({ type: 'user', content: 'Task 2' }), + '{"truncated":', + JSON.stringify({ type: 'user', content: 'Task 3' }) + ]; + fs.writeFileSync(transcriptPath, lines.join('\n')); + + try { + const result = await runHookWithInput( + path.join(scriptsDir, 'session-end.js'), + { transcript_path: transcriptPath }, + { HOME: testDir, USERPROFILE: testDir } + ); + + assert.strictEqual(result.code, 0, 'Should exit 0 despite malformed lines'); + // Should still process the valid lines + assert.ok(result.stderr.includes('[SessionEnd]'), 'Should have SessionEnd log'); + assert.ok(result.stderr.includes('unparseable'), 'Should warn about unparseable lines'); + } finally { + cleanupTestDir(testDir); + } + })) passed++; else failed++; + + if (await asyncTest('session-end creates session file with nested user messages', async () => { + const testDir = createTestDir(); + const transcriptPath = path.join(testDir, 'nested-transcript.jsonl'); + + // Claude Code JSONL format uses nested message.content arrays + const lines = [ + JSON.stringify({ type: 'user', message: { role: 'user', content: [ + { type: 'text', text: 'Refactor the utils module' } + ]}}), + JSON.stringify({ type: 'assistant', message: { content: [ + { type: 'tool_use', name: 'Read', input: { file_path: 'lib/utils.js' } } + ]}}), + JSON.stringify({ type: 'user', message: { role: 'user', content: 'Approve the changes' }}) + ]; + fs.writeFileSync(transcriptPath, lines.join('\n')); + + try { + const result = await runHookWithInput( + path.join(scriptsDir, 'session-end.js'), + { transcript_path: transcriptPath }, + { HOME: testDir, USERPROFILE: testDir } + ); + + assert.strictEqual(result.code, 0, 'Should exit 0'); + + // Check session file was created + const sessionsDir = path.join(testDir, '.claude', 'sessions'); + if (fs.existsSync(sessionsDir)) { + const files = fs.readdirSync(sessionsDir).filter(f => f.endsWith('.tmp')); + assert.ok(files.length > 0, 'Should create session file'); + const content = fs.readFileSync(path.join(sessionsDir, files[0]), 'utf8'); + assert.ok(content.includes('Refactor the utils module') || content.includes('Approve'), + 'Should extract user messages from nested format'); + } + } finally { + cleanupTestDir(testDir); + } + })) passed++; else failed++; + // ========================================== // Error Handling Tests // ========================================== diff --git a/tests/lib/package-manager.test.js b/tests/lib/package-manager.test.js index 6a4dd099..67195e24 100644 --- a/tests/lib/package-manager.test.js +++ b/tests/lib/package-manager.test.js @@ -738,6 +738,126 @@ function runTests() { assert.ok(pattern.includes('yarn build'), 'Should include yarn build'); })) passed++; else failed++; + // getRunCommand PM-specific format tests + console.log('\ngetRunCommand (PM-specific formats):'); + + if (test('pnpm custom script: pnpm