diff --git a/FINDINGS.md b/FINDINGS.md index a491c9a..0b08a64 100644 --- a/FINDINGS.md +++ b/FINDINGS.md @@ -14,8 +14,27 @@ Extracted from `forge.lthn.ai/core/go` `pkg/session/` on 19 Feb 2026. ### Dependencies -- Zero external dependencies -- standard library only (`encoding/json`, `bufio`, `os`) +- Zero external dependencies at runtime -- standard library only (`encoding/json`, `bufio`, `os`) +- Test dependency: `github.com/stretchr/testify` (assert/require) ### Tests - Test coverage for JSONL parsing and event type detection + +## 2026-02-20: Phase 0 Hardening (Charon) + +### Test Coverage + +- 51 tests across 4 test files, 90.9% statement coverage +- `parser_test.go` — 13 tests + 12 extractToolInput subtests + 5 extractResultContent subtests + 5 truncate subtests + 5 ListSessions tests + benchmark +- `html_test.go` — 7 RenderHTML tests + 4 shortID subtests + 6 formatDuration subtests +- `search_test.go` — 9 Search tests covering cross-session matching, case insensitivity, empty dirs, malformed sessions +- `video_test.go` — 8 generateTape tests + 1 RenderMP4 error test + 5 extractCommand subtests + +### Observations + +- `extractCommand()` naively splits on first ` # ` — commands containing literal ` # ` (e.g. inside quotes) get truncated. Documented in test, not a bug per se since the parser always constructs `command + " # " + description`. +- `RenderMP4()` is untestable without the external `vhs` binary. Tests cover `generateTape()` (the pure logic) and verify `RenderMP4` returns a clear error when vhs is absent. +- `extractResultContent()` handles string, `[]interface{}`, and `map[string]interface{}` content types. All three paths plus nil are tested. +- `ListSessions` falls back to file mod time when no valid timestamps are found in a JSONL file. +- `go vet ./...` was clean from the start — no fixes needed. diff --git a/TODO.md b/TODO.md index c18b162..789fc7b 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 -- [ ] **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. -- [ ] **Add ListSessions tests** — Test with: empty directory, single session, multiple sessions sorted by date, non-.jsonl files ignored. -- [ ] **Tool extraction coverage** — Test `extractToolInput()` for each supported tool type. Verify correct field extraction from JSON input. -- [ ] **Benchmark parsing** — `BenchmarkParseTranscript` with a 10MB JSONL file. Measure memory and time. -- [ ] **`go vet ./...` clean** — Fix any warnings. +- [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: HTML renderer tests, video/tape generator tests, search tests. 51 tests total, 90.9% coverage. +- [x] **Add ListSessions tests** — Test with: empty directory, single session, multiple sessions sorted by date, non-.jsonl files ignored, malformed content fallback. +- [x] **Tool extraction coverage** — Test `extractToolInput()` for all 7 supported tool types plus unknown tool fallback and nil input. Test `extractResultContent()` for string, array, map, and nil content. +- [x] **Benchmark parsing** — `BenchmarkParseTranscript` with 4000-line JSONL (2000 assistant + 2000 user entries). +- [x] **`go vet ./...` clean** — No warnings. ## Phase 1: Parser Robustness diff --git a/go.mod b/go.mod index 61c9768..cc68994 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,11 @@ module forge.lthn.ai/core/go-session go 1.25.5 + +require github.com/stretchr/testify v1.11.1 + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..c4c1710 --- /dev/null +++ b/go.sum @@ -0,0 +1,10 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/html_test.go b/html_test.go new file mode 100644 index 0000000..264c1e0 --- /dev/null +++ b/html_test.go @@ -0,0 +1,343 @@ +package session + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestRenderHTML_BasicSession(t *testing.T) { + sess := &Session{ + ID: "test-session-abcdef12", + Path: "/tmp/test.jsonl", + StartTime: baseTime, + EndTime: baseTime.Add(5 * time.Minute), + Events: []Event{ + { + Timestamp: baseTime, + Type: "user", + Input: "Please list files", + }, + { + 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), + Type: "assistant", + Input: "The directory contains 42 items.", + }, + }, + } + + 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, + Type: "tool_use", + Tool: "Bash", + ToolID: "tu_ok", + Input: "echo ok", + Output: "ok", + Duration: time.Second, + Success: true, + }, + { + Timestamp: baseTime.Add(2 * time.Second), + 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", + Duration: 500 * time.Millisecond, + Success: true, + }, + { + Timestamp: baseTime.Add(time.Second), + Type: "tool_use", + Tool: "Bash", + ToolID: "tu_sec", + Input: "slow cmd", + Duration: 45 * time.Second, + Success: true, + }, + { + Timestamp: baseTime.Add(time.Minute), + Type: "tool_use", + Tool: "Bash", + ToolID: "tu_min", + Input: "very slow cmd", + Duration: 3*time.Minute + 30*time.Second, + Success: true, + }, + }, + } + + dir := t.TempDir() + outputPath := filepath.Join(dir, "dur.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, "500ms") + assert.Contains(t, html, "45.0s") + assert.Contains(t, html, "3m30s") + assert.Contains(t, html, "2h0m") // Header duration +} + +func TestRenderHTML_HTMLEscaping(t *testing.T) { + sess := &Session{ + ID: "escape-test", + Path: "/tmp/esc.jsonl", + StartTime: baseTime, + EndTime: baseTime.Add(time.Second), + Events: []Event{ + { + Timestamp: baseTime, + Type: "user", + Input: ``, + }, + { + Timestamp: baseTime, + Type: "tool_use", + Tool: "Bash", + ToolID: "tu_esc", + Input: `echo "bold"`, + Output: `bold`, + Duration: time.Second, + Success: true, + }, + }, + } + + dir := t.TempDir() + outputPath := filepath.Join(dir, "esc.html") + err := RenderHTML(sess, outputPath) + require.NoError(t, err) + + data, err := os.ReadFile(outputPath) + require.NoError(t, err) + html := string(data) + + // Raw angle brackets must be escaped. + assert.NotContains(t, html, `