Artifact 16f9ebae442596f69cd032d9f92dddebd41c747983eeb340c8b43bbabed09fc8:
- File
internal/web/web_test.go
— part of check-in
[9b9618a12f]
at
2026-08-23 02:04:15
on branch assoc-notes
— wiki: show associated notes on the artifact they describe
Fossil's wikitheory.wiki: pages named branch/NAME or checkin/HASH are associated with that branch or check-in, and fossil shows them in an About section on those screens. Hiding them from the documentation index without this made 49 of fossil's 83 wiki pages reachable only by typing the URL — half the fix, and the worse half.
Notes now appear above their branch's timeline, their check-in, their ticket and their tag. A branch note shows only when the timeline is narrowed to that branch: above every branch it is a caption on the wrong picture.
Long ones start folded. Fossil's own auto-toc note runs three screens and put the timeline entirely below the fold, which is an About that has buried its subject. (user: dmestas, size: 69333) [annotate] [blame] [check-ins using] [more...]
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/oltp" "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/docs", "/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/docs"`, `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/docs", 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()) } // The old address keeps working; a wiki URL is somebody's bookmark. rec = httptest.NewRecorder() h.ServeHTTP(rec, httptest.NewRequest("GET", "/r/test/wiki", nil)) if rec.Code != http.StatusMovedPermanently || rec.Header().Get("Location") != "/r/test/docs" { t.Fatalf("/wiki = %d %q, want a permanent redirect to /docs", rec.Code, rec.Header().Get("Location")) } 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 own sections and the door to // the fleet. Headings are design surface and free to change. for _, want := range []string{`class="dash-side"`, `class="dash-feed"`, `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-side"`) { 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, `class="dash-side"`) { 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"` Open int `json:"open"` }{{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) } // Without a door in the chrome the only way here is a ticket that // already happens to be waiting — which is exactly backwards. if home := get("/"); !strings.Contains(home, `href="/tickets"`) { t.Errorf("nothing links to the cross-repository tracker:\n%s", home) } 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) } } // The landing page leads with what needs the reader, and only when // something does. An empty band is a promise the page cannot keep, so // the section must not render at all rather than sit there saying // nothing is waiting. func TestDashboardBandOnlyWhenSomethingWaits(t *testing.T) { mk := func(t *testing.T, author, answerer string) *index.Index { ix, err := index.Open(filepath.Join(t.TempDir(), "index.db")) if err != nil { t.Fatal(err) } t.Cleanup(func() { ix.Close() }) id := strings.Repeat("ab", 20) batch := []index.Parsed{{Hash: "aa11", Art: &artifact.Artifact{ Type: artifact.Ticket, TicketID: id, Date: "2026-08-19T09:00:00", User: author, Fields: []artifact.Field{{Name: "status", Value: "Open"}, {Name: "title", Value: "a question for you"}}}}} if answerer != "" { batch = append(batch, index.Parsed{Hash: "aa22", Art: &artifact.Artifact{ Type: artifact.Ticket, TicketID: id, Date: "2026-08-19T18:00:00", User: answerer, Fields: []artifact.Field{{Name: "status", Value: "Open"}}}}) } if err := ix.FoldNow(batch); err != nil { t.Fatal(err) } return ix } home := func(t *testing.T, ix *index.Index) string { t.Helper() 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) } srv := httptest.NewServer(HandlerWith(r, nil, nil, []Option{WithSessions(fakeSessions{user: "dan"})})) t.Cleanup(srv.Close) req, _ := http.NewRequest("GET", srv.URL+"/", 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) } waiting := home(t, mk(t, "dan", "eve")) if !strings.Contains(waiting, `class="dash-band"`) { t.Fatalf("a ticket somebody else answered did not reach the band:\n%s", waiting) } if !strings.Contains(waiting, `href="/tickets?awaiting=1"`) { t.Error("the band does not link to the rest of what is waiting") } // Nobody has answered, so nothing is owed. if quiet := home(t, mk(t, "dan", "")); strings.Contains(quiet, `class="dash-band"`) { t.Error("the band rendered with nothing waiting in it") } // Somebody else's ticket is not the reader's to answer. if theirs := home(t, mk(t, "eve", "dan")); strings.Contains(theirs, `class="dash-band"`) { t.Error("a ticket the reader did not open reached their band") } } // The feed's chips are only honest if a chip's number counts exactly the // rows clicking it shows. Every artifact here is one typed event stream, // which is what makes that possible at all. func TestDashboardFeedChipsAgreeWithTheirRows(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("11", 32), Art: &artifact.Artifact{Type: artifact.Manifest, Date: "2026-08-19T09:00:00", User: "dan", Comment: "a check-in"}}, {Hash: strings.Repeat("22", 32), Art: &artifact.Artifact{Type: artifact.Wiki, WikiTitle: "Design", Date: "2026-08-19T10:00:00", User: "dan", Mimetype: "text/x-markdown", Body: []byte("page")}}, }); 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) } 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() b, _ := io.ReadAll(resp.Body) return string(b) } all := get("/") if !strings.Contains(all, `href="/?kind=ci"`) || !strings.Contains(all, `href="/?kind=w"`) { t.Fatalf("the feed offers no kind chips:\n%s", all) } one := get("/?kind=w") if strings.Contains(one, "a check-in") { t.Error("the wiki chip showed a check-in") } if !strings.Contains(one, "Design") { t.Error("the wiki chip dropped the wiki edit it counts") } // The repository filter is a plain GET form, so it works with no // script at all — and it must not be offered a repository the // reader was not granted. // The feed still names the repository each event came from, so this // looks for the repository LIST's own row, not any mention. if none := get("/?find=zzz"); strings.Contains(none, `class="dash-title" href="/r/alpha"`) { t.Error("the find box did not narrow the repository list") } } // A wiki index has two jobs beyond listing: point a reader at the front // door, and show where the wiki is coming apart. Controls that cannot // change what you see do not render, which is why a four-page wiki gets // no filter box and no structure panel. func TestWikiIndexShowsStructureOnlyWhenItMeansSomething(t *testing.T) { page := func(t *testing.T, pages map[string]string) string { t.Helper() 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 i := 0 for title, body := range pages { i++ batch = append(batch, index.Parsed{Hash: fmt.Sprintf("w%03d", i), Art: &artifact.Artifact{Type: artifact.Wiki, WikiTitle: title, Date: fmt.Sprintf("2026-08-%02dT09:00:00", i), User: "dan", Mimetype: "text/x-markdown", Body: []byte(body)}}) } if err := ix.FoldNow(batch); 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}})}); err != nil { t.Fatal(err) } srv := httptest.NewServer(HandlerWith(r, nil, nil, nil)) t.Cleanup(srv.Close) resp, err := srv.Client().Get(srv.URL + "/r/alpha/wiki") if err != nil { t.Fatal(err) } defer resp.Body.Close() b, _ := io.ReadAll(resp.Body) return string(b) } small := page(t, map[string]string{"Home": "start", "Guide": "[[Home]]"}) if strings.Contains(small, `class="w-struct"`) { t.Error("a two-page wiki was asked which of its pages are unreachable") } if strings.Contains(small, `class="w-controls"`) { t.Error("a two-page wiki rendered a filter box that cannot narrow anything") } big := page(t, map[string]string{ "Home": "See [[Guide]] and [[Design]].", "Guide": "Back to [[Home]]. Also [[Roadmap]].", "Design": "[[Home]]", "Notes": "nothing points at this page", "Scratch": "nor this one", "Extra": "[[Design]]", }) if !strings.Contains(big, `class="w-struct"`) { t.Fatalf("no structure panel on a wiki with holes in it:\n%s", big) } // A link nobody wrote a page for is the wiki's own to-do list, and // it links to creating that page rather than to a dead end. if !strings.Contains(big, `/r/alpha/wiki-new?name=Roadmap`) { t.Error("a wanted page does not offer to be written") } // Unreachable pages are named; the home page never counts as one. for _, want := range []string{`/r/alpha/wiki/Notes`, `/r/alpha/wiki/Scratch`} { if !strings.Contains(big, want) { t.Errorf("orphan %s not listed", want) } } } // Fossil's own wiki has over two hundred pages nothing links to. A // panel that long is the page, so both lists are bounded — and a bound // that does not say what it dropped reads as "this is everything". func TestWikiStructurePanelSaysWhatItLeftOut(t *testing.T) { 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 // One page linking many names that do not exist, and many pages // nothing links to: both lists overflow. var links strings.Builder for i := 0; i < wikiStructLimit+5; i++ { fmt.Fprintf(&links, "[[Wanted%02d]] ", i) batch = append(batch, index.Parsed{Hash: fmt.Sprintf("o%03d", i), Art: &artifact.Artifact{Type: artifact.Wiki, WikiTitle: fmt.Sprintf("Lonely%02d", i), Date: "2026-08-01T09:00:00", User: "dan", Mimetype: "text/x-markdown", Body: []byte("alone")}}) } batch = append(batch, index.Parsed{Hash: "home", Art: &artifact.Artifact{ Type: artifact.Wiki, WikiTitle: "Home", Date: "2026-08-02T09:00:00", User: "dan", Mimetype: "text/x-markdown", Body: []byte(links.String())}}) if err := ix.FoldNow(batch); 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}})}); err != nil { t.Fatal(err) } srv := httptest.NewServer(HandlerWith(r, nil, nil, nil)) t.Cleanup(srv.Close) resp, err := srv.Client().Get(srv.URL + "/r/alpha/wiki") if err != nil { t.Fatal(err) } defer resp.Body.Close() b, _ := io.ReadAll(resp.Body) html := string(b) // The page list above names every page too, so count inside the // panel alone. _, panel, ok := strings.Cut(html, `class="w-struct"`) if !ok { t.Fatalf("no structure panel:\n%s", html) } if n := strings.Count(panel, `/r/alpha/wiki-new?name=Wanted`); n != wikiStructLimit { t.Errorf("wanted list shows %d rows, want it bounded at %d", n, wikiStructLimit) } if n := strings.Count(panel, `/r/alpha/wiki/Lonely`); n != wikiStructLimit { t.Errorf("orphan list shows %d rows, want it bounded at %d", n, wikiStructLimit) } // The bound must be visible: a silent truncation claims completeness. if !strings.Contains(panel, "more") { t.Errorf("both lists were truncated without saying so:\n%s", html) } } // A saved view is a NAME for a URL the tracker could already produce. // It has to survive the round trip exactly — the rail entry must return // the same listing that was saved — and it must not become a way out of // the forge: the redirect after saving is built from the repository // name, never taken from the form. func TestSavedViewsRoundTripThroughTheRail(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: "tk01", Art: &artifact.Artifact{ Type: artifact.Ticket, TicketID: strings.Repeat("ab", 20), Date: "2026-08-19T09:00:00", User: "dan", Fields: []artifact.Field{{Name: "status", Value: "Open"}, {Name: "title", Value: "a crash"}, {Name: "subsystem", Value: "web"}}}}}); err != nil { t.Fatal(err) } odb, err := oltp.Open(filepath.Join(t.TempDir(), "oltp.db")) if err != nil { t.Fatal(err) } t.Cleanup(func() { odb.Close() }) r := ops.NewRegistry() if err := api.RegisterAll(r, api.Deps{Version: "v", Repos: api.NewRepos(map[string]*api.Repo{"alpha": {Index: ix}}), Views: odb, Accounts: stubAccts{grants: map[string]map[string]string{"dan": {"alpha": "write"}}}, }); err != nil { t.Fatal(err) } srv := httptest.NewServer(HandlerWith(r, nil, nil, []Option{WithSessions(fakeSessions{user: "dan"})})) t.Cleanup(srv.Close) client := srv.Client() client.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse } do := func(method, path string, form url.Values) *http.Response { t.Helper() var req *http.Request if form == nil { req, _ = http.NewRequest(method, srv.URL+path, nil) } else { req, _ = http.NewRequest(method, srv.URL+path, strings.NewReader(form.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") } req.AddCookie(&http.Cookie{Name: sessionCookie, Value: "good"}) resp, err := client.Do(req) if err != nil { t.Fatal(err) } return resp } body := func(path string) string { resp := do("GET", path, nil) defer resp.Body.Close() b, _ := io.ReadAll(resp.Body) return string(b) } // Nothing to name yet: an unfiltered listing needs no shortcut to // itself, so the control does not render. if strings.Contains(body("/r/alpha/ticket"), `class="t-rail-save"`) { t.Error("the unfiltered listing offered to save itself") } filtered := body("/r/alpha/ticket?subsystem=web") if !strings.Contains(filtered, `class="t-rail-save"`) { t.Fatalf("no way to name a report that exists:\n%s", filtered) } resp := do("POST", "/views", url.Values{ "repo": {"alpha"}, "name": {"web crashes"}, "query": {"subsystem=web"}}) resp.Body.Close() if resp.StatusCode != http.StatusSeeOther { t.Fatalf("saving a view: %d", resp.StatusCode) } // Back to the listing that was named, not to some other page. if loc := resp.Header.Get("Location"); loc != "/r/alpha/ticket?subsystem=web" { t.Fatalf("after saving, landed on %q", loc) } // The rail now carries it, and it points at the same listing. rail := body("/r/alpha/ticket") if !strings.Contains(rail, `href="/r/alpha/ticket?subsystem=web"`) || !strings.Contains(rail, "web crashes") { t.Fatalf("the saved view is not in the rail:\n%s", rail) } // It belongs to that tracker alone. if strings.Contains(body("/tickets"), "web crashes") { t.Error("a view saved against one tracker appeared on the cross-repository listing") } // A hand-made post cannot turn the redirect into a door out of the // forge: the path is rebuilt from the repository name. resp = do("POST", "/views", url.Values{ "repo": {"https://evil.example.com"}, "name": {"x"}, "query": {""}}) resp.Body.Close() if loc := resp.Header.Get("Location"); strings.Contains(loc, "evil.example.com") { t.Fatalf("open redirect through the repo field: %q", loc) } // Forgetting it loses the shortcut and nothing else. resp = do("POST", "/views/delete", url.Values{"repo": {"alpha"}, "name": {"web crashes"}}) resp.Body.Close() if gone := body("/r/alpha/ticket"); strings.Contains(gone, "web crashes") { t.Error("the view survived being forgotten") } if still := body("/r/alpha/ticket?subsystem=web"); !strings.Contains(still, "a crash") { t.Error("forgetting a view took the listing with it") } } // Fossil's wikitheory.wiki: "Stand-alone wiki pages with special names // branch/BRANCHNAME or checkin/HASH are associated with the // corresponding branch or check-in." They are kept out of the // documentation index BECAUSE they belong on those screens — hiding // them without showing them here would make them unreachable. func TestAssociatedNotesAppearOnTheirArtifact(t *testing.T) { ix, err := index.Open(filepath.Join(t.TempDir(), "index.db")) if err != nil { t.Fatal(err) } t.Cleanup(func() { ix.Close() }) const hash = "aa11bb22cc33dd44ee55ff66aa77bb88cc99dd00aa11bb22cc33dd44ee55ff66" if err := ix.FoldNow([]index.Parsed{ {Hash: strings.Repeat("d1", 20), Art: &artifact.Artifact{Type: artifact.Wiki, WikiTitle: "checkin/" + hash, Date: "2026-08-15T09:00:00", User: "dan", Mimetype: "text/x-markdown", Body: []byte("why this landed\n")}}, {Hash: strings.Repeat("d2", 20), Art: &artifact.Artifact{Type: artifact.Wiki, WikiTitle: "branch/spike", Date: "2026-08-15T09:00:00", User: "dan", Mimetype: "text/x-markdown", Body: []byte("what this branch is for\n")}}, {Hash: strings.Repeat("d3", 20), Art: &artifact.Artifact{Type: artifact.Wiki, WikiTitle: "Guide", Date: "2026-08-15T09:00:00", User: "dan", Mimetype: "text/x-markdown", Body: []byte("ordinary page\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)) return rec.Body.String() } // The branch note shows only when the timeline is narrowed to it: // a note about one branch above every branch is the wrong caption. if body := get("/r/test/timeline?branch=spike"); !strings.Contains(body, "what this branch is for") { t.Error("branch note missing from its own branch's timeline") } if body := get("/r/test/timeline"); strings.Contains(body, "what this branch is for") { t.Error("branch note appeared above a timeline of every branch") } // Ordinary pages are NOT notes and stay in the index. if body := get("/r/test/docs"); !strings.Contains(body, "Guide") { t.Error("an ordinary wiki page went missing from the index") } if body := get("/r/test/docs"); strings.Contains(body, "branch/spike") { t.Error("an associated note is still listed in the documentation index") } }