summaryrefslogtreecommitdiff
path: root/web/transcript_test.go
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-08-02 23:43:08 -0300
committerGabriel Schneider <[email protected]>2026-08-03 09:54:39 -0300
commit5ec72f8723d795d0b02d7e9ebb9f555f0c7e6a2e (patch)
treef12f4b700fff4ff6f7e818d2a542672ba0b8d76e /web/transcript_test.go
parenta9263daee9413c5eff3c1f1bebcd224442fd8685 (diff)
downloadnotevi-5ec72f8723d795d0b02d7e9ebb9f555f0c7e6a2e.tar.gz
notevi-5ec72f8723d795d0b02d7e9ebb9f555f0c7e6a2e.zip
rebrand to notevi: one CLI over the jj sidecar
vr and vrsite become a single binary. vrsite/ folds into a web package in one module (0x4200.cafe/notevi); "notevi web" serves and exports exactly what vrsite did, and "notevi read/grep/note/query" is unchanged. The sidecar is renamed with it: notevi_log, notevi-log.jsonl, and the description "private: notevi log". The pre-rebrand names are still recognized, so an old repository opens and reads; it is renamed in place on the first write, or up front with "notevi migrate DIR...". That rename cannot be a single mv inside jj run. jj only auto-tracks a *new* file in the run working copy below a size limit it does not take from the command line, so writing a whole log under a name the change has never held is silently dropped while jj reports success. ensureLogFile creates the file empty first and lets every later byte be a modification of a tracked file, which snapshots at any size; that also fixes the same latent bug when importing a large legacy vr-log.jsonl. Adds a bem-te-vi mark (favicon and nav brand) and a README. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Diffstat (limited to 'web/transcript_test.go')
-rw-r--r--web/transcript_test.go333
1 files changed, 333 insertions, 0 deletions
diff --git a/web/transcript_test.go b/web/transcript_test.go
new file mode 100644
index 0000000..1c3b3db
--- /dev/null
+++ b/web/transcript_test.go
@@ -0,0 +1,333 @@
+package web
+
+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":"notevi 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\":\"notevi 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 notevi 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 notevi 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.",
+ "notevi read src/lib.rs:1-30", // a tool call, summarized
+ "read hello.txt:1-2", // the notevi 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 notevi 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)
+ }
+}