summaryrefslogtreecommitdiff
path: root/vrsite/app_test.go
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-07-30 17:05:12 -0300
committerGabriel Schneider <[email protected]>2026-07-30 17:56:23 -0300
commit8ec4e135e318c7799b2576a8729493c063da4a97 (patch)
treeab37cdbb7f478eb996264cf941891f127f303c85 /vrsite/app_test.go
parentcb05c363045bf0e7af5858f6d7bc26f026fa9d70 (diff)
downloadnotevi-8ec4e135e318c7799b2576a8729493c063da4a97.tar.gz
notevi-8ec4e135e318c7799b2576a8729493c063da4a97.zip
Diffstat (limited to 'vrsite/app_test.go')
-rw-r--r--vrsite/app_test.go316
1 files changed, 313 insertions, 3 deletions
diff --git a/vrsite/app_test.go b/vrsite/app_test.go
index c56fb0d..4db74d4 100644
--- a/vrsite/app_test.go
+++ b/vrsite/app_test.go
@@ -1,12 +1,15 @@
package main
import (
+ "crypto/sha256"
+ "fmt"
"net/http"
"net/http/httptest"
"net/url"
"os"
"os/exec"
"path/filepath"
+ "regexp"
"strings"
"testing"
"time"
@@ -23,7 +26,7 @@ const (
func testSite(t *testing.T) *Site {
t.Helper()
- s := buildModel("test", parseLog([]byte(logLines(grepA, readA, noteA, noteB))))
+ s := buildModel("test", parseLog([]byte(logLines(grepA, readA, noteA, noteB))), "")
s.Theme, s.Light = loadTheme("", "", "dark"), loadTheme("", "", "light")
return s
}
@@ -55,7 +58,7 @@ func TestAgentColorsAreBounded(t *testing.T) {
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...))))
+ s := buildModel("t", parseLog([]byte(logLines(lines...))), "")
if len(s.Agents) != maxAgents+3 {
t.Fatalf("agents = %d", len(s.Agents))
}
@@ -72,6 +75,60 @@ func TestAgentColorsAreBounded(t *testing.T) {
}
}
+// 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 {
@@ -186,13 +243,43 @@ func TestHrefModes(t *testing.T) {
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" {
+ // 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)
}
@@ -280,6 +367,229 @@ func TestServerPages(t *testing.T) {
}
}
+// 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)
+ }
+}
+
+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(logPath, sv.repo, "test", "tester", sv.theme, sv.light).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()