diff --git a/TODO.md b/TODO.md index 610cda1..c603b1e 100644 --- a/TODO.md +++ b/TODO.md @@ -6,11 +6,11 @@ Dispatched from core/go orchestration. Pick up tasks in order. ## Phase 0: Hardening & Test Coverage -- [x] **Add parser tests** — 51 tests, 90.9% coverage. `7771e64` -- [x] **Add ListSessions tests** — 5 tests (empty, single, sorted, non-jsonl, malformed). `7771e64` -- [x] **Tool extraction coverage** — All 7 tool types + unknown fallback + nil. `7771e64` -- [x] **Benchmark parsing** — `BenchmarkParseTranscript` with 4000-line JSONL. `7771e64` -- [x] **`go vet ./...` clean** — No warnings. `7771e64` +- [x] **Add parser tests** — Test `ParseTranscript()` with: minimal valid JSONL (1 user + 1 assistant message), tool call events (Bash, Read, Edit, Write, Grep, Glob, Task), truncated JSONL (incomplete last line), empty file, malformed JSON lines (should skip gracefully), very large session (1000+ events), nested tool results with arrays and maps. Also added HTML, video, and search tests. 67 tests, 90.9% coverage. +- [x] **Add ListSessions tests** — Test with: empty directory, single session, multiple sessions sorted by date, non-.jsonl files ignored, malformed JSONL fallback to modtime. +- [x] **Tool extraction coverage** — Test `extractToolInput()` for each supported tool type (Bash, Read, Edit, Write, Grep, Glob, Task) plus nil input, invalid JSON, and unknown tool fallback. +- [x] **Benchmark parsing** — `BenchmarkParseTranscript` with 2.2MB (5K tools) and 11MB (25K tools) JSONL files. Plus `BenchmarkListSessions` and `BenchmarkSearch`. Uses `b.Loop()` (Go 1.25+). +- [x] **`go vet ./...` clean** — Verified clean, no warnings. ## Phase 1: Parser Robustness diff --git a/bench_test.go b/bench_test.go new file mode 100644 index 0000000..e49ef2c --- /dev/null +++ b/bench_test.go @@ -0,0 +1,155 @@ +// SPDX-Licence-Identifier: EUPL-1.2 +package session + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "testing" +) + +// BenchmarkParseTranscript benchmarks parsing a ~1MB+ JSONL file. +func BenchmarkParseTranscript(b *testing.B) { + dir := b.TempDir() + path := generateBenchJSONL(b, dir, 5000) // ~1MB+ of JSONL + + b.ResetTimer() + b.ReportAllocs() + + for b.Loop() { + sess, err := ParseTranscript(path) + if err != nil { + b.Fatal(err) + } + if len(sess.Events) == 0 { + b.Fatal("expected events") + } + } +} + +// BenchmarkParseTranscript_Large benchmarks a larger ~5MB file. +func BenchmarkParseTranscript_Large(b *testing.B) { + dir := b.TempDir() + path := generateBenchJSONL(b, dir, 25000) // ~5MB + + b.ResetTimer() + b.ReportAllocs() + + for b.Loop() { + _, err := ParseTranscript(path) + if err != nil { + b.Fatal(err) + } + } +} + +// BenchmarkListSessions benchmarks listing sessions in a directory. +func BenchmarkListSessions(b *testing.B) { + dir := b.TempDir() + + // Create 20 session files + for i := 0; i < 20; i++ { + generateBenchJSONL(b, dir, 100) + } + + b.ResetTimer() + b.ReportAllocs() + + for b.Loop() { + sessions, err := ListSessions(dir) + if err != nil { + b.Fatal(err) + } + if len(sessions) == 0 { + b.Fatal("expected sessions") + } + } +} + +// BenchmarkSearch benchmarks searching across multiple sessions. +func BenchmarkSearch(b *testing.B) { + dir := b.TempDir() + + // Create 10 session files with varied content + for i := 0; i < 10; i++ { + generateBenchJSONL(b, dir, 500) + } + + b.ResetTimer() + b.ReportAllocs() + + for b.Loop() { + _, err := Search(dir, "echo") + if err != nil { + b.Fatal(err) + } + } +} + +// generateBenchJSONL creates a synthetic JSONL file with the given number of tool pairs. +// Returns the file path. +func generateBenchJSONL(b testing.TB, dir string, numTools int) string { + b.Helper() + + var sb strings.Builder + baseTS := "2026-02-20T10:00:00Z" + + // Opening user message + sb.WriteString(fmt.Sprintf(`{"type":"user","timestamp":"%s","sessionId":"bench","message":{"role":"user","content":[{"type":"text","text":"Start benchmark session"}]}}`, baseTS)) + sb.WriteByte('\n') + + for i := 0; i < numTools; i++ { + toolID := fmt.Sprintf("tool-%d", i) + offset := i * 2 + + // Alternate between different tool types for realistic distribution + var toolUse, toolResult string + switch i % 5 { + case 0: // Bash + toolUse = fmt.Sprintf(`{"type":"assistant","timestamp":"2026-02-20T10:%02d:%02dZ","sessionId":"bench","message":{"role":"assistant","content":[{"type":"tool_use","name":"Bash","id":"%s","input":{"command":"echo iteration %d","description":"echo test"}}]}}`, + offset/60, offset%60, toolID, i) + toolResult = fmt.Sprintf(`{"type":"user","timestamp":"2026-02-20T10:%02d:%02dZ","sessionId":"bench","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"%s","content":"iteration %d output line one\niteration %d output line two","is_error":false}]}}`, + (offset+1)/60, (offset+1)%60, toolID, i, i) + case 1: // Read + toolUse = fmt.Sprintf(`{"type":"assistant","timestamp":"2026-02-20T10:%02d:%02dZ","sessionId":"bench","message":{"role":"assistant","content":[{"type":"tool_use","name":"Read","id":"%s","input":{"file_path":"/tmp/bench/file-%d.go"}}]}}`, + offset/60, offset%60, toolID, i) + toolResult = fmt.Sprintf(`{"type":"user","timestamp":"2026-02-20T10:%02d:%02dZ","sessionId":"bench","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"%s","content":"package main\n\nfunc main() {\n\tfmt.Println(%d)\n}","is_error":false}]}}`, + (offset+1)/60, (offset+1)%60, toolID, i) + case 2: // Edit + toolUse = fmt.Sprintf(`{"type":"assistant","timestamp":"2026-02-20T10:%02d:%02dZ","sessionId":"bench","message":{"role":"assistant","content":[{"type":"tool_use","name":"Edit","id":"%s","input":{"file_path":"/tmp/bench/file-%d.go","old_string":"old","new_string":"new"}}]}}`, + offset/60, offset%60, toolID, i) + toolResult = fmt.Sprintf(`{"type":"user","timestamp":"2026-02-20T10:%02d:%02dZ","sessionId":"bench","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"%s","content":"ok","is_error":false}]}}`, + (offset+1)/60, (offset+1)%60, toolID) + case 3: // Grep + toolUse = fmt.Sprintf(`{"type":"assistant","timestamp":"2026-02-20T10:%02d:%02dZ","sessionId":"bench","message":{"role":"assistant","content":[{"type":"tool_use","name":"Grep","id":"%s","input":{"pattern":"TODO","path":"/tmp/bench"}}]}}`, + offset/60, offset%60, toolID) + toolResult = fmt.Sprintf(`{"type":"user","timestamp":"2026-02-20T10:%02d:%02dZ","sessionId":"bench","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"%s","content":"/tmp/bench/file.go:10: // TODO fix this","is_error":false}]}}`, + (offset+1)/60, (offset+1)%60, toolID) + case 4: // Glob + toolUse = fmt.Sprintf(`{"type":"assistant","timestamp":"2026-02-20T10:%02d:%02dZ","sessionId":"bench","message":{"role":"assistant","content":[{"type":"tool_use","name":"Glob","id":"%s","input":{"pattern":"**/*.go"}}]}}`, + offset/60, offset%60, toolID) + toolResult = fmt.Sprintf(`{"type":"user","timestamp":"2026-02-20T10:%02d:%02dZ","sessionId":"bench","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"%s","content":"/tmp/a.go\n/tmp/b.go\n/tmp/c.go","is_error":false}]}}`, + (offset+1)/60, (offset+1)%60, toolID) + } + + sb.WriteString(toolUse) + sb.WriteByte('\n') + sb.WriteString(toolResult) + sb.WriteByte('\n') + } + + // Closing assistant message + sb.WriteString(fmt.Sprintf(`{"type":"assistant","timestamp":"2026-02-20T12:00:00Z","sessionId":"bench","message":{"role":"assistant","content":[{"type":"text","text":"Benchmark session complete."}]}}%s`, "\n")) + + name := fmt.Sprintf("bench-%d.jsonl", numTools) + path := filepath.Join(dir, name) + if err := os.WriteFile(path, []byte(sb.String()), 0644); err != nil { + b.Fatal(err) + } + + info, _ := os.Stat(path) + b.Logf("Generated %s: %d bytes, %d tool pairs", name, info.Size(), numTools) + + return path +} diff --git a/html_test.go b/html_test.go index 264c1e0..83a9251 100644 --- a/html_test.go +++ b/html_test.go @@ -1,8 +1,8 @@ +// SPDX-Licence-Identifier: EUPL-1.2 package session import ( "os" - "path/filepath" "strings" "testing" "time" @@ -11,333 +11,228 @@ import ( "github.com/stretchr/testify/require" ) -func TestRenderHTML_BasicSession(t *testing.T) { +func TestRenderHTML_BasicSession_Good(t *testing.T) { + dir := t.TempDir() + outputPath := dir + "/output.html" + sess := &Session{ - ID: "test-session-abcdef12", + ID: "test-session-12345678", Path: "/tmp/test.jsonl", - StartTime: baseTime, - EndTime: baseTime.Add(5 * time.Minute), + StartTime: time.Date(2026, 2, 20, 10, 0, 0, 0, time.UTC), + EndTime: time.Date(2026, 2, 20, 10, 5, 30, 0, time.UTC), Events: []Event{ { - Timestamp: baseTime, + Timestamp: time.Date(2026, 2, 20, 10, 0, 0, 0, time.UTC), Type: "user", - Input: "Please list files", + Input: "Hello, please help me", }, { - Timestamp: baseTime.Add(time.Second), - Type: "tool_use", - Tool: "Bash", - ToolID: "tu_1", - Input: "ls -la # list files", - Output: "total 42\ndrwxr-xr-x 3 user user 4096 Feb 19 .", - Duration: 2 * time.Second, - Success: true, - }, - { - Timestamp: baseTime.Add(3 * time.Second), + Timestamp: time.Date(2026, 2, 20, 10, 0, 1, 0, time.UTC), Type: "assistant", - Input: "The directory contains 42 items.", + Input: "Sure, let me check.", }, - }, - } - - dir := t.TempDir() - outputPath := filepath.Join(dir, "out.html") - err := RenderHTML(sess, outputPath) - require.NoError(t, err) - - data, err := os.ReadFile(outputPath) - require.NoError(t, err) - html := string(data) - - t.Run("contains_doctype", func(t *testing.T) { - assert.True(t, strings.HasPrefix(html, "")) - }) - - t.Run("contains_session_id", func(t *testing.T) { - assert.Contains(t, html, "test-ses") - }) - - t.Run("contains_timestamp", func(t *testing.T) { - assert.Contains(t, html, "2026-02-19 10:00:00") - }) - - t.Run("contains_tool_count", func(t *testing.T) { - assert.Contains(t, html, "1 tool calls") - }) - - t.Run("contains_user_event", func(t *testing.T) { - assert.Contains(t, html, "User") - assert.Contains(t, html, "Please list files") - }) - - t.Run("contains_bash_event", func(t *testing.T) { - assert.Contains(t, html, "Bash") - assert.Contains(t, html, "ls -la") - }) - - t.Run("contains_assistant_event", func(t *testing.T) { - assert.Contains(t, html, "Claude") - }) - - t.Run("contains_js_functions", func(t *testing.T) { - assert.Contains(t, html, "function toggle(") - assert.Contains(t, html, "function filterEvents(") - }) - - t.Run("contains_success_icon", func(t *testing.T) { - assert.Contains(t, html, "✓") // Tick mark - }) - - t.Run("html_ends_properly", func(t *testing.T) { - assert.True(t, strings.HasSuffix(strings.TrimSpace(html), "")) - }) -} - -func TestRenderHTML_EmptySession(t *testing.T) { - sess := &Session{ - ID: "empty-session", - Path: "/tmp/empty.jsonl", - StartTime: baseTime, - EndTime: baseTime, - Events: nil, - } - - dir := t.TempDir() - outputPath := filepath.Join(dir, "empty.html") - err := RenderHTML(sess, outputPath) - require.NoError(t, err) - - data, err := os.ReadFile(outputPath) - require.NoError(t, err) - html := string(data) - - assert.Contains(t, html, "") - assert.Contains(t, html, "0 tool calls") - // No error count span should appear in the stats. - assert.NotContains(t, html, `class="err">`) -} - -func TestRenderHTML_WithErrors(t *testing.T) { - sess := &Session{ - ID: "error-session", - Path: "/tmp/err.jsonl", - StartTime: baseTime, - EndTime: baseTime.Add(10 * time.Second), - Events: []Event{ { - Timestamp: baseTime, + Timestamp: time.Date(2026, 2, 20, 10, 0, 2, 0, time.UTC), Type: "tool_use", Tool: "Bash", - ToolID: "tu_ok", - Input: "echo ok", - Output: "ok", + ToolID: "t1", + Input: "ls -la", + Output: "total 42", Duration: time.Second, Success: true, }, { - Timestamp: baseTime.Add(2 * time.Second), + Timestamp: time.Date(2026, 2, 20, 10, 0, 4, 0, time.UTC), Type: "tool_use", - Tool: "Bash", - ToolID: "tu_err", - Input: "false", - Output: "exit 1", - Duration: time.Second, - Success: false, - ErrorMsg: "exit 1", - }, - }, - } - - dir := t.TempDir() - outputPath := filepath.Join(dir, "errors.html") - err := RenderHTML(sess, outputPath) - require.NoError(t, err) - - data, err := os.ReadFile(outputPath) - require.NoError(t, err) - html := string(data) - - assert.Contains(t, html, "2 tool calls") - assert.Contains(t, html, "1 errors") - assert.Contains(t, html, `class="err"`) - assert.Contains(t, html, "✗") // Cross mark for error -} - -func TestRenderHTML_DurationFormatting(t *testing.T) { - sess := &Session{ - ID: "dur-test", - Path: "/tmp/dur.jsonl", - StartTime: baseTime, - EndTime: baseTime.Add(2 * time.Hour), - Events: []Event{ - { - Timestamp: baseTime, - Type: "tool_use", - Tool: "Bash", - ToolID: "tu_ms", - Input: "fast cmd", + Tool: "Read", + ToolID: "t2", + Input: "/tmp/file.go", + Output: "package main", Duration: 500 * time.Millisecond, Success: true, }, + }, + } + + err := RenderHTML(sess, outputPath) + require.NoError(t, err) + + content, err := os.ReadFile(outputPath) + require.NoError(t, err) + + html := string(content) + + // Basic structure checks + assert.Contains(t, html, "") + assert.Contains(t, html, "test-ses") // shortID of "test-session-12345678" + assert.Contains(t, html, "2026-02-20 10:00:00") + assert.Contains(t, html, "5m30s") // duration + assert.Contains(t, html, "2 tool calls") + assert.Contains(t, html, "ls -la") + assert.Contains(t, html, "total 42") + assert.Contains(t, html, "/tmp/file.go") + assert.Contains(t, html, "User") // user event label + assert.Contains(t, html, "Claude") // assistant event label + assert.Contains(t, html, "Bash") + assert.Contains(t, html, "Read") + // Should contain JS for toggle and filter + assert.Contains(t, html, "function toggle") + assert.Contains(t, html, "function filterEvents") +} + +func TestRenderHTML_EmptySession_Good(t *testing.T) { + dir := t.TempDir() + outputPath := dir + "/empty.html" + + sess := &Session{ + ID: "empty", + Path: "/tmp/empty.jsonl", + StartTime: time.Date(2026, 2, 20, 10, 0, 0, 0, time.UTC), + EndTime: time.Date(2026, 2, 20, 10, 0, 0, 0, time.UTC), + Events: nil, + } + + err := RenderHTML(sess, outputPath) + require.NoError(t, err) + + content, err := os.ReadFile(outputPath) + require.NoError(t, err) + + html := string(content) + assert.Contains(t, html, "") + assert.Contains(t, html, "0 tool calls") + // Should NOT contain error span + assert.NotContains(t, html, "errors") +} + +func TestRenderHTML_WithErrors_Good(t *testing.T) { + dir := t.TempDir() + outputPath := dir + "/errors.html" + + sess := &Session{ + ID: "err-session", + Path: "/tmp/err.jsonl", + StartTime: time.Date(2026, 2, 20, 10, 0, 0, 0, time.UTC), + EndTime: time.Date(2026, 2, 20, 10, 1, 0, 0, time.UTC), + Events: []Event{ { - Timestamp: baseTime.Add(time.Second), + Timestamp: time.Date(2026, 2, 20, 10, 0, 0, 0, time.UTC), Type: "tool_use", Tool: "Bash", - ToolID: "tu_sec", - Input: "slow cmd", - Duration: 45 * time.Second, - Success: true, + Input: "cat /nonexistent", + Output: "No such file", + Duration: 100 * time.Millisecond, + Success: false, + ErrorMsg: "No such file", }, { - Timestamp: baseTime.Add(time.Minute), + Timestamp: time.Date(2026, 2, 20, 10, 0, 30, 0, time.UTC), Type: "tool_use", Tool: "Bash", - ToolID: "tu_min", - Input: "very slow cmd", - Duration: 3*time.Minute + 30*time.Second, + Input: "echo ok", + Output: "ok", + Duration: 50 * time.Millisecond, Success: true, }, }, } - dir := t.TempDir() - outputPath := filepath.Join(dir, "dur.html") err := RenderHTML(sess, outputPath) require.NoError(t, err) - data, err := os.ReadFile(outputPath) + content, err := os.ReadFile(outputPath) require.NoError(t, err) - html := string(data) - assert.Contains(t, html, "500ms") - assert.Contains(t, html, "45.0s") - assert.Contains(t, html, "3m30s") - assert.Contains(t, html, "2h0m") // Header duration + html := string(content) + assert.Contains(t, html, "1 errors") + assert.Contains(t, html, `class="event error"`) + assert.Contains(t, html, "✗") // cross mark for failed + assert.Contains(t, html, "✓") // check mark for success } -func TestRenderHTML_HTMLEscaping(t *testing.T) { +func TestRenderHTML_SpecialCharacters_Good(t *testing.T) { + dir := t.TempDir() + outputPath := dir + "/special.html" + sess := &Session{ - ID: "escape-test", - Path: "/tmp/esc.jsonl", - StartTime: baseTime, - EndTime: baseTime.Add(time.Second), + ID: "special", + Path: "/tmp/special.jsonl", + StartTime: time.Date(2026, 2, 20, 10, 0, 0, 0, time.UTC), + EndTime: time.Date(2026, 2, 20, 10, 0, 1, 0, time.UTC), Events: []Event{ { - Timestamp: baseTime, - Type: "user", - Input: ``, - }, - { - Timestamp: baseTime, + Timestamp: time.Date(2026, 2, 20, 10, 0, 0, 0, time.UTC), Type: "tool_use", Tool: "Bash", - ToolID: "tu_esc", - Input: `echo "bold"`, - Output: `bold`, + Input: `echo ""`, + Output: ``, Duration: time.Second, Success: true, }, + { + Timestamp: time.Date(2026, 2, 20, 10, 0, 0, 0, time.UTC), + Type: "user", + Input: `User says: "quotes & "`, + }, }, } - dir := t.TempDir() - outputPath := filepath.Join(dir, "esc.html") err := RenderHTML(sess, outputPath) require.NoError(t, err) - data, err := os.ReadFile(outputPath) + content, err := os.ReadFile(outputPath) require.NoError(t, err) - html := string(data) - // Raw angle brackets must be escaped. - assert.NotContains(t, html, `