Artifact 1d2b76bba3da25362c3bdbba618d1710643760cf96fb4ce2e7abd1ef72ff30db:
- File
internal/web/web_test.go
— part of check-in
[94a2276f2c]
at
2026-08-23 01:10:21
on branch trunk
— tickets: one tracker page across every repository
/tickets is the same report machinery pointed at all granted trackers: repository becomes a browse axis, rows carry their own repo, and the board and New button stay behind since neither has one repo to mean. (user: dmestas, size: 51871) [annotate] [blame] [check-ins using]
package web import ( "bufio" "errors" "fmt" "io" "net/http" "net/http/httptest" "net/url" "path/filepath" "strings" "testing" "time" "craftdesign.group/quarry/internal/api" "craftdesign.group/quarry/internal/artifact" "craftdesign.group/quarry/internal/chat" "craftdesign.group/quarry/internal/errlog" "craftdesign.group/quarry/internal/index" "craftdesign.group/quarry/internal/ops" ) func testRegistry(t *testing.T) *ops.Registry { t.Helper() // An empty (but real) index so entity lookups answer not-found // rather than "no store configured". ix, err := index.Open(filepath.Join(t.TempDir(), "index.db")) if err != nil { t.Fatal(err) } t.Cleanup(func() { ix.Close() }) r := ops.NewRegistry() if err := api.RegisterAll(r, api.Deps{Version: "9.9.9-test", Repos: api.NewRepos(map[string]*api.Repo{"test": {Index: ix}})}); err != nil { t.Fatal(err) } return r } func TestHomePageRendersThroughVersionOp(t *testing.T) { rec := httptest.NewRecorder() Handler(testRegistry(t), nil, nil).ServeHTTP(rec, httptest.NewRequest("GET", "/", nil)) if rec.Code != 200 { t.Fatalf("status %d", rec.Code) } body := rec.Body.String() if !strings.Contains(body, "quarry 9.9.9-test") { t.Fatalf("footer missing op-served version:\n%s", body) } if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/html") { t.Fatalf("content-type %q", ct) } // Principle 3: the ONLY script is the vendored Datastar module (the // one allowed framework, pure enhancement); no inline code. if n := strings.Count(body, "<script"); n != 1 || !strings.Contains(body, `src="/assets/datastar.js"`) { t.Fatalf("script policy violated (%d script tags):\n%s", n, body) } if strings.Contains(body, "<script>") { t.Fatal("inline script on the base page") } if !strings.Contains(body, "prefers-color-scheme") { t.Fatal("dark theme missing") } } func TestUnknownPathIs404(t *testing.T) { rec := httptest.NewRecorder() Handler(testRegistry(t), nil, nil).ServeHTTP(rec, httptest.NewRequest("GET", "/nope", nil)) if rec.Code != 404 { t.Fatalf("status %d", rec.Code) } } func TestThemed404Page(t *testing.T) { for _, path := range []string{"/nope", "/r/test/wiki/NoSuchPage", "/r/test/checkin/deadbeefdead", "/r/test/ticket/deadbeef"} { rec := httptest.NewRecorder() Handler(testRegistry(t), nil, nil).ServeHTTP(rec, httptest.NewRequest("GET", path, nil)) if rec.Code != 404 { t.Errorf("%s: status %d", path, rec.Code) continue } body := rec.Body.String() if !strings.HasPrefix(rec.Header().Get("Content-Type"), "text/html") { t.Errorf("%s: content-type %q — bare default 404?", path, rec.Header().Get("Content-Type")) } if !strings.Contains(body, "quarry 9.9.9-test") { t.Errorf("%s: 404 page missing version footer", path) } if !strings.Contains(body, `href="/"`) { t.Errorf("%s: 404 page has no way back into the site", path) } } } func TestPersistentNavOnEveryPage(t *testing.T) { // Ticket 010: every repo page carries that repo's nav and search // form; every page (fleet and 404 included) carries the site link. for _, path := range []string{"/r/test/timeline", "/r/test/wiki", "/r/test/ticket", "/r/test/search"} { rec := httptest.NewRecorder() Handler(testRegistry(t), nil, nil).ServeHTTP(rec, httptest.NewRequest("GET", path, nil)) body := rec.Body.String() for _, want := range []string{`<nav>`, `href="/r/test/timeline"`, `href="/r/test/wiki"`, `href="/r/test/ticket"`, `action="/r/test/search"`} { if !strings.Contains(body, want) { t.Errorf("%s: missing %s in header", path, want) } } } for _, path := range []string{"/", "/nope"} { rec := httptest.NewRecorder() Handler(testRegistry(t), nil, nil).ServeHTTP(rec, httptest.NewRequest("GET", path, nil)) if !strings.Contains(rec.Body.String(), `class="site" href="/"`) { t.Errorf("%s: missing site link", path) } } } func TestFleetPageListsRepos(t *testing.T) { rec := httptest.NewRecorder() Handler(testRegistry(t), nil, nil).ServeHTTP(rec, httptest.NewRequest("GET", "/", nil)) if rec.Code != 200 { t.Fatalf("status %d", rec.Code) } // A repository's name opens the repository, which is its files — // not the timeline. Landing on what changed rather than on what the // project is was the old behaviour. if !strings.Contains(rec.Body.String(), `href="/r/test"`) { t.Fatalf("fleet page missing repo link:\n%s", rec.Body.String()) } } func TestTablesScrollInWrapper(t *testing.T) { // Ticket 120: wide tables scroll inside .tw, not the page. rec := httptest.NewRecorder() Handler(testRegistry(t), nil, nil).ServeHTTP(rec, httptest.NewRequest("GET", "/r/test/timeline", nil)) body := rec.Body.String() if strings.Contains(body, "<table>") && !strings.Contains(body, `<div class="tw"><table>`) { t.Fatalf("timeline table not wrapped:\n%s", body) } } func TestTimelineRendersAllRowsWithBranches(t *testing.T) { // Regression: a template field mismatch once truncated the page at // the first row — Execute's error was discarded. Render a POPULATED // timeline and assert every row, branch labels, and a complete page. ix, err := index.Open(filepath.Join(t.TempDir(), "index.db")) if err != nil { t.Fatal(err) } t.Cleanup(func() { ix.Close() }) var batch []index.Parsed for i := 0; i < 5; i++ { h := strings.Repeat(fmt.Sprintf("%02d", i+1), 32) art := &artifact.Artifact{Type: artifact.Manifest, User: "dan", Date: fmt.Sprintf("2026-08-15T0%d:00:00", i), Comment: fmt.Sprintf("commit %d", i)} if i == 0 { art.Tags = []artifact.Tag{{Name: "*branch", Target: "*", Value: "trunk"}} } if i > 0 { art.Parents = []string{strings.Repeat(fmt.Sprintf("%02d", i), 32)} } batch = append(batch, index.Parsed{Hash: h, Art: art}) } if err := ix.FoldNow(batch); err != nil { t.Fatal(err) } r := ops.NewRegistry() if err := api.RegisterAll(r, api.Deps{Version: "9.9.9-test", Repos: api.NewRepos(map[string]*api.Repo{"test": {Index: ix}})}); err != nil { t.Fatal(err) } rec := httptest.NewRecorder() Handler(r, nil, nil).ServeHTTP(rec, httptest.NewRequest("GET", "/r/test/timeline", nil)) body := rec.Body.String() if got := strings.Count(body, `class="tl-card"`); got != 5 { t.Fatalf("rendered %d rows, want 5:\n%s", got, body) } // Count the GRAPH's layers specifically. A bare "<svg " count also // catches the file-listing icons, which live as data URIs in the // base stylesheet and have nothing to do with the timeline. if got := strings.Count(body, `<svg class="tl-`); got != 10 { // head + tail per row t.Fatalf("rendered %d graph layers, want 10", got) } if got := strings.Count(body, `<svg class="tl-head`); got != 5 { t.Fatalf("rendered %d head layers, want 5", got) } if !strings.Contains(body, `<span class="tl-pill">Sat · 2026-08-15</span>`) { t.Fatal("date pill missing") } if !strings.Contains(body, `href="/r/test/timeline?branch=trunk"><b>trunk</b></a>`) { t.Fatalf("branch label missing:\n%s", body) } if !strings.HasSuffix(strings.TrimSpace(body), "</html>") { t.Fatal("page truncated — template error swallowed") } } func TestTimelineGraphRails(t *testing.T) { // DAG: m (merge of c2+b1) ← newest // b1 (branch dev, parent c1) // c2 (trunk, parent c1) // c1 (trunk root) c1, c2, b1, m := strings.Repeat("aa", 32), strings.Repeat("bb", 32), strings.Repeat("cc", 32), strings.Repeat("dd", 32) entries := []timelineEntry{ {Hash: m, Date: "2026-08-15T04:00:00", Branch: "trunk", Comment: "merge", IsMerge: true, Parents: []string{c2, b1}}, {Hash: b1, Date: "2026-08-15T03:00:00", Branch: "dev", Comment: "dev work", Parents: []string{c1}}, {Hash: c2, Date: "2026-08-15T02:00:00", Branch: "trunk", Comment: "trunk work", Parents: []string{c1}}, {Hash: c1, Date: "2026-08-15T01:00:00", Branch: "trunk", Comment: "root"}, } days := buildTimeline("test", "trunk", entries) if len(days) != 1 || len(days[0].Rows) != 4 { t.Fatalf("days = %+v", days) } svg := func(i int) string { return string(days[0].Rows[i].Head) + string(days[0].Rows[i].Tail) } // The merge row curves out to a second rail. if !strings.Contains(svg(0), "<path") { t.Fatalf("merge row has no curve:\n%s", svg(0)) } // The dev row sits on a different x than the trunk rail and carries // the dev branch color. if !strings.Contains(svg(1), newPalette("trunk", entries).stroke("dev")) { t.Fatalf("dev row not dev-colored:\n%s", svg(1)) } if !strings.Contains(string(days[0].Rows[1].Head), `cx="21"`) { t.Fatalf("dev node not on second rail:\n%s", days[0].Rows[1].Head) } // The trunk row between branch point and merge shows the dev rail // passing through. if !strings.Contains(svg(2), `x1="21"`) { t.Fatalf("dev rail not passing through trunk row:\n%s", svg(2)) } // The root row converges both rails (a join curve). if !strings.Contains(svg(3), "<path") { t.Fatalf("root row has no join curve:\n%s", svg(3)) } // Root has nothing below: no line from cy to unitH on the node rail. if strings.Contains(svg(3), `y1="13" x2="7" y2="26"`) { t.Fatalf("root row continues below:\n%s", svg(3)) } } func TestTimelineGraphBranchColorContinuity(t *testing.T) { // The dev branch line must be dev-colored end to end: the merge // curve out of m, the rail between m and b1, b1's own segments, and // b1's join curve at the fork commit c1. No empty-branch hash hues. c1, c2, b1, m := strings.Repeat("aa", 32), strings.Repeat("bb", 32), strings.Repeat("cc", 32), strings.Repeat("dd", 32) entries := []timelineEntry{ {Hash: m, Date: "2026-08-15T04:00:00", Branch: "trunk", Comment: "merge", IsMerge: true, Parents: []string{c2, b1}}, {Hash: b1, Date: "2026-08-15T03:00:00", Branch: "dev", Comment: "dev work", Parents: []string{c1}}, {Hash: c2, Date: "2026-08-15T02:00:00", Branch: "trunk", Comment: "trunk work", Parents: []string{c1}}, {Hash: c1, Date: "2026-08-15T01:00:00", Branch: "trunk", Comment: "root"}, } days := buildTimeline("test", "trunk", entries) rows := days[0].Rows dev, unknown := newPalette("trunk", entries).stroke("dev"), newPalette("trunk", nil).stroke("") // Merge row: the curve AND the tail rail toward b1 are dev-colored. mergeHead, mergeTail := string(rows[0].Head), string(rows[0].Tail) if !strings.Contains(mergeHead, `Q`) || !strings.Contains(mergeHead, dev) { t.Fatalf("merge curve not dev-colored:\n%s", mergeHead) } if !strings.Contains(mergeTail, dev) { t.Fatalf("rail below merge not dev-colored:\n%s", mergeTail) } // b1's node and below-rail are dev; the fork row's join curve is dev. if !strings.Contains(string(rows[1].Head), dev) || !strings.Contains(string(rows[1].Tail), dev) { t.Fatalf("dev commit segments not dev-colored") } rootHead := string(rows[3].Head) if !strings.Contains(rootHead, `Q`) || !strings.Contains(rootHead, dev) { t.Fatalf("join curve at fork not dev-colored:\n%s", rootHead) } // The pass-through row (c2) shows the dev rail in dev color. if !strings.Contains(string(rows[2].Head), dev) { t.Fatalf("pass-through dev rail not dev-colored:\n%s", rows[2].Head) } // Nothing anywhere uses the unknown-branch neutral (all on-page). for i, r := range rows { if strings.Contains(string(r.Head), unknown) || strings.Contains(string(r.Tail), unknown) { t.Fatalf("row %d uses unknown-branch color", i) } } // Uniform stroke width. for i, r := range rows { all := string(r.Head) + string(r.Tail) if strings.Contains(all, `stroke-width="2"`) || strings.Contains(all, "vector-effect") { t.Fatalf("row %d has non-uniform stroke setup", i) } } } func TestTimelineOffPageParentIsNeutral(t *testing.T) { // A merge whose second parent is beyond the page: its rail renders // neutral, never a hashed hue for the empty branch name. m := strings.Repeat("ee", 32) off := strings.Repeat("ff", 32) days := buildTimeline("test", "trunk", []timelineEntry{ {Hash: m, Date: "2026-08-15T04:00:00", Branch: "trunk", Comment: "merge far", IsMerge: true, Parents: []string{strings.Repeat("aa", 32), off}}, }) head := string(days[0].Rows[0].Head) if !strings.Contains(head, newPalette("trunk", nil).stroke("")) { t.Fatalf("off-page merge rail not neutral:\n%s", head) } } type fakeNotifier struct{ ch chan struct{} } func (f *fakeNotifier) Watch(string) (<-chan struct{}, func()) { return f.ch, func() {} } func TestLiveTimelineSSE(t *testing.T) { // The live endpoint streams a Datastar element patch containing the // re-rendered #tl fragment when the notifier ticks. ix, err := index.Open(filepath.Join(t.TempDir(), "index.db")) if err != nil { t.Fatal(err) } t.Cleanup(func() { ix.Close() }) h := strings.Repeat("99", 32) if err := ix.FoldNow([]index.Parsed{{Hash: h, Art: &artifact.Artifact{ Type: artifact.Manifest, Date: "2026-08-15T10:00:00", User: "dan", Comment: "live one"}}}); err != nil { t.Fatal(err) } r := ops.NewRegistry() if err := api.RegisterAll(r, api.Deps{Version: "v", Repos: api.NewRepos(map[string]*api.Repo{"test": {Index: ix}})}); err != nil { t.Fatal(err) } fn := &fakeNotifier{ch: make(chan struct{}, 1)} srv := httptest.NewServer(Handler(r, fn, nil)) t.Cleanup(srv.Close) // The page advertises the live hookup. page, _ := srv.Client().Get(srv.URL + "/r/test/timeline") body, _ := io.ReadAll(page.Body) page.Body.Close() if !strings.Contains(string(body), `data-init="@get('/r/test/timeline.sse?`) { t.Fatalf("page missing live hookup:\n%s", body) } resp, err := srv.Client().Get(srv.URL + "/r/test/timeline.sse") if err != nil { t.Fatal(err) } t.Cleanup(func() { resp.Body.Close() }) if ct := resp.Header.Get("Content-Type"); ct != "text/event-stream" { t.Fatalf("content-type %q", ct) } fn.ch <- struct{}{} rd := bufio.NewReader(resp.Body) deadline := time.After(5 * time.Second) got := "" for !strings.Contains(got, "datastar-patch-elements") || !strings.Contains(got, "live one") { lineCh := make(chan string, 1) go func() { l, _ := rd.ReadString('\n') lineCh <- l }() select { case l := <-lineCh: got += l case <-deadline: t.Fatalf("no patch within deadline; got:\n%s", got) } } if !strings.Contains(got, `data: elements <div id="tl">`) { t.Fatalf("patch not a #tl morph:\n%s", got) } } // TestWikiHistoryPages covers the wiki history list, revision view with // its banner, and the timeline linking wiki rows to their revision. func TestWikiHistoryPages(t *testing.T) { ix, err := index.Open(filepath.Join(t.TempDir(), "index.db")) if err != nil { t.Fatal(err) } t.Cleanup(func() { ix.Close() }) if err := ix.FoldNow([]index.Parsed{ {Hash: strings.Repeat("a1", 20), Art: &artifact.Artifact{Type: artifact.Wiki, WikiTitle: "Guide", Date: "2026-08-15T09:00:00", User: "dan", Mimetype: "text/x-markdown", Body: []byte("first draft\n")}}, {Hash: strings.Repeat("b2", 20), Art: &artifact.Artifact{Type: artifact.Wiki, WikiTitle: "Guide", Date: "2026-08-15T10:00:00", User: "eve", Mimetype: "text/x-markdown", Body: []byte("polished\n")}}, }); err != nil { t.Fatal(err) } r := ops.NewRegistry() if err := api.RegisterAll(r, api.Deps{Version: "t", Repos: api.NewRepos(map[string]*api.Repo{"test": {Index: ix}})}); err != nil { t.Fatal(err) } h := Handler(r, nil, nil) get := func(path string) string { rec := httptest.NewRecorder() h.ServeHTTP(rec, httptest.NewRequest("GET", path, nil)) if rec.Code != 200 { t.Fatalf("%s: status %d\n%s", path, rec.Code, rec.Body.String()) } return rec.Body.String() } // Latest view: history link, no revision banner. body := get("/r/test/wiki/Guide") if !strings.Contains(body, "polished") || !strings.Contains(body, `/r/test/wiki/Guide/history`) { t.Fatalf("latest view:\n%s", body) } if strings.Contains(body, "Historical revision") { t.Fatal("banner on latest view") } // History lists both edits, newest first, old one linked with ?rev=. body = get("/r/test/wiki/Guide/history") if !strings.Contains(body, "?rev="+strings.Repeat("a1", 6)) { t.Fatalf("old revision not linked:\n%s", body) } if i, j := strings.Index(body, strings.Repeat("b2", 6)), strings.Index(body, strings.Repeat("a1", 6)); i < 0 || j < 0 || i > j { t.Fatalf("history order wrong (b2 at %d, a1 at %d):\n%s", i, j, body) } // Revision view: old content plus the banner back to latest. body = get("/r/test/wiki/Guide?rev=" + strings.Repeat("a1", 6)) if !strings.Contains(body, "first draft") || strings.Contains(body, "polished") { t.Fatalf("revision content:\n%s", body) } if !strings.Contains(body, "Historical revision") { t.Fatalf("banner missing:\n%s", body) } // Timeline wiki rows link the revision they represent. Check-ins // are the timeline's DEFAULT view now, so wiki rows are asked for. body = get("/r/test/timeline?y=w") if !strings.Contains(body, "/r/test/wiki/Guide?rev="+strings.Repeat("a1", 6)) { t.Fatalf("timeline wiki row not revision-linked:\n%s", body) } } // Real stores carry wiki titles with slashes (tcl's "(Initi/Fin)alize"); // every generated link must escape them so the mux sees one segment. func TestWikiTitleWithSlash(t *testing.T) { ix, err := index.Open(filepath.Join(t.TempDir(), "index.db")) if err != nil { t.Fatal(err) } t.Cleanup(func() { ix.Close() }) if err := ix.FoldNow([]index.Parsed{ {Hash: strings.Repeat("c3", 20), Art: &artifact.Artifact{Type: artifact.Wiki, WikiTitle: "(Initi/Fin)alize", Date: "2026-08-15T09:00:00", User: "dan", Mimetype: "text/x-markdown", Body: []byte("lifecycle\n")}}, }); err != nil { t.Fatal(err) } r := ops.NewRegistry() if err := api.RegisterAll(r, api.Deps{Version: "t", Repos: api.NewRepos(map[string]*api.Repo{"test": {Index: ix}})}); err != nil { t.Fatal(err) } h := Handler(r, nil, nil) rec := httptest.NewRecorder() h.ServeHTTP(rec, httptest.NewRequest("GET", "/r/test/wiki", nil)) if !strings.Contains(rec.Body.String(), `/r/test/wiki/%28Initi%2FFin%29alize`) { t.Fatalf("wiki list link not escaped:\n%s", rec.Body.String()) } for _, path := range []string{ "/r/test/wiki/%28Initi%2FFin%29alize", "/r/test/wiki/%28Initi%2FFin%29alize/history", } { rec := httptest.NewRecorder() h.ServeHTTP(rec, httptest.NewRequest("GET", path, nil)) if rec.Code != 200 || !strings.Contains(rec.Body.String(), "(Initi/Fin)alize") { t.Fatalf("%s: status %d\n%s", path, rec.Code, rec.Body.String()) } } } // TestLiveFleetRevStream: the landing page is live AT LOAD, and the // stream that makes it live carries only an invalidation signal. The // old stream carried rendered rows and therefore the filter it was // opened with — opened at load, that meant repainting a reader's // filtered view with everything a few seconds after they typed. A // revision number cannot be stale: the client re-requests the listing // with its CURRENT form state. func TestLiveFleetRevStream(t *testing.T) { ix, err := index.Open(filepath.Join(t.TempDir(), "index.db")) if err != nil { t.Fatal(err) } t.Cleanup(func() { ix.Close() }) if err := ix.FoldNow([]index.Parsed{{Hash: strings.Repeat("77", 32), Art: &artifact.Artifact{ Type: artifact.Manifest, Date: "2026-08-15T10:00:00", User: "dan", Comment: "fleet tip"}}}); err != nil { t.Fatal(err) } r := ops.NewRegistry() if err := api.RegisterAll(r, api.Deps{Version: "v", Repos: api.NewRepos(map[string]*api.Repo{"test": {Index: ix}})}); err != nil { t.Fatal(err) } fn := &fakeNotifier{ch: make(chan struct{}, 1)} srv := httptest.NewServer(Handler(r, fn, nil)) t.Cleanup(srv.Close) page, _ := srv.Client().Get(srv.URL + "/") body, _ := io.ReadAll(page.Body) page.Body.Close() // Live at load: the signal stream opens with the page… if !strings.Contains(string(body), `data-init="@get('/fleet.rev')"`) { t.Fatalf("home does not open the revision stream at load:\n%s", body) } // …and every refresh goes through the form, so it carries the filter // as it is NOW. The && guard stops the effect's first run (rev 0) // from re-fetching what the server just rendered. if !strings.Contains(string(body), `data-effect="$fleetRev && @get('/fleet.frag', {contentType: 'form'})"`) { t.Fatalf("no revision-driven refresh effect:\n%s", body) } if !strings.Contains(string(body), `data-on:input__debounce.300ms="@get('/fleet.frag'`) { t.Fatalf("filter input does not refresh through the one-shot:\n%s", body) } // The signal stream must NEVER render rows: rendered rows are what // carried the stale filter. resp, err := srv.Client().Get(srv.URL + "/fleet.rev") if err != nil { t.Fatal(err) } t.Cleanup(func() { resp.Body.Close() }) fn.ch <- struct{}{} rd := bufio.NewReader(resp.Body) deadline := time.After(5 * time.Second) got := "" for !strings.Contains(got, "fleetRev") { lineCh := make(chan string, 1) go func() { l, _ := rd.ReadString('\n') lineCh <- l }() select { case l := <-lineCh: got += l case <-deadline: t.Fatalf("no signal within deadline; got:\n%s", got) } } if !strings.Contains(got, "datastar-patch-signals") { t.Fatalf("revision did not arrive as a signal patch:\n%s", got) } if strings.Contains(got, "datastar-patch-elements") { t.Fatalf("the signal stream rendered elements; that is the stale-filter bug reborn:\n%s", got) } } // TestForumTechnotePages renders the forum and technote read surfaces. func TestForumTechnotePages(t *testing.T) { ix, err := index.Open(filepath.Join(t.TempDir(), "index.db")) if err != nil { t.Fatal(err) } t.Cleanup(func() { ix.Close() }) root := strings.Repeat("aa", 20) if err := ix.FoldNow([]index.Parsed{ {Hash: root, Art: &artifact.Artifact{Type: artifact.Forum, ThreadTitle: "First topic", Date: "2026-08-16T09:00:00", User: "dan", Body: []byte("opening post body\n")}}, {Hash: strings.Repeat("bb", 20), Art: &artifact.Artifact{Type: artifact.Forum, ThreadRoot: root, InReplyTo: root, Date: "2026-08-16T10:00:00", User: "eve", Body: []byte("a considered reply\n")}}, {Hash: strings.Repeat("cc", 20), Art: &artifact.Artifact{Type: artifact.Technote, EventID: strings.Repeat("dd", 20), EventTime: "2026-08-16T08:00:00", Date: "2026-08-16T08:00:00", User: "dan", Mimetype: "text/x-markdown", Body: []byte("# Note\n\ntechnote body text\n")}}, }); err != nil { t.Fatal(err) } r := ops.NewRegistry() if err := api.RegisterAll(r, api.Deps{Version: "t", Repos: api.NewRepos(map[string]*api.Repo{"test": {Index: ix}})}); err != nil { t.Fatal(err) } h := Handler(r, nil, nil) get := func(path string) string { rec := httptest.NewRecorder() h.ServeHTTP(rec, httptest.NewRequest("GET", path, nil)) if rec.Code != 200 { t.Fatalf("%s: status %d\n%s", path, rec.Code, rec.Body.String()) } return rec.Body.String() } if b := get("/r/test/forum"); !strings.Contains(b, "First topic") || !strings.Contains(b, "2 posts") { t.Fatalf("forum list:\n%s", b) } if b := get("/r/test/forum/" + root[:16]); !strings.Contains(b, "opening post body") || !strings.Contains(b, "a considered reply") { t.Fatalf("forum thread:\n%s", b) } if b := get("/r/test/technote"); !strings.Contains(b, "/r/test/technote/"+strings.Repeat("dd", 8)) { t.Fatalf("technote list:\n%s", b) } if b := get("/r/test/technote/" + strings.Repeat("dd", 8)); !strings.Contains(b, "technote body text") { t.Fatalf("technote page:\n%s", b) } // Nav links present on every repo page. if b := get("/r/test/timeline"); !strings.Contains(b, `/r/test/forum`) || !strings.Contains(b, `/r/test/technote`) { t.Fatal("nav missing forum/technote links") } } // fakeChatLive delivers preset messages to the SSE handler. type fakeChatLive struct{ ch chan ChatMessage } func (f *fakeChatLive) SubscribeChat(string) (<-chan ChatMessage, func()) { return f.ch, func() {} } func TestChatPage(t *testing.T) { ix, err := index.Open(filepath.Join(t.TempDir(), "index.db")) if err != nil { t.Fatal(err) } t.Cleanup(func() { ix.Close() }) r := ops.NewRegistry() fc := &webChatStub{msgs: []chat.Message{{User: "dan", Text: "hello world", At: "2026-08-16T12:00:00Z"}}} if err := api.RegisterAll(r, api.Deps{Version: "t", Repos: api.NewRepos(map[string]*api.Repo{"test": {Index: ix, Code: "c1"}}), Chat: fc}); err != nil { t.Fatal(err) } live := &fakeChatLive{ch: make(chan ChatMessage, 1)} h := Handler(r, nil, live) // The page shows history + the post form + the live hookup + nav link. rec := httptest.NewRecorder() h.ServeHTTP(rec, httptest.NewRequest("GET", "/r/test/chat", nil)) body := rec.Body.String() if !strings.Contains(body, "hello world") || !strings.Contains(body, `action="/r/test/chat"`) { t.Fatalf("chat page:\n%s", body) } if !strings.Contains(body, `data-init="@get('/r/test/chat.sse')"`) { t.Fatal("chat page missing live hookup") } if !strings.Contains(body, `/r/test/chat`) || !strings.Contains(rec.Body.String(), `id="chatlog"`) { t.Fatal("chat log container missing") } // The SSE stream appends a new message as a datastar patch. Close // the response before the server so Close doesn't block on the // still-open stream. srv := httptest.NewServer(h) resp, err := srv.Client().Get(srv.URL + "/r/test/chat.sse") if err != nil { srv.Close() t.Fatal(err) } live.ch <- ChatMessage{User: "eve", Text: "live line", At: "now"} rd := bufio.NewReader(resp.Body) deadline := time.After(5 * time.Second) got := "" for !strings.Contains(got, "live line") || !strings.Contains(got, "mode append") { lineCh := make(chan string, 1) go func() { l, _ := rd.ReadString('\n'); lineCh <- l }() select { case l := <-lineCh: got += l case <-deadline: resp.Body.Close() srv.Close() t.Fatalf("no chat patch:\n%s", got) } } resp.Body.Close() srv.Close() if !strings.Contains(got, "selector #chatlog") { t.Fatalf("patch not targeting #chatlog:\n%s", got) } } type webChatStub struct{ msgs []chat.Message } func (s *webChatStub) Post(code string, m chat.Message) error { s.msgs = append(s.msgs, m); return nil } func (s *webChatStub) History(code string, limit int) ([]chat.Message, error) { return s.msgs, nil } // The live stream re-renders the whole listing, so it has to know what // the reader is looking at. A stream that does not carry the filter // replaces the filtered listing with the default about a second after // it appears — which reads as the filter simply not working. func TestLiveFleetStreamCarriesTheFilter(t *testing.T) { ix, err := index.Open(filepath.Join(t.TempDir(), "index.db")) if err != nil { t.Fatal(err) } t.Cleanup(func() { ix.Close() }) r := ops.NewRegistry() if err := api.RegisterAll(r, api.Deps{Version: "v", Repos: api.NewRepos( map[string]*api.Repo{"alpha": {Index: ix}, "beta": {Index: ix}})}); err != nil { t.Fatal(err) } fn := &fakeNotifier{ch: make(chan struct{}, 1)} srv := httptest.NewServer(Handler(r, fn, nil)) t.Cleanup(srv.Close) // The page must point the stream at its own view, not at the bare URL. page, _ := srv.Client().Get(srv.URL + "/?q=alpha") body, _ := io.ReadAll(page.Body) page.Body.Close() // The filter reaches the stream through the form itself now: the // request carries q= because Datastar serializes the surrounding // form, so the page need not encode it in the URL. if !strings.Contains(string(body), `value="alpha"`) { t.Fatalf("the filter is not reflected in the form:\n%s", body) } // And the one-shot must actually apply it — and CLOSE: a held // connection is what carried the stale filter. ReadAll returning is // the closure assertion. resp, err := srv.Client().Get(srv.URL + "/fleet.frag?q=alpha") if err != nil { t.Fatal(err) } defer resp.Body.Close() done := make(chan []byte, 1) go func() { b, _ := io.ReadAll(resp.Body); done <- b }() var frag string select { case b := <-done: frag = string(b) case <-time.After(3 * time.Second): t.Fatal("/fleet.frag held its connection open; a one-shot must close") } if !strings.Contains(frag, "alpha") { t.Errorf("filtered stream dropped the match:\n%s", frag) } if strings.Contains(frag, "beta") { t.Errorf("filtered stream includes a repo the filter excludes:\n%s", frag) } // The controls come back with the repaint, so they must carry the // filter too or the box empties itself while the listing stays filtered. // The controls are NOT in the fragment any more — they live outside // the morphed region so a repaint cannot tear the input out from // under the reader mid-keystroke. What the fragment must carry is // the count, which describes the rows it is replacing. if strings.Contains(frag, `name="q"`) { t.Errorf("the filter input is inside the morphed region again:\n%s", frag) } if !strings.Contains(frag, "repositories") { t.Errorf("the repaint dropped the count that describes its rows:\n%s", frag) } } // The first SSE repaint arrives immediately on connect and REPLACES the // server-rendered listing, so the fragment must be given everything the // full page gave the same template. Any field it omits renders on load // and then vanishes a moment later — which is what happened to the // "23 of 23" count and the New repository link, and before them to the // filter and sort. // // The admin control is per-connection: it must appear for an admin's // stream and for nobody else's. func TestFleetFragmentCarriesEverythingThePageDoes(t *testing.T) { ix, err := index.Open(filepath.Join(t.TempDir(), "index.db")) if err != nil { t.Fatal(err) } t.Cleanup(func() { ix.Close() }) r := ops.NewRegistry() if err := api.RegisterAll(r, api.Deps{Version: "v", Repos: api.NewRepos( map[string]*api.Repo{"alpha": {Index: ix}, "beta": {Index: ix}})}); err != nil { t.Fatal(err) } anon, err := fleetFragment(r, url.Values{}, false) if err != nil { t.Fatal(err) } // Unfiltered the listing states the total outright; the point is // that the number survives the repaint at all. if !strings.Contains(anon, "2 repositories") { t.Errorf("the fragment dropped the fleet total, so the count vanishes on repaint:\n%s", anon) } // Filtered, the denominator is the fleet rather than the match // count: "1 of 1" is true and tells a reader nothing. one, err := fleetFragment(r, url.Values{"q": {"alpha"}}, false) if err != nil { t.Fatal(err) } if !strings.Contains(one, "1 of 2 repositories") { t.Errorf("a filtered listing does not count against the fleet:\n%s", one) } // The persistent New repository link lives with the controls now, // outside the morph. What the fragment still decides is the // empty-fleet prompt, which is admin-only — and must stay so. empty := api.NewRepos(map[string]*api.Repo{}) r2 := ops.NewRegistry() if err := api.RegisterAll(r2, api.Deps{Version: "v", Repos: empty}); err != nil { t.Fatal(err) } anonEmpty, err := fleetFragment(r2, url.Values{}, false) if err != nil { t.Fatal(err) } if strings.Contains(anonEmpty, "Create one") { t.Error("an anonymous stream is offered the admin-only create prompt") } adminEmpty, err := fleetFragment(r2, url.Values{}, true) if err != nil { t.Fatal(err) } if !strings.Contains(adminEmpty, "Create one") { t.Error("an admin's stream loses the create prompt on repaint") } } // stubAccts is the AccountStore the dashboard needs; everything else // no-ops. type stubAccts struct{ grants map[string]map[string]string } func (s stubAccts) Accounts() ([]api.Account, error) { return nil, nil } func (s stubAccts) AddAccount(string, string, bool) error { return nil } func (s stubAccts) SetPassword(string, string) error { return nil } func (s stubAccts) DeleteAccount(string) error { return nil } func (s stubAccts) Grant(string, string, string) error { return nil } func (s stubAccts) Revoke(string, string) error { return nil } func (s stubAccts) GrantsFor(a string) (map[string]string, error) { return s.grants[a], nil } // A signed-in reader's "/" is their dashboard; anonymous readers and // ?view=explore get the fleet unchanged — GitHub's rule. The dashboard // must never 500 the homepage: an account with no grants falls back to // the fleet. func TestRootSwitchesOnSession(t *testing.T) { ix, err := index.Open(filepath.Join(t.TempDir(), "index.db")) if err != nil { t.Fatal(err) } t.Cleanup(func() { ix.Close() }) if err := ix.FoldNow([]index.Parsed{{Hash: strings.Repeat("55", 32), Art: &artifact.Artifact{ Type: artifact.Manifest, Date: "2026-08-19T09:00:00", User: "dan", Comment: "dash tip"}}}); err != nil { t.Fatal(err) } r := ops.NewRegistry() if err := api.RegisterAll(r, api.Deps{Version: "v", Repos: api.NewRepos(map[string]*api.Repo{"alpha": {Index: ix}}), Accounts: stubAccts{grants: map[string]map[string]string{"dan": {"alpha": "write"}}}, }); err != nil { t.Fatal(err) } sess := fakeSessions{user: "dan"} srv := httptest.NewServer(HandlerWith(r, nil, nil, []Option{WithSessions(sess)})) t.Cleanup(srv.Close) get := func(path string, cookie bool) string { req, _ := http.NewRequest("GET", srv.URL+path, nil) if cookie { req.AddCookie(&http.Cookie{Name: sessionCookie, Value: "good"}) } resp, err := srv.Client().Do(req) if err != nil { t.Fatal(err) } defer resp.Body.Close() b, _ := io.ReadAll(resp.Body) return string(b) } home := get("/", true) // Structure, not copy: the dashboard's three sections and the door // to the fleet. Headings are design surface and free to change. for _, want := range []string{`class="dash-repos"`, `class="dash-feed"`, `class="dash-mine"`, `href="/?view=explore"`} { if !strings.Contains(home, want) { t.Fatalf("signed-in home missing %q:\n%s", want, home) } } anon := get("/", false) if strings.Contains(anon, `class="dash-repos"`) { t.Fatal("anonymous home rendered a dashboard") } if !strings.Contains(anon, "filter") { t.Fatalf("anonymous home is not the fleet:\n%s", anon) } explore := get("/?view=explore", true) if strings.Contains(explore, "Your repositories") { t.Fatal("?view=explore did not escape the dashboard") } } // Every funnel that answers "internal error" must leave a journal // entry: the web fail() default, and the panic recovery (which before // this existed dropped the connection with a stack trace nobody saw). // The /fleet page then renders the journal — that page saying "ok" // while the web layer burned is the failure this whole surface exists // to end. func TestServerErrorsAreJournaled(t *testing.T) { before, _, _ := errlog.Default.Stats() r := ops.NewRegistry() if err := api.RegisterAll(r, api.Deps{Version: "v", Repos: api.NewRepos(map[string]*api.Repo{}), Errors: errlog.Default}); err != nil { t.Fatal(err) } h := HandlerWith(r, nil, nil, []Option{WithSessions(fakeSessions{ user: "root", roles: map[string]ops.Auth{"": ops.Admin}})}) mux := http.NewServeMux() mux.Handle("/", h) mux.Handle("GET /boom", recovered(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { panic("kaboom") }))) srv := httptest.NewServer(mux) t.Cleanup(srv.Close) // A panic becomes a clean 500 and a journal entry. resp, err := srv.Client().Get(srv.URL + "/boom") if err != nil { t.Fatalf("panic dropped the connection: %v", err) } resp.Body.Close() if resp.StatusCode != http.StatusInternalServerError { t.Fatalf("panic answered %d, want 500", resp.StatusCode) } after, _, _ := errlog.Default.Stats() if after <= before { t.Fatal("the panic left no journal entry") } found := false for _, e := range errlog.Default.Recent(0) { if e.Surface == "panic" && strings.Contains(e.Message, "kaboom") { found = true } } if !found { t.Fatalf("journal lacks the panic: %+v", errlog.Default.Recent(5)) } // The admin fleet page renders it. req, _ := http.NewRequest("GET", srv.URL+"/fleet", nil) req.AddCookie(&http.Cookie{Name: sessionCookie, Value: "good"}) fresp, err := srv.Client().Do(req) if err != nil { t.Fatal(err) } defer fresp.Body.Close() body, _ := io.ReadAll(fresp.Body) if !strings.Contains(string(body), "Server errors") || !strings.Contains(string(body), "kaboom") { t.Fatalf("/fleet does not show the journal:\n%s", body) } } // The fleet's file-ticket button and the prefilled form it leads to: // the journal knows the message, surface and count, so the ticket // starts written. Only a designated tracker draws the buttons — on a // forge with no self-tracker there is nowhere honest to send them. func TestErrorJournalFilesTickets(t *testing.T) { errlog.Default.Report("web", "/exploding", errors.New("prefill probe boom")) r := ops.NewRegistry() if err := api.RegisterAll(r, api.Deps{Version: "v", Repos: api.NewRepos(map[string]*api.Repo{"selfrepo": {Index: wtIndex(t)}}), Errors: errlog.Default}); err != nil { t.Fatal(err) } sess := fakeSessions{user: "root", roles: map[string]ops.Auth{ "": ops.Admin, "selfrepo": ops.Token}} srv := httptest.NewServer(HandlerWith(r, nil, nil, []Option{WithSessions(sess), WithTracker("selfrepo")})) t.Cleanup(srv.Close) get := func(path string) string { req, _ := http.NewRequest("GET", srv.URL+path, nil) req.AddCookie(&http.Cookie{Name: sessionCookie, Value: "good"}) resp, err := srv.Client().Do(req) if err != nil { t.Fatal(err) } defer resp.Body.Close() b, _ := io.ReadAll(resp.Body) return string(b) } fleet := get("/fleet") if !strings.Contains(fleet, "/r/selfrepo/ticket/new?title=") || !strings.Contains(fleet, "file ticket") { t.Fatalf("no file-ticket button on the journal row:\n%s", fleet) } form := get("/r/selfrepo/ticket/new?title=prefill+probe+boom&subsystem=web&comment=details+here") for _, want := range []string{ `value="prefill probe boom"`, `name="subsystem" value="web"`, `>details here</textarea>`, } { if !strings.Contains(form, want) { t.Fatalf("form not prefilled with %s:\n%s", want, form) } } } // wtIndex is a minimal open index for handler tests. func wtIndex(t *testing.T) *index.Index { t.Helper() ix, err := index.Open(filepath.Join(t.TempDir(), "ix.db")) if err != nil { t.Fatal(err) } t.Cleanup(func() { ix.Close() }) return ix } // A chip earns its box by DISTINGUISHING a ticket from its neighbours. // The closed listing is where this bites: every finished ticket in a // healthy tracker resolves "Fixed", so a resolution chip on all of them // is 34 repetitions of one word. The odd one out must still show. func TestChipsMuteTheValueEveryRowShares(t *testing.T) { var ts []ticketSummary for i := 0; i < 9; i++ { ts = append(ts, ticketSummary{ ID: fmt.Sprintf("%040d", i), Title: "t", Status: "Closed", Type: "Code_Defect", Subsystem: "web", Resolution: "Fixed", }) } ts[8].Resolution = "Works_As_Designed" ts[8].Subsystem = "ci" rows := ticketRows("r", ts, map[string][]string{}) has := func(r ticketRow, text string) bool { for _, c := range r.Chips { if c.Text == text { return true } } return false } // Shared by 8 of 9 and 9 of 9: neither says anything here. if has(rows[0], "Fixed") { t.Error("the resolution every closed ticket shares is still a chip") } if has(rows[0], "Code_Defect") { t.Error("the type every listed ticket shares is still a chip") } if has(rows[0], "web") { t.Error("the subsystem 8 of 9 share is still a chip") } // The deviations are the whole point of the row. if !has(rows[8], "Works_As_Designed") { t.Error("the one resolution that differs was muted with the rest") } if !has(rows[8], "ci") { t.Error("the one subsystem that differs was muted with the rest") } // Below the floor a "majority" is an accident, so nothing is muted. small := ticketRows("r", ts[:3], map[string][]string{}) if !has(small[0], "Fixed") { t.Error("a three-row listing muted a value on majority grounds") } } // State, view, sort and the report are INDEPENDENT axes. Narrowing to a // subsystem and then clicking Closed must show that subsystem's closed // tickets; an axis the header silently drops is an axis a reader cannot // trust, and every link on the page is built by this one type. func TestTicketNavKeepsTheReportAcrossEveryAxis(t *testing.T) { nav := ticketNav{Repo: "alpha", Sort: "recent", Report: url.Values{"subsystem": {"web"}, "author": {"dan"}}} for _, tc := range []struct{ axis, value string }{ {"state", "closed"}, {"state", "all"}, {"view", "board"}, {"sort", ""}, } { got := nav.URL(tc.axis, tc.value) q, err := url.Parse(got) if err != nil { t.Fatal(err) } v := q.Query() if v.Get("subsystem") != "web" || v.Get("author") != "dan" { t.Errorf("switching %s=%q dropped the report: %s", tc.axis, tc.value, got) } } // Clear drops the report and keeps where you are standing. cleared, err := url.Parse(nav.Clear()) if err != nil { t.Fatal(err) } if cleared.Query().Has("subsystem") || cleared.Query().Has("author") { t.Errorf("clear kept a report axis: %s", nav.Clear()) } if cleared.Query().Get("sort") != "recent" { t.Errorf("clear threw away the sort the reader chose: %s", nav.Clear()) } // A GET form replaces the whole query string, so the dropdown form // restates what its selects do NOT carry — and must not restate // what they do, or every axis is submitted twice. var names []string for _, f := range (ticketNav{Repo: "alpha", State: "closed", View: "table", Order: "status", Title: "crash", Report: url.Values{"author": {"dan"}, "type": {"bug"}}}).Hidden() { names = append(names, f.Name) } if strings.Join(names, ",") != "state,view,order,title" { t.Errorf("hidden fields = %v, want state,view,order,title", names) } for _, f := range names { if _, isAxis := map[string]bool{"author": true, "type": true}[f]; isAxis { t.Errorf("hidden field %q duplicates a dropdown that already submits it", f) } } // The query box's form carries presentation only: its text already // spells the state, the report and the free words. var vf []string for _, f := range (ticketNav{Repo: "alpha", State: "closed", View: "table", Order: "status", Title: "crash"}).ViewFields() { vf = append(vf, f.Name) } if strings.Join(vf, ",") != "view,order" { t.Errorf("query box hidden fields = %v, want view,order", vf) } } // A table header sorts by naming a key, and clicking the column you are // already sorted by turns it around where the whitelist has a reverse. func TestColumnHeadersSortAndReverse(t *testing.T) { if got := (ticketNav{Repo: "a", View: "table"}).OrderURL("updated"); !strings.Contains(got, "order=oldest") { t.Errorf("re-clicking the default column did not reverse it: %s", got) } if got := (ticketNav{Repo: "a", View: "table", Order: "oldest"}).OrderURL("updated"); !strings.Contains(got, "order=updated") { t.Errorf("reversing back to the default failed: %s", got) } // A column with no opposite stays put rather than pretending. if got := (ticketNav{Repo: "a", View: "table", Order: "status"}).OrderURL("status"); !strings.Contains(got, "order=status") { t.Errorf("a column with no reverse should stay put: %s", got) } // Every column the table draws must name a real order key. for _, c := range ticketColumns { if _, ok := index.TicketOrders[c.Key]; !ok { t.Errorf("column %q names no key of index.TicketOrders", c.Key) } } } func TestQueryBoxAndParamsAreTwoSpellingsOfOneReport(t *testing.T) { for _, tc := range []struct { in string want url.Values spell string // how the box renders it back; "" means same as in }{ {in: "subsystem:web", want: url.Values{"subsystem": {"web"}}}, {in: "is:closed subsystem:web", want: url.Values{"state": {"closed"}, "subsystem": {"web"}}}, {in: "state:all author:dan", want: url.Values{"state": {"all"}, "author": {"dan"}}, spell: "is:all author:dan"}, {in: "crash on empty", want: url.Values{"title": {"crash on empty"}}}, {in: "is:closed type:Code_Defect index loss", want: url.Values{"state": {"closed"}, "type": {"Code_Defect"}, "title": {"index loss"}}, spell: "is:closed type:Code_Defect index loss"}, // A prefix nobody defined is text, not an error and not dropped: // somebody who types fix:later meant those characters. {in: "fix:later", want: url.Values{"title": {"fix:later"}}}, // Quotes hold a value with a space in it together. {in: `subsystem:"web ui" crash`, want: url.Values{"subsystem": {"web ui"}, "title": {"crash"}}, spell: `subsystem:"web ui" crash`}, {in: "", want: url.Values{}}, } { got := ticketParseQuery(tc.in) if len(got) != len(tc.want) { t.Errorf("parse(%q) = %v, want %v", tc.in, got, tc.want) continue } for k, v := range tc.want { if got.Get(k) != v[0] { t.Errorf("parse(%q)[%s] = %q, want %q", tc.in, k, got.Get(k), v[0]) } } // And back: what the box shows for the listing it produced. report := url.Values{} for _, k := range ticketAxes { if v := got.Get(k); v != "" { report.Set(k, v) } } want := tc.spell if want == "" { want = tc.in } if back := ticketQueryString(got.Get("state"), report, got.Get("title")); back != want { t.Errorf("round trip of %q rendered %q, want %q", tc.in, back, want) } } } // Paging is offset-based in the UI, so the one thing that must never // happen is a reader stranded past the end of a listing they just // narrowed. Every axis resets to the first page; only PageURL pages. func TestOnlyPagingSetsAnOffset(t *testing.T) { nav := ticketNav{Repo: "alpha", View: "table", Order: "status", Offset: 100, Report: url.Values{"subsystem": {"web"}}} for _, tc := range []struct{ what, got string }{ {"state", nav.URL("state", "closed")}, {"view", nav.URL("view", "")}, {"order", nav.URL("order", "title")}, {"clear", nav.Clear()}, } { if strings.Contains(tc.got, "offset=") { t.Errorf("changing %s kept the offset: %s", tc.what, tc.got) } } if got := nav.PageURL(150); !strings.Contains(got, "offset=150") { t.Errorf("PageURL did not set the offset: %s", got) } if got := nav.PageURL(0); strings.Contains(got, "offset=") { t.Errorf("the first page should carry no offset: %s", got) } if got := nav.PageURL(-20); strings.Contains(got, "offset=") { t.Errorf("a negative offset must clamp to the first page: %s", got) } } // The pager tells a reader where they are; it should not appear when // that is the only thing it could say. func TestPagerAppearsOnlyWhenThereIsSomewhereToGo(t *testing.T) { nav := ticketNav{Repo: "alpha"} if p := pagerFor(nav, 12, 12); p != nil { t.Errorf("a single full page drew a pager: %+v", p) } if p := pagerFor(nav, 0, 0); p != nil { t.Error("an empty listing drew a pager") } p := pagerFor(nav, 50, 71) if p == nil || p.From != 1 || p.To != 50 || p.Total != 71 { t.Fatalf("first page = %+v, want 1-50 of 71", p) } if p.Prev != "" || p.Next == "" { t.Errorf("first page should lead forward only: %+v", p) } last := pagerFor(ticketNav{Repo: "alpha", Offset: 50}, 21, 71) if last == nil || last.From != 51 || last.To != 71 { t.Fatalf("last page = %+v, want 51-71", last) } if last.Next != "" || last.Prev == "" { t.Errorf("last page should lead back only: %+v", last) } } // The dashboard styles its own lists. It once borrowed ul.tickets, so // when the tracker became a bordered card the dashboard grew three // boxes around unrelated content — a shared class is a shared fate. func dashboardSource() string { var b strings.Builder if err := dashboardPage.ExecuteTemplate(&b, "content", pageData{Dash: &dashData{ Repos: []struct { Name string `json:"name"` Description string `json:"description"` LastActivity string `json:"lastActivity"` }{{Name: "r"}}, Feed: []dashEvent{{Kind: "ci", Repo: "r", Comment: "subject\n\nbody"}}, }}); err != nil { panic(err) } return b.String() } func TestDashboardDoesNotBorrowTheTicketList(t *testing.T) { if strings.Contains(dashboardSource(), `class="tickets"`) { t.Error("the dashboard is borrowing the ticket page's list class again") } if !strings.Contains(dashboardSource(), `class="dash-list"`) { t.Error("the dashboard lost its own list class") } } // A feed row is one line. Rendering a whole commit message there turns // the column into a wall of bold text. func TestDashboardFeedShowsOnlyTheSubject(t *testing.T) { got := firstLine("wiki: contents and a real index\n\nLong pages were unreadable: fossil's Cookbook runs 26,000 pixels.") if got != "wiki: contents and a real index" { t.Fatalf("feed title = %q, want just the subject", got) } } // /tickets is the tracker page pointed at every repository the reader // can reach. The two things that make it that page rather than a second // tracker page: a row links into its OWN repository, and every control // keeps addressing /tickets instead of falling back to one repo's URL. func TestCrossRepoTicketListing(t *testing.T) { mk := func(t *testing.T, sub, title string) *index.Index { ix, err := index.Open(filepath.Join(t.TempDir(), "index.db")) if err != nil { t.Fatal(err) } t.Cleanup(func() { ix.Close() }) if err := ix.FoldNow([]index.Parsed{{Hash: title[:4], Art: &artifact.Artifact{ Type: artifact.Ticket, TicketID: strings.Repeat(title[:1], 40), Date: "2026-08-19T09:00:00", User: "dan", Fields: []artifact.Field{{Name: "status", Value: "Open"}, {Name: "title", Value: title}, {Name: "subsystem", Value: sub}}}}}); err != nil { t.Fatal(err) } return ix } r := ops.NewRegistry() if err := api.RegisterAll(r, api.Deps{Version: "v", Repos: api.NewRepos(map[string]*api.Repo{ "alpha": {Index: mk(t, "web", "alpha bug")}, "beta": {Index: mk(t, "api", "beta bug")}, "gamma": {Index: mk(t, "web", "gamma bug")}, }), Accounts: stubAccts{grants: map[string]map[string]string{"dan": {"alpha": "write", "beta": "read"}}}, }); err != nil { t.Fatal(err) } srv := httptest.NewServer(HandlerWith(r, nil, nil, []Option{WithSessions(fakeSessions{user: "dan"})})) t.Cleanup(srv.Close) get := func(path string) string { req, _ := http.NewRequest("GET", srv.URL+path, nil) req.AddCookie(&http.Cookie{Name: sessionCookie, Value: "good"}) resp, err := srv.Client().Do(req) if err != nil { t.Fatal(err) } defer resp.Body.Close() if resp.StatusCode != 200 { t.Fatalf("GET %s: %d", path, resp.StatusCode) } b, _ := io.ReadAll(resp.Body) return string(b) } html := get("/tickets") // Each row reaches its own tracker. for _, want := range []string{`href="/r/alpha/ticket/`, `href="/r/beta/ticket/`} { if !strings.Contains(html, want) { t.Errorf("no row links into %s:\n%s", want, html) } } if strings.Contains(html, `/r/gamma/ticket/`) { t.Error("a tracker the reader was not granted appeared in the listing") } // Repository is a browse axis here, and the rail's links stay on // this page rather than diving into one repository's tracker. if !strings.Contains(html, `href="/tickets?repo=alpha"`) { t.Errorf("no repository facet:\n%s", html) } if strings.Contains(html, `/r//ticket`) { t.Errorf("a control fell back to a repository URL with no repository:\n%s", html) } // Filing needs one tracker to file into; the board needs one // vocabulary. Neither exists here, so neither control renders. if strings.Contains(html, `/ticket/new`) || strings.Contains(html, `view=board`) { t.Errorf("a control that cannot work here was rendered:\n%s", html) } // The facet narrows without losing the page. one := get("/tickets?repo=alpha") if strings.Contains(one, `/r/beta/ticket/`) { t.Error("the repository facet did not narrow the listing") } // No identity, no listing: this page is only ever somebody's. req, _ := http.NewRequest("GET", srv.URL+"/tickets", nil) srv.Client().CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse } resp, err := srv.Client().Do(req) if err != nil { t.Fatal(err) } defer resp.Body.Close() if resp.StatusCode != http.StatusSeeOther { t.Fatalf("anonymous /tickets: %d, want a redirect to sign in", resp.StatusCode) } }