diff options
| author | Gabriel Schneider <[email protected]> | 2026-07-30 17:05:12 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-07-30 17:56:23 -0300 |
| commit | 8ec4e135e318c7799b2576a8729493c063da4a97 (patch) | |
| tree | ab37cdbb7f478eb996264cf941891f127f303c85 /vrsite/app_test.go | |
| parent | cb05c363045bf0e7af5858f6d7bc26f026fa9d70 (diff) | |
| download | notevi-8ec4e135e318c7799b2576a8729493c063da4a97.tar.gz notevi-8ec4e135e318c7799b2576a8729493c063da4a97.zip | |
Diffstat (limited to 'vrsite/app_test.go')
| -rw-r--r-- | vrsite/app_test.go | 316 |
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¬e=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+"&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() |
