summaryrefslogtreecommitdiff
path: root/vrsite/app_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 /vrsite/app_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 'vrsite/app_test.go')
-rw-r--r--vrsite/app_test.go912
1 files changed, 0 insertions, 912 deletions
diff --git a/vrsite/app_test.go b/vrsite/app_test.go
deleted file mode 100644
index 876e253..0000000
--- a/vrsite/app_test.go
+++ /dev/null
@@ -1,912 +0,0 @@
-package main
-
-import (
- "crypto/sha256"
- "fmt"
- "net/http"
- "net/http/httptest"
- "net/url"
- "os"
- "os/exec"
- "path/filepath"
- "regexp"
- "strings"
- "testing"
- "time"
-
- "0x4200.cafe/vr/sidecar"
-)
-
-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
-}
-
-// testThemes stands in for whatever themes this machine happens to have, so
-// the picker's two files are the same in every test run.
-func testThemes(dark, light *Theme) *themeSet {
- return themesFrom([]*Theme{dark, light}, dark, light)
-}
-
-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")
- }
-}
-
-func TestNoteThreadRecursesThroughRepliesAndMentions(t *testing.T) {
- entries := parseLog([]byte(logLines(
- `{"op":"note","session":"s","agent":"a","change":"aaa","note_id":1,"text":"root"}`,
- `{"op":"note","session":"s","agent":"a","change":"aaa","note_id":2,"reply_to":1,"text":"child"}`,
- `{"op":"note","session":"s","agent":"a","change":"aaa","note_id":3,"reply_to":2,"text":"grandchild"}`,
- `{"op":"note","session":"s","agent":"a","change":"aaa","note_id":4,"text":"[mentions root](../note/1.html)"}`,
- `{"op":"note","session":"s","agent":"a","change":"aaa","note_id":5,"reply_to":3,"text":"[also mentions child](note/2)"}`,
- `{"op":"note","session":"s","agent":"a","change":"aaa","note_id":6,"reply_to":5,"text":"arbitrarily deeper"}`,
- )))
- s := buildModel("test", entries, "")
- root, child := s.NoteByID(1), s.NoteByID(2)
- if len(root.Replies) != 1 || root.Replies[0].ID != 2 || len(root.Mentions) != 1 || root.Mentions[0].ID != 4 {
- t.Fatalf("root edges = replies %+v, mentions %+v", root.Replies, root.Mentions)
- }
- if len(child.Mentions) != 1 || child.Mentions[0].ID != 5 {
- t.Fatalf("child mentions = %+v", child.Mentions)
- }
-
- thread := s.NoteThread(1)
- if thread == nil || len(thread.Children) != 2 || thread.Children[0].Via != "reply" || thread.Children[1].Via != "mention" {
- t.Fatalf("root thread = %+v", thread)
- }
- // 1 → 2 → 3 → 5 → 6 proves the renderer's data is recursive rather
- // than a fixed parent/child pair.
- deep := thread.Children[0].Children[0].Children[0]
- if deep.Note.ID != 5 || len(deep.Children) != 1 || deep.Children[0].Note.ID != 6 {
- t.Fatalf("deep reply branch = %+v", deep)
- }
- // Note 5 also mentions note 2. It is fully rendered under note 3, then left
- // as a DAG reference on the second path instead of recursing twice.
- join := thread.Children[0].Children[1]
- if join.Note.ID != 5 || !join.Reference || join.Via != "mention" {
- t.Errorf("DAG join = %+v", join)
- }
-}
-
-// 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)
- }
-}
-
-// A view of one change holds only what was recorded there — but note ids stay
-// positional over the whole log, so links survive the scope changing.
-func TestModelScopedToAChange(t *testing.T) {
- otherChange := `{"time":"2026-07-26T10:00:00Z","op":"note","session":"s3","agent":"codex","dir":"/repo","change":"bbb","file":"src/lib.rs","start":5,"end":6,"note_id":1,"text":"at another change"}`
- otherRead := `{"time":"2026-07-26T09:00:00Z","op":"read","session":"s3","agent":"codex","dir":"/repo","change":"bbb","file":"src/other.rs","start":1,"end":9}`
- entries := parseLog([]byte(logLines(grepA, readA, noteA, noteB, otherRead, otherChange)))
-
- all := buildModel("t", entries, "")
- if len(all.Notes) != 3 || all.NotesTotal != 3 {
- t.Fatalf("unscoped notes = %d of %d", len(all.Notes), all.NotesTotal)
- }
- if all.Change != "aaa" || all.Votes["aaa"] != 4 || all.Votes["bbb"] != 2 {
- t.Errorf("change %q votes %v", all.Change, all.Votes)
- }
- if strings.Join(all.Ranked, ",") != "aaa,bbb" {
- t.Errorf("ranked = %v, want the most-logged change first", all.Ranked)
- }
-
- one := buildModel("t", entries, "aaa")
- if len(one.Notes) != 2 {
- t.Fatalf("scoped notes = %d, want the two at aaa", len(one.Notes))
- }
- if one.NotesTotal != 3 || one.NotesAt["aaa"] != 2 || one.NotesAt["bbb"] != 1 {
- t.Errorf("a scoped view must still know the whole log: %d %v", one.NotesTotal, one.NotesAt)
- }
- if _, ok := one.Files["src/other.rs"]; ok {
- t.Error("a file only read at another change leaked into the view")
- }
- // the read at bbb covered lines 1-9 of another file; lib.rs keeps only aaa's
- if f := one.Files["src/lib.rs"]; f == nil || f.Reads != 1 || len(f.Notes) != 1 {
- t.Errorf("src/lib.rs at aaa = %+v", f)
- }
- // ids are the note's position in the log, the same in both views
- ids := map[string]int{}
- for _, n := range all.Notes {
- ids[n.Text] = n.ID
- }
- for _, n := range one.Notes {
- if ids[n.Text] != n.ID {
- t.Errorf("note %q is id %d scoped, %d unscoped", n.Text, n.ID, ids[n.Text])
- }
- }
- // agent colors come from the whole log, so they do not shuffle when the
- // scope narrows
- if len(one.Agents) != len(all.Agents) {
- t.Errorf("agents = %d scoped, %d unscoped", len(one.Agents), len(all.Agents))
- }
- for i := range all.Agents {
- if one.Agents[i].Name != all.Agents[i].Name || one.Agents[i].Bit != all.Agents[i].Bit {
- t.Errorf("agent %d differs: %+v vs %+v", i, one.Agents[i], all.Agents[i])
- }
- }
-}
-
-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)
- }
- // notes are read against a change too, so the link keeps it
- if got := live.Href("notes"); got != "/notes?rev=xyz" {
- t.Errorf("live notes href = %q", got)
- }
- // "@" is a revset like any other; dropping it would slide the reader back
- // to the change the log points at
- if got := (&page{Live: true, Root: "/", Rev: "@"}).Href("files"); got != "/files?rev=%40" {
- t.Errorf("href browsing @ = %q", got)
- }
- wide := &page{Live: true, Root: "/", Kind: "file", Rev: "xyz", Scope: "all", Path: "/code/a.rs"}
- if got := wide.Href("files"); got != "/files?rev=xyz&scope=all" {
- t.Errorf("wide files href = %q", got)
- }
- if got := wide.ScopeHref("change"); got != "/code/a.rs?rev=xyz" {
- t.Errorf("narrowing href = %q", got)
- }
- if got := wide.BackHref(); got != "/code/a.rs?scope=all" {
- t.Errorf("back href = %q", got)
- }
- if got := (&page{Live: true, Kind: "change", Path: "/change/abc"}).JumpPath(); got != "/files" {
- t.Errorf("jump path from a change page = %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)
- }
- // a note link carries the note itself, so the page can open focused on it
- n := &Note{ID: 7, File: "src/a.rs", Start: 12, End: 20}
- if got := (&page{Live: true, Root: "/"}).NoteHref(n); got != "/code/src/a.rs?note=7#L12" {
- t.Errorf("live note href = %q", got)
- }
- if got := (&page{Live: true, Root: "/", Rev: "xyz"}).NoteHref(n); got != "/code/src/a.rs?rev=xyz&note=7#L12" {
- t.Errorf("note href at a revision = %q", got)
- }
- if got := static.NoteHref(n); got != "../code/src/a.rs.html?note=7#L12" {
- t.Errorf("static note 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)
- }
- dark, light := loadTheme("", "", "dark"), loadTheme("", "", "light")
- sv := newServer(newFileLog(logPath), repo, "test", "tester", dark, light, testThemes(dark, 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"},
- {"/", `id="filetoggle"`},
- {"/", `id="filelist"`},
- {"/files", "hello.txt"},
- {"/tree", `class="file`},
- {"/notes", "second line matters"},
- {"/code/hello.txt", "three"},
- {"/jj", "revset"},
- {"/change/" + change, "first change"},
- {"/style.css", ".am1 {"},
- {"/style.css", `data-files="hidden"`},
- {"/themes.css", `:root[data-theme="zed/one-light"]`},
- {"/themes.js", "VRSITE_THEMES"},
- {"/app.js", "vrsite-mode"},
- {"/app.js", "vrsite-files-hidden"},
- {"/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)
- }
- }
-}
-
-// The current change is on every page, and the view follows it.
-func TestCurrentChangeRunsTheView(t *testing.T) {
- sv, logPath, change := testServer(t)
-
- // a second change with nothing recorded at it
- empty := filepath.Join(sv.repo, "later.txt")
- if err := os.WriteFile(empty, []byte("later\n"), 0o644); err != nil {
- t.Fatal(err)
- }
- cmd := exec.Command("jj", "new", "-m", "a later change")
- cmd.Dir, cmd.Env = sv.repo, append(os.Environ(), "JJ_USER=test", "[email protected]", "HOME="+sv.repo)
- if out, err := cmd.CombinedOutput(); err != nil {
- t.Fatalf("jj new: %v\n%s", err, out)
- }
- later, err := resolveChange(sv.repo, "@")
- if err != nil {
- t.Fatal(err)
- }
- h := sv.handler()
-
- // the default page is the change the log points at, scoped to it
- body := get(t, h, "/", false).Body.String()
- for _, want := range []string{short(change), "notes here", `class="changebar`} {
- if !strings.Contains(body, want) {
- t.Errorf("the index does not show the current change (%q missing)", want)
- }
- }
- if !strings.Contains(body, "second line matters") {
- t.Error("the note recorded at this change is missing")
- }
-
- // browsing a change nothing was recorded at must not look empty: it says so
- // and falls back to the whole log
- body = get(t, h, "/?rev="+later, false).Body.String()
- if !strings.Contains(body, "nothing recorded at this change") {
- t.Errorf("no explanation for an empty change:\n%s", body[:min(len(body), 900)])
- }
- if !strings.Contains(body, "second line matters") {
- t.Error("the fallback should show the whole log, not nothing")
- }
-
- // a note written from a page at that change is recorded there
- form := url.Values{"text": {"written at the later change"}, "rev": {later}}
- r := httptest.NewRequest("POST", "/notes", strings.NewReader(form.Encode()))
- r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
- w := httptest.NewRecorder()
- h.ServeHTTP(w, r)
- if w.Code != http.StatusSeeOther {
- t.Fatalf("POST /notes = %d", w.Code)
- }
- entries := parseLog(mustRead(t, logPath))
- if last := entries[len(entries)-1]; last.Change != later {
- t.Errorf("note recorded at %q, want the change being browsed %q", last.Change, later)
- }
-
- // a note written with no change named lands on the one the log points at,
- // not on the working copy, which has moved on
- form = url.Values{"text": {"written with nothing named"}}
- r = httptest.NewRequest("POST", "/notes", strings.NewReader(form.Encode()))
- r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
- w = httptest.NewRecorder()
- h.ServeHTTP(w, r)
- entries = parseLog(mustRead(t, logPath))
- if last := entries[len(entries)-1]; last.Change != change {
- t.Errorf("unaimed note recorded at %q, want the log's change %q", last.Change, change)
- }
-
- // now that change has a note, scoping to it shows that one alone
- body = get(t, h, "/notes?rev="+later, false).Body.String()
- if strings.Contains(body, "second line matters") {
- t.Error("a note from another change leaked into the scoped view")
- }
- if !strings.Contains(body, "written at the later change") {
- t.Error("the note recorded here is missing")
- }
- // and the wide view has both, with the other change's note marked
- body = get(t, h, "/notes?rev="+later+"&scope=all", false).Body.String()
- if !strings.Contains(body, "second line matters") || !strings.Contains(body, "written at the later change") {
- t.Error("scope=all must show every change's notes")
- }
- if !strings.Contains(body, "note am1 away") && !strings.Contains(body, "away") {
- t.Error("a note from another change is not marked as such")
- }
- // links keep both the change and the scope
- if !strings.Contains(body, "rev="+later+"&amp;scope=all") {
- t.Error("links drop the change or the scope")
- }
-}
-
-func mustRead(t *testing.T, path string) []byte {
- t.Helper()
- b, err := os.ReadFile(path)
- if err != nil {
- t.Fatal(err)
- }
- return b
-}
-
-// ── the server keeps nothing of its own ───────────────────────────────────
-//
-// Every byte it writes goes to the log; everything it holds in memory is a
-// cache of the log and the repo, so a restart changes nothing and a repo that
-// moves underneath it is followed, not remembered.
-
-func repoState(t *testing.T, repo string) string {
- t.Helper()
- sum := sha256.New()
- err := filepath.WalkDir(repo, func(path string, d os.DirEntry, err error) error {
- if err != nil {
- return err
- }
- name := d.Name()
- if d.IsDir() {
- if name == ".jj" || name == ".git" {
- return filepath.SkipDir // caches and mtimes of their own
- }
- return nil
- }
- b, err := os.ReadFile(path)
- if err != nil {
- return err
- }
- fmt.Fprintf(sum, "%s %x\n", path, sha256.Sum256(b))
- return nil
- })
- if err != nil {
- t.Fatal(err)
- }
- // the operation log head: proof we never asked jj to do anything
- op, err := jjRun(repo, "op", "log", "-n", "1", "--no-graph", "-T", "id")
- if err != nil {
- t.Fatal(err)
- }
- return fmt.Sprintf("%x %s", sum.Sum(nil), op)
-}
-
-func TestServerWritesOnlyTheLog(t *testing.T) {
- sv, logPath, change := testServer(t)
- h := sv.handler()
- before := repoState(t, sv.repo)
- logBefore := len(mustRead(t, logPath))
-
- for _, path := range []string{"/", "/files", "/notes", "/code/hello.txt", "/tree",
- "/jj?revset=all()", "/jj?kind=op", "/change/" + change, "/activity", "/style.css"} {
- if w := get(t, h, path, false); w.Code != 200 {
- t.Errorf("GET %s = %d", path, w.Code)
- }
- }
- form := url.Values{"text": {"a note from the browser"}, "file": {"hello.txt"}, "change": {change}}
- r := httptest.NewRequest("POST", "/notes", strings.NewReader(form.Encode()))
- r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
- h.ServeHTTP(httptest.NewRecorder(), r)
-
- if after := repoState(t, sv.repo); after != before {
- t.Errorf("the repository changed:\n%s\n%s", before, after)
- }
- if len(mustRead(t, logPath)) <= logBefore {
- t.Error("the note did not reach the log")
- }
- // nothing else appeared next to the log either
- dir := filepath.Dir(logPath)
- names, err := os.ReadDir(dir)
- if err != nil {
- t.Fatal(err)
- }
- if len(names) != 1 || filepath.Join(dir, names[0].Name()) != logPath {
- var got []string
- for _, n := range names {
- got = append(got, n.Name())
- }
- t.Errorf("the server left files of its own: %v", got)
- }
-}
-
-func TestServerWritesBrowserNotesToJJSidecar(t *testing.T) {
- repo, change := testRepo(t)
- t.Setenv("HOME", repo)
- t.Setenv("JJ_USER", "test")
- t.Setenv("JJ_EMAIL", "[email protected]")
- _, commit, err := resolveRevision(repo, change)
- if err != nil {
- t.Fatal(err)
- }
- log, err := sidecar.Open(repo)
- if err != nil {
- t.Fatal(err)
- }
- dark, light := loadTheme("", "", "dark"), loadTheme("", "", "light")
- sv := newServer(log, repo, "test", "tester", dark, light, testThemes(dark, light))
- before, err := jjRun(repo, "status")
- if err != nil {
- t.Fatal(err)
- }
- form := url.Values{"text": {"sidecar browser note"}, "file": {"hello.txt"}, "change": {change}}
- r := httptest.NewRequest("POST", "/notes", strings.NewReader(form.Encode()))
- r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
- w := httptest.NewRecorder()
- sv.handler().ServeHTTP(w, r)
- if w.Code != http.StatusSeeOther {
- t.Fatalf("POST /notes = %d: %s", w.Code, w.Body)
- }
- after, err := jjRun(repo, "status")
- if err != nil {
- t.Fatal(err)
- }
- if string(after) != string(before) {
- t.Fatalf("project working copy changed:\n--- before\n%s--- after\n%s", before, after)
- }
- if _, err := os.Stat(filepath.Join(repo, sidecar.LogFile)); !os.IsNotExist(err) {
- t.Fatalf("sidecar materialized in project working copy: %v", err)
- }
- b, err := log.Read()
- if err != nil {
- t.Fatal(err)
- }
- entries := parseLog(b)
- last := entries[len(entries)-1]
- if last.Text != "sidecar browser note" || last.Commit != commit || len(last.Commit) != 40 {
- t.Fatalf("sidecar note = %+v", last)
- }
-}
-
-var agoRe = regexp.MustCompile(`\d+[smhd] ago|just now`)
-
-func TestServerHoldsNoStateOfItsOwn(t *testing.T) {
- sv, logPath, change := testServer(t)
- h := sv.handler()
- form := url.Values{"text": {"written before the restart"}, "file": {"hello.txt"},
- "start": {"2"}, "change": {change}}
- r := httptest.NewRequest("POST", "/notes", strings.NewReader(form.Encode()))
- r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
- h.ServeHTTP(httptest.NewRecorder(), r)
-
- // a second server that has only ever seen the log file
- fresh := newServer(newFileLog(logPath), sv.repo, "test", "tester", sv.theme, sv.light, sv.themes).handler()
- for _, path := range []string{"/", "/notes", "/notes?scope=all", "/code/hello.txt",
- "/files", "/tree", "/change/" + change} {
- was := agoRe.ReplaceAllString(get(t, h, path, false).Body.String(), "AGO")
- is := agoRe.ReplaceAllString(get(t, fresh, path, false).Body.String(), "AGO")
- if was != is {
- t.Errorf("GET %s differs after a restart — the server is holding state the log does not", path)
- }
- }
-}
-
-// A change id is a pointer: editing files rewrites the commit under it. Pages
-// must follow the content, not a cached snapshot of it.
-func TestPagesFollowTheRepo(t *testing.T) {
- sv, _, _ := testServer(t)
- h := sv.handler()
- if body := get(t, h, "/code/hello.txt", false).Body.String(); !strings.Contains(body, "three") {
- t.Fatalf("the file did not render:\n%s", body[:min(len(body), 400)])
- }
- if err := os.WriteFile(filepath.Join(sv.repo, "hello.txt"), []byte("one\ntwo\nrewritten\n"), 0o644); err != nil {
- t.Fatal(err)
- }
- // someone else's jj command snapshots it: same change, new commit
- cmd := exec.Command("jj", "status")
- cmd.Dir, cmd.Env = sv.repo, append(os.Environ(), "JJ_USER=test", "[email protected]", "HOME="+sv.repo)
- if out, err := cmd.CombinedOutput(); err != nil {
- t.Fatalf("jj status: %v\n%s", err, out)
- }
- body := get(t, h, "/code/hello.txt", false).Body.String()
- if !strings.Contains(body, "rewritten") {
- t.Error("the page still shows the content the change had when it was first read")
- }
- if strings.Contains(body, "three") {
- t.Error("the old content is still on the page")
- }
-}
-
-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 TestFocusedNoteRendersAndAppendsAnArbitrarilyDeepThread(t *testing.T) {
- sv, logPath, change := testServer(t)
- h := sv.handler()
- post := func(values url.Values) {
- t.Helper()
- values.Set("change", change)
- r := httptest.NewRequest("POST", "/notes", strings.NewReader(values.Encode()))
- r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
- w := httptest.NewRecorder()
- h.ServeHTTP(w, r)
- if w.Code != http.StatusSeeOther {
- t.Fatalf("POST /notes = %d: %s", w.Code, w.Body)
- }
- }
- post(url.Values{"reply_to": {"1"}, "text": {"child from focused view"}})
- post(url.Values{"reply_to": {"2"}, "text": {"grandchild from focused view"}})
- post(url.Values{"text": {"[backlink to root](/note/1)"}})
-
- entries := parseLog(mustRead(t, logPath))
- if len(entries) < 5 || entries[len(entries)-3].ReplyTo != 1 || entries[len(entries)-2].ReplyTo != 2 || entries[len(entries)-1].ReplyTo != 0 {
- t.Fatalf("thread entries were not appended as explicit replies plus a plain mention: %+v", entries)
- }
- body := get(t, h, "/note/1", false).Body.String()
- for _, want := range []string{
- "child from focused view", "grandchild from focused view", "backlink to root",
- `class="threadnode via-reply"`, `class="threadnode via-mention"`,
- `name="reply_to" value="2"`, "reply to #2", "Replies &amp; mentions",
- } {
- if !strings.Contains(body, want) {
- t.Errorf("focused thread is missing %q", want)
- }
- }
- if strings.Index(body, "grandchild from focused view") < strings.Index(body, "child from focused view") {
- t.Error("grandchild rendered outside its parent branch")
- }
-}
-
-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)
- }
-
- r = httptest.NewRequest("POST", "/notes", strings.NewReader(url.Values{
- "text": {"orphan"}, "reply_to": {"999"},
- }.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("missing reply target = %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")
- }
-}