From 5ec72f8723d795d0b02d7e9ebb9f555f0c7e6a2e Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Sun, 2 Aug 2026 23:43:08 -0300 Subject: 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) --- web/app_test.go | 912 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 912 insertions(+) create mode 100644 web/app_test.go (limited to 'web/app_test.go') diff --git a/web/app_test.go b/web/app_test.go new file mode 100644 index 0000000..8416099 --- /dev/null +++ b/web/app_test.go @@ -0,0 +1,912 @@ +package web + +import ( + "crypto/sha256" + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "os" + "os/exec" + "path/filepath" + "regexp" + "strings" + "testing" + "time" + + "0x4200.cafe/notevi/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<