/** * Unit tests for JSONL Parser */ import { describe, expect, test, beforeEach, afterEach } from 'bun:test'; import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; import { parseUsageEntry, parseJsonlFile, parseProjectDirectory, scanProjectsDirectory, findProjectDirectories, countJsonlFiles, getDefaultProjectsDir, type RawUsageEntry, } from '../../src/web-server/jsonl-parser'; // ============================================================================ // TEST FIXTURES // ============================================================================ const VALID_ASSISTANT_ENTRY = JSON.stringify({ type: 'assistant', sessionId: 'test-session-123', timestamp: '2025-12-09T10:00:00.000Z', version: '2.0.60', cwd: '/home/user/project', message: { model: 'claude-sonnet-4-5', usage: { input_tokens: 1000, output_tokens: 500, cache_creation_input_tokens: 200, cache_read_input_tokens: 100, }, }, }); const ASSISTANT_ENTRY_NO_CACHE = JSON.stringify({ type: 'assistant', sessionId: 'test-session-456', timestamp: '2025-12-09T11:00:00.000Z', message: { model: 'gemini-2.5-pro', usage: { input_tokens: 2000, output_tokens: 1000, }, }, }); const USER_ENTRY = JSON.stringify({ type: 'user', sessionId: 'test-session-123', timestamp: '2025-12-09T09:59:00.000Z', message: { role: 'user', content: 'Hello world', }, }); const ASSISTANT_NO_USAGE = JSON.stringify({ type: 'assistant', sessionId: 'test-session-123', timestamp: '2025-12-09T10:01:00.000Z', message: { role: 'assistant', content: [{ type: 'text', text: 'response' }], }, }); const FILE_HISTORY_ENTRY = JSON.stringify({ type: 'file-history-snapshot', messageId: 'some-uuid', snapshot: {}, }); // ============================================================================ // parseUsageEntry Tests // ============================================================================ describe('parseUsageEntry', () => { test('parses valid assistant entry with full usage data', () => { const result = parseUsageEntry(VALID_ASSISTANT_ENTRY, '/home/user/project'); expect(result).not.toBeNull(); expect(result!.inputTokens).toBe(1000); expect(result!.outputTokens).toBe(500); expect(result!.cacheCreationTokens).toBe(200); expect(result!.cacheReadTokens).toBe(100); expect(result!.model).toBe('claude-sonnet-4-5'); expect(result!.sessionId).toBe('test-session-123'); expect(result!.timestamp).toBe('2025-12-09T10:00:00.000Z'); expect(result!.version).toBe('2.0.60'); }); test('parses assistant entry without cache tokens (defaults to 0)', () => { const result = parseUsageEntry(ASSISTANT_ENTRY_NO_CACHE, '/home/user/project'); expect(result).not.toBeNull(); expect(result!.inputTokens).toBe(2000); expect(result!.outputTokens).toBe(1000); expect(result!.cacheCreationTokens).toBe(0); expect(result!.cacheReadTokens).toBe(0); expect(result!.model).toBe('gemini-2.5-pro'); }); test('returns null for user entries', () => { const result = parseUsageEntry(USER_ENTRY, '/home/user/project'); expect(result).toBeNull(); }); test('returns null for assistant entries without usage data', () => { const result = parseUsageEntry(ASSISTANT_NO_USAGE, '/home/user/project'); expect(result).toBeNull(); }); test('returns null for file-history-snapshot entries', () => { const result = parseUsageEntry(FILE_HISTORY_ENTRY, '/home/user/project'); expect(result).toBeNull(); }); test('returns null for empty lines', () => { expect(parseUsageEntry('', '/test')).toBeNull(); expect(parseUsageEntry(' ', '/test')).toBeNull(); expect(parseUsageEntry('\n', '/test')).toBeNull(); }); test('returns null for malformed JSON', () => { expect(parseUsageEntry('{invalid json}', '/test')).toBeNull(); expect(parseUsageEntry('not json at all', '/test')).toBeNull(); }); test('includes project path in result', () => { const result = parseUsageEntry(VALID_ASSISTANT_ENTRY, '/custom/project/path'); expect(result!.projectPath).toBe('/custom/project/path'); }); }); // ============================================================================ // File Parsing Tests (with temp files) // ============================================================================ describe('parseJsonlFile', () => { let tempDir: string; beforeEach(() => { tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'jsonl-test-')); }); afterEach(() => { fs.rmSync(tempDir, { recursive: true, force: true }); }); test('parses file with mixed entry types', async () => { const filePath = path.join(tempDir, 'test.jsonl'); const content = [ USER_ENTRY, VALID_ASSISTANT_ENTRY, FILE_HISTORY_ENTRY, ASSISTANT_ENTRY_NO_CACHE, ASSISTANT_NO_USAGE, ].join('\n'); fs.writeFileSync(filePath, content); const entries = await parseJsonlFile(filePath, '/test/project'); // Only 2 valid assistant entries with usage expect(entries.length).toBe(2); expect(entries[0].model).toBe('claude-sonnet-4-5'); expect(entries[1].model).toBe('gemini-2.5-pro'); }); test('handles empty file', async () => { const filePath = path.join(tempDir, 'empty.jsonl'); fs.writeFileSync(filePath, ''); const entries = await parseJsonlFile(filePath, '/test'); expect(entries.length).toBe(0); }); test('returns empty array for non-existent file', async () => { const entries = await parseJsonlFile('/nonexistent/file.jsonl', '/test'); expect(entries.length).toBe(0); }); test('handles file with blank lines', async () => { const filePath = path.join(tempDir, 'blanks.jsonl'); const content = [ '', VALID_ASSISTANT_ENTRY, '', ' ', ASSISTANT_ENTRY_NO_CACHE, '', ].join('\n'); fs.writeFileSync(filePath, content); const entries = await parseJsonlFile(filePath, '/test'); expect(entries.length).toBe(2); }); }); // ============================================================================ // Directory Scanning Tests // ============================================================================ describe('parseProjectDirectory', () => { let tempDir: string; beforeEach(() => { tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'project-test-')); }); afterEach(() => { fs.rmSync(tempDir, { recursive: true, force: true }); }); test('parses all JSONL files in directory', async () => { // Create multiple JSONL files fs.writeFileSync(path.join(tempDir, 'session1.jsonl'), VALID_ASSISTANT_ENTRY); fs.writeFileSync(path.join(tempDir, 'session2.jsonl'), ASSISTANT_ENTRY_NO_CACHE); const entries = await parseProjectDirectory(tempDir); expect(entries.length).toBe(2); }); test('ignores non-JSONL files', async () => { fs.writeFileSync(path.join(tempDir, 'session.jsonl'), VALID_ASSISTANT_ENTRY); fs.writeFileSync(path.join(tempDir, 'readme.txt'), 'text file'); fs.writeFileSync(path.join(tempDir, 'data.json'), '{}'); const entries = await parseProjectDirectory(tempDir); expect(entries.length).toBe(1); }); test('returns empty array for non-existent directory', async () => { const entries = await parseProjectDirectory('/nonexistent/dir'); expect(entries.length).toBe(0); }); }); describe('findProjectDirectories', () => { let tempDir: string; beforeEach(() => { tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'projects-test-')); }); afterEach(() => { fs.rmSync(tempDir, { recursive: true, force: true }); }); test('finds all subdirectories', () => { fs.mkdirSync(path.join(tempDir, 'project-a')); fs.mkdirSync(path.join(tempDir, 'project-b')); fs.writeFileSync(path.join(tempDir, 'file.txt'), 'not a dir'); const dirs = findProjectDirectories(tempDir); expect(dirs.length).toBe(2); expect(dirs).toContain(path.join(tempDir, 'project-a')); expect(dirs).toContain(path.join(tempDir, 'project-b')); }); test('returns empty array for non-existent directory', () => { const dirs = findProjectDirectories('/nonexistent/path'); expect(dirs.length).toBe(0); }); }); describe('countJsonlFiles', () => { let tempDir: string; beforeEach(() => { tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'count-test-')); }); afterEach(() => { fs.rmSync(tempDir, { recursive: true, force: true }); }); test('counts JSONL files across multiple project directories', () => { const project1 = path.join(tempDir, 'project-a'); const project2 = path.join(tempDir, 'project-b'); fs.mkdirSync(project1); fs.mkdirSync(project2); fs.writeFileSync(path.join(project1, 'a.jsonl'), ''); fs.writeFileSync(path.join(project1, 'b.jsonl'), ''); fs.writeFileSync(path.join(project2, 'c.jsonl'), ''); fs.writeFileSync(path.join(project1, 'not-jsonl.txt'), ''); const count = countJsonlFiles(tempDir); expect(count).toBe(3); }); }); // ============================================================================ // scanProjectsDirectory Tests // ============================================================================ describe('scanProjectsDirectory', () => { let tempDir: string; beforeEach(() => { tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'scan-test-')); }); afterEach(() => { fs.rmSync(tempDir, { recursive: true, force: true }); }); test('scans all projects and aggregates entries', async () => { const project1 = path.join(tempDir, '-home-user-project1'); const project2 = path.join(tempDir, '-home-user-project2'); fs.mkdirSync(project1); fs.mkdirSync(project2); fs.writeFileSync(path.join(project1, 'session.jsonl'), VALID_ASSISTANT_ENTRY); fs.writeFileSync(path.join(project2, 'session.jsonl'), ASSISTANT_ENTRY_NO_CACHE); const entries = await scanProjectsDirectory({ projectsDir: tempDir }); expect(entries.length).toBe(2); }); test('filters by minDate', async () => { const project = path.join(tempDir, '-test-project'); fs.mkdirSync(project); const oldEntry = JSON.stringify({ type: 'assistant', sessionId: 'old', timestamp: '2024-01-01T00:00:00.000Z', message: { model: 'claude-sonnet-4-5', usage: { input_tokens: 100, output_tokens: 50 } }, }); const newEntry = JSON.stringify({ type: 'assistant', sessionId: 'new', timestamp: '2025-12-09T00:00:00.000Z', message: { model: 'claude-sonnet-4-5', usage: { input_tokens: 200, output_tokens: 100 } }, }); fs.writeFileSync(path.join(project, 'session.jsonl'), [oldEntry, newEntry].join('\n')); const entries = await scanProjectsDirectory({ projectsDir: tempDir, minDate: new Date('2025-01-01'), }); expect(entries.length).toBe(1); expect(entries[0].sessionId).toBe('new'); }); test('returns empty array for empty directory', async () => { const entries = await scanProjectsDirectory({ projectsDir: tempDir }); expect(entries.length).toBe(0); }); test('respects concurrency option', async () => { // Create 5 projects for (let i = 0; i < 5; i++) { const project = path.join(tempDir, `-project-${i}`); fs.mkdirSync(project); fs.writeFileSync(path.join(project, 'session.jsonl'), VALID_ASSISTANT_ENTRY); } // Should still work with concurrency of 2 const entries = await scanProjectsDirectory({ projectsDir: tempDir, concurrency: 2, }); expect(entries.length).toBe(5); }); }); // ============================================================================ // getDefaultProjectsDir Tests // ============================================================================ describe('getDefaultProjectsDir', () => { const originalEnv = process.env.CLAUDE_CONFIG_DIR; afterEach(() => { if (originalEnv === undefined) { delete process.env.CLAUDE_CONFIG_DIR; } else { process.env.CLAUDE_CONFIG_DIR = originalEnv; } }); test('uses CLAUDE_CONFIG_DIR env var if set', () => { process.env.CLAUDE_CONFIG_DIR = '/custom/claude'; const dir = getDefaultProjectsDir(); expect(dir).toBe('/custom/claude/projects'); }); test('falls back to ~/.claude/projects', () => { delete process.env.CLAUDE_CONFIG_DIR; const dir = getDefaultProjectsDir(); expect(dir).toBe(path.join(os.homedir(), '.claude', 'projects')); }); });