diff options
Diffstat (limited to 'vrsite/transcript_test.go')
| -rw-r--r-- | vrsite/transcript_test.go | 333 |
1 files changed, 0 insertions, 333 deletions
diff --git a/vrsite/transcript_test.go b/vrsite/transcript_test.go deleted file mode 100644 index 5e83ef4..0000000 --- a/vrsite/transcript_test.go +++ /dev/null @@ -1,333 +0,0 @@ -package main - -import ( - "fmt" - "os" - "path/filepath" - "strings" - "testing" - "time" -) - -// Both fixtures are the shapes the real files use: Claude Code writes one -// record per message with content blocks, codex wraps everything in a payload -// and keeps the model's history in response_items. -const claudeJSONL = `{"type":"user","timestamp":"2026-07-20T09:57:00.000Z","sessionId":"s1","cwd":"/repo","message":{"role":"user","content":"read lib.rs and tell me about the cache"}} -{"type":"assistant","timestamp":"2026-07-20T09:57:30.000Z","message":{"role":"assistant","model":"claude-opus-5","content":[{"type":"thinking","thinking":"start with the struct","signature":"CAIS"},{"type":"text","text":"Reading it now."},{"type":"tool_use","id":"toolu_1","name":"Bash","input":{"command":"vr read src/lib.rs:1-30","description":"read the file"}}]}} -{"type":"user","timestamp":"2026-07-20T09:59:10.000Z","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_1","content":"1\tuse std::collections::HashMap;\n2\t\n3\tstruct Cache;\n"}]}} -{"type":"mode","timestamp":"2026-07-20T09:59:11.000Z","mode":"default"} -{"type":"assistant","timestamp":"2026-07-20T09:59:40.000Z","message":{"role":"assistant","content":[{"type":"tool_use","id":"toolu_2","name":"Read","input":{"file_path":"/repo/src/main.rs"}}]}} -{"type":"user","timestamp":"2026-07-20T09:59:45.000Z","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_2","is_error":true,"content":[{"type":"text","text":"no such file"}]}]}} -{"type":"assistant","timestamp":"2026-07-20T10:02:30.000Z","message":{"role":"assistant","content":[{"type":"text","text":"It is a two-level map."}]}} -` - -const codexJSONL = `{"timestamp":"2026-07-20T12:00:00.000Z","type":"session_meta","payload":{"id":"019f0000-0000-7000-8000-00000000abcd","cwd":"/repo"}} -{"timestamp":"2026-07-20T12:00:01.000Z","type":"response_item","payload":{"type":"message","role":"developer","content":[{"type":"input_text","text":"You are Codex, an agent."}]}} -{"timestamp":"2026-07-20T12:00:02.000Z","type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"<environment_context>\n <cwd>/repo</cwd>\n</environment_context>"}]}} -{"timestamp":"2026-07-20T12:00:03.000Z","type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"review the cache"}]}} -{"timestamp":"2026-07-20T12:00:04.000Z","type":"response_item","payload":{"type":"reasoning","summary":[{"type":"summary_text","text":"start with lib.rs"}],"encrypted_content":"gAAAA"}} -{"timestamp":"2026-07-20T12:00:05.000Z","type":"response_item","payload":{"type":"function_call","name":"exec_command","call_id":"call_1","arguments":"{\"cmd\":\"vr read src/lib.rs\",\"workdir\":\"/repo\"}"}} -{"timestamp":"2026-07-20T12:00:06.000Z","type":"response_item","payload":{"type":"function_call_output","call_id":"call_1","output":"Chunk ID: abc123\nOutput:\n1\tstruct Cache;\n"}} -{"timestamp":"2026-07-20T12:00:07.000Z","type":"event_msg","payload":{"type":"agent_message","message":"the cache is two-level"}} -{"timestamp":"2026-07-20T12:00:08.000Z","type":"response_item","payload":{"type":"custom_tool_call","name":"apply_patch","call_id":"call_2","input":"*** Begin Patch\n*** Update File: src/lib.rs\n@@\n-old\n+new\n"}} -{"timestamp":"2026-07-20T12:00:09.000Z","type":"response_item","payload":{"type":"custom_tool_call_output","call_id":"call_2","output":"Exit code: 0\n"}} -{"timestamp":"2026-07-20T12:00:10.000Z","type":"response_item","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"The cache is two-level."}]}} -` - -func writeFile(t *testing.T, path, body string) string { - t.Helper() - if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(path, []byte(body), 0o644); err != nil { - t.Fatal(err) - } - return path -} - -func turnLine(tn Turn) string { - s := tn.Role - if tn.Tool != "" { - s += " " + tn.Tool - } - if tn.Text != "" { - s += " " + tn.Text - } - if tn.Detail != "" { - s += " " + tn.Detail - } - return s -} - -func TestParseClaudeTranscript(t *testing.T) { - path := writeFile(t, filepath.Join(t.TempDir(), "s.jsonl"), claudeJSONL) - tr := readTranscript(path, int64(len(claudeJSONL))) - if tr.Harness != "claude-code" || tr.Err != "" { - t.Fatalf("harness = %q err = %q", tr.Harness, tr.Err) - } - want := []string{ - "user read lib.rs and tell me about the cache", - "thinking start with the struct", - "assistant Reading it now.", - "tool Bash vr read src/lib.rs:1-30", - "result Bash 1\tuse std::collections::HashMap; … +2 lines", - "tool Read /repo/src/main.rs", - "result Read no such file", - "assistant It is a two-level map.", - } - var got []string - for _, tn := range tr.Turns { - got = append(got, turnLine(tn)) - } - if strings.Join(got, "\n") != strings.Join(want, "\n") { - t.Errorf("turns:\n%s\nwant:\n%s", strings.Join(got, "\n"), strings.Join(want, "\n")) - } - if tr.Turns[6].Kind != "error" { - t.Error("a failed tool result is not marked") - } - if at := tr.Turns[0].When; at.Format(time.RFC3339) != "2026-07-20T09:57:00Z" { - t.Errorf("first turn at %v", at) - } -} - -func TestParseCodexTranscript(t *testing.T) { - path := writeFile(t, filepath.Join(t.TempDir(), "r.jsonl"), codexJSONL) - tr := readTranscript(path, int64(len(codexJSONL))) - if tr.Harness != "codex" || tr.Err != "" { - t.Fatalf("harness = %q err = %q", tr.Harness, tr.Err) - } - want := []string{ - "user review the cache", - "thinking start with lib.rs", - "tool exec_command vr read src/lib.rs", - "result exec_command Chunk ID: abc123 … +2 lines", - "tool apply_patch *** Begin Patch … +4 lines", - "result apply_patch Exit code: 0", - "assistant The cache is two-level.", - } - var got []string - for _, tn := range tr.Turns { - got = append(got, turnLine(tn)) - } - if strings.Join(got, "\n") != strings.Join(want, "\n") { - t.Errorf("turns:\n%s\nwant:\n%s", strings.Join(got, "\n"), strings.Join(want, "\n")) - } -} - -// Tool output is what makes these files megabytes; a page gets one line of it. -func TestToolOutputIsSummarized(t *testing.T) { - huge := strings.Repeat("x", 400) + "\n" + strings.Repeat("line\n", 50000) - line := fmt.Sprintf(`{"type":"user","timestamp":"2026-07-20T10:00:00.000Z","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"t","content":%q}]}}`, huge) - path := writeFile(t, filepath.Join(t.TempDir(), "big.jsonl"), line+"\n") - tr := readTranscript(path, int64(len(line)+1)) - if len(tr.Turns) != 1 { - t.Fatalf("turns = %d", len(tr.Turns)) - } - if n := len(tr.Turns[0].Detail); n > 4*briefLine { - t.Errorf("summary of a 250 KB result is %d bytes", n) - } - if !strings.Contains(tr.Turns[0].Detail, "+50000 lines") { - t.Errorf("summary does not say what was left out: %q", tr.Turns[0].Detail) - } -} - -func TestTranscriptTooLargeIsRefused(t *testing.T) { - path := writeFile(t, filepath.Join(t.TempDir(), "x.jsonl"), "{}\n") - if tr := readTranscript(path, maxTranscript+1); !strings.Contains(tr.Err, "too large") { - t.Errorf("err = %q", tr.Err) - } -} - -// ── locating ───────────────────────────────────────────────────────────── - -func TestSafeSession(t *testing.T) { - for _, ok := range []string{"s1", "web:goblin", "24983541-2e59-4d47-94bb-837acf8147c3", - "019fa0f1-49de-7e33-af62-6db956589080"} { - if err := safeSession(ok); err != nil { - t.Errorf("safeSession(%q) = %v", ok, err) - } - } - for _, bad := range []string{"", "..", "a/b", `a\b`, "../../etc/passwd", "a..b", "a b", - "a*b", "a\x00b", strings.Repeat("x", 129)} { - if err := safeSession(bad); err == nil { - t.Errorf("safeSession(%q) was accepted", bad) - } - } -} - -func TestFindTranscript(t *testing.T) { - claudeRoot, codexRoot := t.TempDir(), t.TempDir() - const ( - cid = "24983541-2e59-4d47-94bb-837acf8147c3" - xid = "019fa0f1-49de-7e33-af62-6db956589080" - ) - want := writeFile(t, filepath.Join(claudeRoot, "-home-u-0x4200-cafe-proj", cid+".jsonl"), claudeJSONL) - wantX := writeFile(t, filepath.Join(codexRoot, "2026", "07", "26", - "rollout-2026-07-26T21-19-55-"+xid+".jsonl"), codexJSONL) - // a file outside every root must stay invisible - outside := writeFile(t, filepath.Join(t.TempDir(), cid+".jsonl"), claudeJSONL) - - tx := newTranscripts([]string{claudeRoot, codexRoot}) - if got := tx.find(cid, "/home/u/0x4200.cafe/proj"); got != want { - t.Errorf("with the logged cwd: %q, want %q", got, want) - } - if got := tx.find(cid, "/somewhere/else"); got != want { - t.Errorf("with a stale cwd: %q, want %q", got, want) - } - if got := tx.find(xid, ""); got != wantX { - t.Errorf("codex thread: %q, want %q", got, wantX) - } - if got := tx.find("nosuchsession", ""); got != "" { - t.Errorf("unknown session found %q", got) - } - if got := newTranscripts(nil).find(cid, filepath.Dir(outside)); got != "" { - t.Errorf("no roots configured, yet found %q", got) - } - if under(claudeRoot, outside) { - t.Error("a path outside a root passed the check") - } -} - -// A 5 MB transcript is parsed once and then only re-read when it changes. -func TestTranscriptCacheKeyedByFile(t *testing.T) { - root := t.TempDir() - path := writeFile(t, filepath.Join(root, "-repo", "s1.jsonl"), claudeJSONL) - tx := newTranscripts([]string{root}) - - first := tx.load("s1", "/repo") - if first == nil || len(first.Turns) == 0 { - t.Fatal("nothing parsed") - } - if again := tx.load("s1", "/repo"); again != first { - t.Error("an unchanged transcript was parsed twice") - } - writeFile(t, path, claudeJSONL+`{"type":"assistant","timestamp":"2026-07-20T11:00:00.000Z","message":{"role":"assistant","content":[{"type":"text","text":"one more thing"}]}}`+"\n") - future := time.Now().Add(time.Second) - os.Chtimes(path, future, future) - after := tx.load("s1", "/repo") - if after == first || len(after.Turns) != len(first.Turns)+1 { - t.Errorf("a changed transcript was not re-parsed: %d -> %d turns", len(first.Turns), len(after.Turns)) - } - if tx.load("nope", "/repo") != nil { - t.Error("a missing transcript should be nil, not empty") - } -} - -// ── windowing ──────────────────────────────────────────────────────────── - -func TestConvWindowIsBounded(t *testing.T) { - turns := make([]Turn, 500) - for i := range turns { - turns[i] = Turn{Role: "assistant", Text: fmt.Sprint(i)} - } - from, got := convWindowAt(turns, -1, false) - if len(got) != convWindow || from != 500-convWindow || got[len(got)-1].Text != "499" { - t.Errorf("unanchored window = %d turns from %d", len(got), from) - } - from, got = convWindowAt(turns, 200, false) - if len(got) != convWindow || from > 200 || from+len(got) <= 200 { - t.Errorf("window %d..%d does not hold turn 200", from, from+len(got)) - } - from, got = convWindowAt(turns, 498, false) - if len(got) != convWindow || from+len(got) != 500 { - t.Errorf("window near the end = %d..%d", from, from+len(got)) - } - if _, got = convWindowAt(turns, 200, true); len(got) != 500 { - t.Errorf("show all gave %d of 500 turns", len(got)) - } - if _, got = convWindowAt(turns[:10], -1, false); len(got) != 10 { - t.Errorf("short conversation was cut to %d", len(got)) - } -} - -// ── the session page ───────────────────────────────────────────────────── - -// sessionServer is testServer with a Claude Code transcript for session s1, -// filed under the repo path the log entries carry. -func sessionServer(t *testing.T) (*server, string) { - t.Helper() - sv, _, _ := testServer(t) - root := t.TempDir() - writeFile(t, filepath.Join(root, mangleDir(sv.repo), "s1.jsonl"), claudeJSONL) - sv.tx = newTranscripts([]string{root}) - return sv, root -} - -func TestSessionPageInterleavesLogAndTranscript(t *testing.T) { - sv, _ := sessionServer(t) - body := get(t, sv.handler(), "/session/s1?note=1", false).Body.String() - for _, want := range []string{ - "read lib.rs and tell me about the cache", // the conversation - "It is a two-level map.", - "vr read src/lib.rs:1-30", // a tool call, summarized - "read hello.txt:1-2", // the vr log, woven in - "second line matters", // the note itself - `data-anchor="note-1"`, // and it is what the page lands on - `class="turn t-note here"`, // marked as the anchored moment - } { - if !strings.Contains(body, want) { - t.Errorf("session page is missing %q", want) - } - } - // the whole file is never in the page - if strings.Contains(body, "use std::collections::HashMap;\n2") { - t.Error("a tool result was dumped in full") - } - // ordering: the note was logged after the first message, before the last - iFirst := strings.Index(body, "read lib.rs and tell me") - iNote := strings.Index(body, "second line matters") - iLast := strings.Index(body, "It is a two-level map.") - if !(iFirst < iNote && iNote < iLast) { - t.Errorf("turns are out of order: %d %d %d", iFirst, iNote, iLast) - } -} - -func TestSessionPageWithoutATranscript(t *testing.T) { - sv, _, _ := testServer(t) - sv.tx = newTranscripts([]string{t.TempDir()}) - body := get(t, sv.handler(), "/session/s1", false).Body.String() - if !strings.Contains(body, "no transcript found") { - t.Error("the page does not say the transcript is missing") - } - if !strings.Contains(body, sv.tx.roots[0]) { - t.Error("the page does not say where it looked") - } - if !strings.Contains(body, "read hello.txt:1-2") || !strings.Contains(body, "second line matters") { - t.Error("the vr timeline is missing from the fallback") - } -} - -func TestSessionPageRejectsUnsafeIDs(t *testing.T) { - sv, _ := sessionServer(t) - h := sv.handler() - if w := get(t, h, "/session/a%20b", false); w.Code != 400 { - t.Errorf("GET /session/a b = %d, want 400", w.Code) - } - if w := get(t, h, "/session/unknown-session", false); w.Code != 200 || - !strings.Contains(w.Body.String(), "no transcript found") { - t.Errorf("unknown session = %d", w.Code) - } -} - -// Live pages link a note to its conversation; the static export has no server -// to serve one from. -func TestConversationLinksAreLiveOnly(t *testing.T) { - sv, _ := sessionServer(t) - h := sv.handler() - if b := get(t, h, "/notes", false).Body.String(); !strings.Contains(b, `href="/session/s1?note=1#note-1"`) { - t.Error("no conversation link on a note card") - } - if b := get(t, h, "/", false).Body.String(); !strings.Contains(b, `href="/session/s1"`) { - t.Error("the agents table does not link the session") - } - if b := get(t, h, "/activity", true).Body.String(); !strings.Contains(b, `href="/session/s1"`) { - t.Error("the activity table does not link the session") - } - if b := get(t, h, "/session/s1?note=1", false).Body.String(); strings.Contains(b, ">conversation<") { - t.Error("the session page links back to itself") - } - static := &page{Root: "../"} - if got := static.ConvHref(&Note{ID: 1, Session: "s1"}); got != "" { - t.Errorf("static conversation href = %q, want none", got) - } -} |
