summaryrefslogtreecommitdiff
path: root/vrsite/app_test.go
diff options
context:
space:
mode:
Diffstat (limited to 'vrsite/app_test.go')
-rw-r--r--vrsite/app_test.go456
1 files changed, 456 insertions, 0 deletions
diff --git a/vrsite/app_test.go b/vrsite/app_test.go
new file mode 100644
index 0000000..c56fb0d
--- /dev/null
+++ b/vrsite/app_test.go
@@ -0,0 +1,456 @@
+package main
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+)
+
+func logLines(lines ...string) string { return strings.Join(lines, "\n") + "\n" }
+
+const (
+ noteA = `{"time":"2026-07-20T10:00:00Z","op":"note","session":"s1","agent":"codex","model":"gpt-5.6-sol","reasoning":"max","dir":"/repo","change":"aaa","file":"src/lib.rs","start":10,"end":20,"note_id":1,"type":"struct","text":"Cache is a two-level map"}`
+ noteB = `{"time":"2026-07-25T10:00:00Z","op":"note","session":"s2","agent":"claude","model":"claude-fable-5","reasoning":"xhigh","dir":"/repo","change":"aaa","file":"src/main.rs","note_id":1,"text":"entry point does too much"}`
+ readA = `{"time":"2026-07-20T09:59:00Z","op":"read","session":"s1","agent":"codex","model":"gpt-5.6-sol","dir":"/repo","change":"aaa","file":"src/lib.rs","start":1,"end":30}`
+ grepA = `{"time":"2026-07-20T09:58:00Z","op":"grep","session":"s1","agent":"codex","dir":"/repo","change":"aaa","file":"src/lib.rs","pattern":"Cache","matches":3}`
+)
+
+func testSite(t *testing.T) *Site {
+ t.Helper()
+ s := buildModel("test", parseLog([]byte(logLines(grepA, readA, noteA, noteB))))
+ s.Theme, s.Light = loadTheme("", "", "dark"), loadTheme("", "", "light")
+ return s
+}
+
+func TestBuildModel(t *testing.T) {
+ s := testSite(t)
+ if s.Change != "aaa" {
+ t.Errorf("site change = %q, want aaa", s.Change)
+ }
+ if len(s.Agents) != 2 || s.Agents[0].Bit != 1 || s.Agents[1].Bit != 2 {
+ t.Errorf("agents = %+v", s.Agents)
+ }
+ f := s.Files["src/lib.rs"]
+ if f == nil || f.Reads != 1 || f.Greps != 1 || len(f.Notes) != 1 {
+ t.Fatalf("src/lib.rs = %+v", f)
+ }
+ if f.Cov[15] != 1 || f.Mask != 1 {
+ t.Errorf("coverage = %v mask %d", f.Cov, f.Mask)
+ }
+ if got := s.Notes[0].When; got == "" {
+ t.Error("note has no rendered timestamp")
+ }
+}
+
+// A ninth agent gets no color instead of overflowing the uint8 mask (and the
+// per-mask CSS it would generate).
+func TestAgentColorsAreBounded(t *testing.T) {
+ var lines []string
+ for i := 0; i < maxAgents+3; i++ {
+ lines = append(lines, `{"time":"2026-07-20T10:00:00Z","op":"note","agent":"a`+string(rune('a'+i))+`","change":"aaa","note_id":1,"text":"x"}`)
+ }
+ s := buildModel("t", parseLog([]byte(logLines(lines...))))
+ if len(s.Agents) != maxAgents+3 {
+ t.Fatalf("agents = %d", len(s.Agents))
+ }
+ for i, a := range s.Agents {
+ if i < maxAgents && a.Bit == 0 {
+ t.Errorf("agent %d has no bit", i)
+ }
+ if i >= maxAgents && a.Bit != 0 {
+ t.Errorf("agent %d got bit %d, want none", i, a.Bit)
+ }
+ }
+ if n := len(s.masks()); n != 1<<maxAgents-1 {
+ t.Errorf("masks = %d, want %d", n, 1<<maxAgents-1)
+ }
+}
+
+func TestFilterNotes(t *testing.T) {
+ s := testSite(t)
+ for _, tc := range []struct {
+ name string
+ f NoteFilter
+ want []string
+ }{
+ {"all", NoteFilter{}, []string{"src/main.rs", "src/lib.rs"}}, // newest first
+ {"text", NoteFilter{Text: "two-level"}, []string{"src/lib.rs"}},
+ {"text is case-insensitive", NoteFilter{Text: "CACHE"}, []string{"src/lib.rs"}},
+ {"file substring", NoteFilter{File: "main"}, []string{"src/main.rs"}},
+ {"agent", NoteFilter{Agent: "claude"}, []string{"src/main.rs"}},
+ {"model", NoteFilter{Model: "gpt-5.6-sol"}, []string{"src/lib.rs"}},
+ {"session", NoteFilter{Session: "s2"}, []string{"src/main.rs"}},
+ {"kind", NoteFilter{Kind: "struct"}, []string{"src/lib.rs"}},
+ {"from", NoteFilter{From: "2026-07-21"}, []string{"src/main.rs"}},
+ {"to", NoteFilter{To: "2026-07-21"}, []string{"src/lib.rs"}},
+ {"range", NoteFilter{From: "2026-07-19", To: "2026-07-26"}, []string{"src/main.rs", "src/lib.rs"}},
+ {"combined", NoteFilter{Agent: "codex", Text: "map"}, []string{"src/lib.rs"}},
+ {"no match", NoteFilter{Agent: "codex", Text: "nothing"}, nil},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ var got []string
+ for _, n := range s.FilterNotes(tc.f) {
+ got = append(got, n.File)
+ }
+ if strings.Join(got, ",") != strings.Join(tc.want, ",") {
+ t.Errorf("got %v, want %v", got, tc.want)
+ }
+ })
+ }
+}
+
+func TestFacetsAreDistinctAndSorted(t *testing.T) {
+ fa := testSite(t).Facets()
+ if strings.Join(fa.Agents, ",") != "claude,codex" {
+ t.Errorf("agents = %v", fa.Agents)
+ }
+ if strings.Join(fa.Kinds, ",") != "struct" {
+ t.Errorf("kinds = %v", fa.Kinds)
+ }
+ if strings.Join(fa.Sessions, ",") != "s1,s2" {
+ t.Errorf("sessions = %v", fa.Sessions)
+ }
+}
+
+func TestSessionsReportWhatIsRunning(t *testing.T) {
+ now := time.Date(2026, 7, 25, 10, 1, 0, 0, time.UTC)
+ ss := testSite(t).Sessions(now, liveWindow, 10)
+ if len(ss) != 2 {
+ t.Fatalf("sessions = %d", len(ss))
+ }
+ if ss[0].ID != "s2" || !ss[0].Live {
+ t.Errorf("newest session = %+v, want s2 live", ss[0])
+ }
+ if ss[1].ID != "s1" || ss[1].Live {
+ t.Errorf("older session = %+v, want s1 not live", ss[1])
+ }
+ if ss[1].LastOp != "note on src/lib.rs" || ss[1].Reads != 1 || ss[1].Greps != 1 {
+ t.Errorf("s1 activity = %q %d/%d", ss[1].LastOp, ss[1].Reads, ss[1].Greps)
+ }
+}
+
+func TestNextNoteIDCountsPerSession(t *testing.T) {
+ entries := parseLog([]byte(logLines(noteA, noteB)))
+ if got := nextNoteID(entries, "s1"); got != 2 {
+ t.Errorf("s1 next = %d, want 2", got)
+ }
+ if got := nextNoteID(entries, "web:me"); got != 1 {
+ t.Errorf("fresh session next = %d, want 1", got)
+ }
+}
+
+// The graph art in front of a templated row must not end up inside a field.
+func TestParseRows(t *testing.T) {
+ out := "@ \x1eabc123\x1fabc1\x1fdef456\x1fdef4\x1fme@x\x1f2026-07-20 10:00\x1fmain\x1fworking copy\x1ffix the thing\n" +
+ "│\n" +
+ "~\n"
+ rows := parseRows([]byte(out), false)
+ if len(rows) != 3 {
+ t.Fatalf("rows = %d", len(rows))
+ }
+ r := rows[0]
+ if r.Graph != "@ " || r.ID != "abc123" || r.Short != "abc1" || r.CommitShort != "def4" ||
+ r.Author != "me@x" || r.Refs != "main" || r.Flags != "working copy" || r.Desc != "fix the thing" {
+ t.Errorf("row = %+v", r)
+ }
+ if !rows[1].Blank() || rows[1].Graph != "│" {
+ t.Errorf("graph-only row = %+v", rows[1])
+ }
+ op := parseRows([]byte("@ \x1eopid\x1fopid1\x1f\x1f\x1fme@host\x1f2026-07-20 10:00:00\x1f\x1f10:00:05\x1fsnapshot\n"), true)
+ if len(op) != 1 || op[0].End != "10:00:05" || op[0].Flags != "" || op[0].Desc != "snapshot" {
+ t.Errorf("op row = %+v", op)
+ }
+}
+
+func TestCheckRevsetRejectsFlags(t *testing.T) {
+ if err := checkRevset("--config=x"); err == nil {
+ t.Error("a leading dash must be refused, jj would read it as a flag")
+ }
+ if err := checkRevset("all() & file(x)"); err != nil {
+ t.Errorf("plain revset refused: %v", err)
+ }
+}
+
+func TestHrefModes(t *testing.T) {
+ live := &page{Live: true, Root: "/"}
+ if got := live.Href("code", "src/a b.rs"); got != "/code/src/a%20b.rs" {
+ t.Errorf("live code href = %q", got)
+ }
+ live.Rev = "xyz"
+ if got := live.Href("code", "src/a.rs"); got != "/code/src/a.rs?rev=xyz" {
+ t.Errorf("live code href with rev = %q", got)
+ }
+ if got := live.Href("notes"); got != "/notes" {
+ t.Errorf("live notes href = %q", got)
+ }
+ static := &page{Root: "../"}
+ if got := static.Href("code", "src/a.rs"); got != "../code/src/a.rs.html" {
+ t.Errorf("static code href = %q", got)
+ }
+ if got := static.Href("change", "abc"); got != "" {
+ t.Errorf("static change href = %q, want none", got)
+ }
+}
+
+// ── the web app, end to end ───────────────────────────────────────────────
+
+// testRepo makes a one-commit jj repo and returns its path plus the change id.
+func testRepo(t *testing.T) (string, string) {
+ t.Helper()
+ if _, err := exec.LookPath("jj"); err != nil {
+ t.Skip("jj not installed")
+ }
+ dir := t.TempDir()
+ env := append(os.Environ(), "JJ_USER=test", "[email protected]", "HOME="+dir)
+ runJJ := func(args ...string) {
+ t.Helper()
+ cmd := exec.Command("jj", args...)
+ cmd.Dir, cmd.Env = dir, env
+ if out, err := cmd.CombinedOutput(); err != nil {
+ t.Fatalf("jj %s: %v\n%s", strings.Join(args, " "), err, out)
+ }
+ }
+ runJJ("git", "init")
+ if err := os.WriteFile(filepath.Join(dir, "hello.txt"), []byte("one\ntwo\nthree\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ runJJ("describe", "-m", "first change")
+ change, err := resolveChange(dir, "@")
+ if err != nil {
+ t.Fatal(err)
+ }
+ return dir, change
+}
+
+func testServer(t *testing.T) (*server, string, string) {
+ t.Helper()
+ repo, change := testRepo(t)
+ logPath := filepath.Join(t.TempDir(), "trace.jsonl")
+ entry := `{"time":"2026-07-20T10:00:00Z","op":"read","session":"s1","agent":"codex","model":"gpt-5.6-sol","dir":"` +
+ repo + `","change":"` + change + `","file":"hello.txt","start":1,"end":2}`
+ note := `{"time":"2026-07-20T10:01:00Z","op":"note","session":"s1","agent":"codex","model":"gpt-5.6-sol","dir":"` +
+ repo + `","change":"` + change + `","file":"hello.txt","start":2,"end":2,"note_id":1,"text":"second line matters"}`
+ if err := os.WriteFile(logPath, []byte(logLines(entry, note)), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ sv := newServer(logPath, repo, "test", "tester", loadTheme("", "", "dark"), loadTheme("", "", "light"))
+ return sv, logPath, change
+}
+
+func get(t *testing.T, h http.Handler, path string, hx bool) *httptest.ResponseRecorder {
+ t.Helper()
+ r := httptest.NewRequest("GET", path, nil)
+ if hx {
+ r.Header.Set("HX-Request", "true")
+ }
+ w := httptest.NewRecorder()
+ h.ServeHTTP(w, r)
+ return w
+}
+
+func TestServerPages(t *testing.T) {
+ sv, _, change := testServer(t)
+ h := sv.handler()
+ for _, tc := range []struct{ path, want string }{
+ {"/", "second line matters"},
+ {"/files", "hello.txt"},
+ {"/tree", `class="file`},
+ {"/notes", "second line matters"},
+ {"/code/hello.txt", "three"},
+ {"/jj", "revset"},
+ {"/change/" + change, "first change"},
+ {"/style.css", "--am1"},
+ {"/app.js", "vrsite-mode"},
+ {"/htmx.min.js", "htmx"},
+ } {
+ w := get(t, h, tc.path, false)
+ if w.Code != 200 {
+ t.Errorf("GET %s = %d", tc.path, w.Code)
+ continue
+ }
+ if !strings.Contains(w.Body.String(), tc.want) {
+ t.Errorf("GET %s does not mention %q", tc.path, tc.want)
+ }
+ }
+}
+
+func TestNotesFilterOverHTTP(t *testing.T) {
+ sv, _, _ := testServer(t)
+ h := sv.handler()
+ if b := get(t, h, "/notes/list?text=second", true).Body.String(); !strings.Contains(b, "second line matters") {
+ t.Error("matching filter dropped the note")
+ }
+ b := get(t, h, "/notes/list?text=absent", true).Body.String()
+ if strings.Contains(b, "second line matters") || !strings.Contains(b, "no notes match") {
+ t.Errorf("non-matching filter kept the note: %s", b)
+ }
+ if !strings.Contains(b, `id="notecount" hx-swap-oob`) {
+ t.Error("list fragment does not carry the out-of-band count")
+ }
+}
+
+// A comment posted from the browser lands in the log, shows up in the model,
+// and leaves the repository alone.
+func TestAddNoteAppendsToLog(t *testing.T) {
+ sv, logPath, change := testServer(t)
+ h := sv.handler()
+ before, err := jjRun(sv.repo, "status")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ form := url.Values{"file": {"hello.txt"}, "start": {"3"}, "end": {"3"},
+ "kind": {"line"}, "text": {"written from the browser"}, "rev": {"@"}}
+ r := httptest.NewRequest("POST", "/notes", strings.NewReader(form.Encode()))
+ r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ r.Header.Set("HX-Request", "true")
+ w := httptest.NewRecorder()
+ h.ServeHTTP(w, r)
+ if w.Code != 200 {
+ t.Fatalf("POST /notes = %d: %s", w.Code, w.Body)
+ }
+ if !strings.Contains(w.Body.String(), "written from the browser") {
+ t.Errorf("response does not contain the new note: %s", w.Body)
+ }
+
+ b, err := os.ReadFile(logPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+ entries := parseLog(b)
+ last := entries[len(entries)-1]
+ if last.Op != "note" || last.Agent != "tester" || last.Session != "web:tester" ||
+ last.File != "hello.txt" || last.Start != 3 || last.Text != "written from the browser" {
+ t.Errorf("appended entry = %+v", last)
+ }
+ if last.Change != change {
+ t.Errorf("note change = %q, want %q", last.Change, change)
+ }
+ if last.NoteID != 1 {
+ t.Errorf("note id = %d, want 1 (first of this session)", last.NoteID)
+ }
+ if last.Time == "" {
+ t.Error("appended entry has no timestamp")
+ }
+
+ // visible without a restart, and the line is now marked as noted
+ if body := get(t, h, "/code/hello.txt", false).Body.String(); !strings.Contains(body, "written from the browser") {
+ t.Error("the new note is not on the file page")
+ }
+ after, err := jjRun(sv.repo, "status")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if string(before) != string(after) {
+ t.Errorf("the repository changed:\nbefore %s\nafter %s", before, after)
+ }
+}
+
+func TestAddNoteRejectsCrossOriginAndEmptyText(t *testing.T) {
+ sv, _, _ := testServer(t)
+ h := sv.handler()
+
+ form := url.Values{"text": {"hi"}, "rev": {"@"}}
+ r := httptest.NewRequest("POST", "/notes", strings.NewReader(form.Encode()))
+ r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ r.Header.Set("Origin", "http://evil.example")
+ w := httptest.NewRecorder()
+ h.ServeHTTP(w, r)
+ if w.Code != http.StatusForbidden {
+ t.Errorf("cross-origin POST = %d, want 403", w.Code)
+ }
+
+ r = httptest.NewRequest("POST", "/notes", strings.NewReader(url.Values{"text": {" "}}.Encode()))
+ r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ w = httptest.NewRecorder()
+ h.ServeHTTP(w, r)
+ if w.Code != http.StatusBadRequest {
+ t.Errorf("empty note = %d, want 400", w.Code)
+ }
+}
+
+func TestJJQueries(t *testing.T) {
+ sv, _, change := testServer(t)
+ h := sv.handler()
+
+ b := get(t, h, "/jj/log?revset="+url.QueryEscape("all()"), true).Body.String()
+ if !strings.Contains(b, `href="/change/`+change) {
+ t.Errorf("jj log has no link to the change: %s", b)
+ }
+ if !strings.Contains(b, "first change") {
+ t.Error("jj log does not show the description")
+ }
+ b = get(t, h, "/jj/op?limit=5", true).Body.String()
+ if !strings.Contains(b, "at_op=") {
+ t.Errorf("op log has no --at-operation links: %s", b)
+ }
+ b = get(t, h, "/jj/log?revset="+url.QueryEscape("bogusfn(")+"", true).Body.String()
+ if !strings.Contains(b, `class="err"`) {
+ t.Errorf("a bad revset should report jj's error: %s", b)
+ }
+ b = get(t, h, "/jj/log?revset="+url.QueryEscape("--config=ui.paginate=never"), true).Body.String()
+ if !strings.Contains(b, "must not start with") {
+ t.Errorf("flag-like revset was not refused: %s", b)
+ }
+}
+
+// Model files are shared across requests and cloned per render; run the
+// handlers hard in parallel so -race can complain about it.
+func TestConcurrentRequests(t *testing.T) {
+ sv, _, change := testServer(t)
+ h := sv.handler()
+ paths := []string{"/", "/code/hello.txt", "/notes", "/notes/list?text=second",
+ "/tree", "/files", "/activity?compact=1", "/change/" + change,
+ "/code/hello.txt?rev=" + change}
+ done := make(chan struct{})
+ for i := 0; i < len(paths)*3; i++ {
+ go func(p string) {
+ defer func() { done <- struct{}{} }()
+ if w := get(t, h, p, false); w.Code != 200 {
+ t.Errorf("GET %s = %d", p, w.Code)
+ }
+ }(paths[i%len(paths)])
+ }
+ for i := 0; i < len(paths)*3; i++ {
+ <-done
+ }
+}
+
+func TestCodePageAtMissingRevision(t *testing.T) {
+ sv, _, _ := testServer(t)
+ w := get(t, sv.handler(), "/code/nope.txt", false)
+ if w.Code != 200 || !strings.Contains(w.Body.String(), "not present at this change") {
+ t.Errorf("missing file = %d: %s", w.Code, w.Body)
+ }
+}
+
+func TestServerPicksUpLogAppendsFromOtherProcesses(t *testing.T) {
+ sv, logPath, change := testServer(t)
+ h := sv.handler()
+ if strings.Contains(get(t, h, "/notes", false).Body.String(), "from another process") {
+ t.Fatal("note exists before it was written")
+ }
+ f, err := os.OpenFile(logPath, os.O_APPEND|os.O_WRONLY, 0o644)
+ if err != nil {
+ t.Fatal(err)
+ }
+ line := `{"time":"2026-07-26T10:00:00Z","op":"note","session":"s9","agent":"vr","change":"` +
+ change + `","file":"hello.txt","note_id":1,"text":"from another process"}`
+ if _, err := f.WriteString(line + "\n"); err != nil {
+ t.Fatal(err)
+ }
+ f.Close()
+ // the poll is stat-based; make sure the mtime moved
+ future := time.Now().Add(time.Second)
+ os.Chtimes(logPath, future, future)
+
+ if !strings.Contains(get(t, h, "/notes", false).Body.String(), "from another process") {
+ t.Error("an append by vr was not picked up")
+ }
+}