package web
import (
"bufio"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"net/url"
"path/filepath"
"regexp"
"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 filters over one typed event stream, which is
// what makes filtering it honest at all. Their numbers count the WHOLE
// feed rather than the page on screen: the feed pages, so a number
// taken from one window would promise less than clicking it delivers.
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")
}
}
// The feed pages. For anyone whose repositories are busy the old fixed
// window was one long column and then a dead end, so the page carries a
// cursor. The two things that must hold: the no-script path is the same
// page by URL (script only decides WHEN the request happens), and the
// cursor is exact across repositories — hashes are unique corpus-wide,
// so events sharing a timestamp must neither repeat nor be stepped over.
func TestDashboardFeedPagesWithoutRepeatingOrSkipping(t *testing.T) {
mk := func(t *testing.T, prefix string, n int) *index.Index {
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 < n; i++ {
// Every event in both repositories shares one timestamp, so
// only the hash tiebreak can order them. A cursor on date
// alone would lose or repeat rows here.
batch = append(batch, index.Parsed{
Hash: fmt.Sprintf("%s%060d", prefix, i),
Art: &artifact.Artifact{Type: artifact.Manifest,
Date: "2026-08-19T09:00:00", User: "dan",
Comment: fmt.Sprintf("%s-%02d", prefix, i)}})
}
if err := ix.FoldNow(batch); 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, "a", 20)},
"beta": {Index: mk(t, "b", 20)},
}),
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)
}
// The comments are unique, so counting them across pages is how we
// see a repeat or a gap.
seen := map[string]int{}
count := func(html string) {
for _, m := range regexp.MustCompile(`[ab]-\d\d`).FindAllString(html, -1) {
seen[m]++
}
}
first := get("/")
count(first)
if !strings.Contains(first, `id="feed-more"`) {
t.Fatalf("40 events and no way past the first page:\n%s", first)
}
// Script decides when, not what: the sentinel's fragment and the
// plain link must ask for the same page.
cursor := regexp.MustCompile(`before=([^"'&]+)`).FindStringSubmatch(first)
if cursor == nil {
t.Fatalf("the sentinel carries no cursor:\n%s", first)
}
if !strings.Contains(first, "<noscript>") {
t.Error("no way forward without script")
}
// Walk the whole feed by the cursor, the way a reader without
// script would.
next, hops := "before="+cursor[1], 0
for next != "" && hops < 10 {
hops++
page := get("/?" + next)
count(page)
if m := regexp.MustCompile(`before=([^"'&]+)`).FindStringSubmatch(page); m != nil {
next = "before=" + m[1]
} else {
next = ""
}
}
if len(seen) != 40 {
t.Fatalf("walked the feed and saw %d of 40 events: the cursor skips", len(seen))
}
for name, n := range seen {
if n != 1 {
t.Fatalf("event %s appeared %d times: the cursor repeats", name, n)
}
}
// The fragment is the same rows, addressed the same way.
req, _ := http.NewRequest("GET", srv.URL+"/feed.frag?"+"before="+cursor[1], 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 ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "text/event-stream") {
t.Fatalf("fragment content type %q", ct)
}
b, _ := io.ReadAll(resp.Body)
frag := string(b)
if !strings.Contains(frag, "datastar-patch-elements") || !strings.Contains(frag, "selector #feed-more") {
t.Fatalf("the fragment does not replace the sentinel:\n%s", frag)
}
if !strings.Contains(frag, "<li>") {
t.Fatalf("the fragment carried no rows:\n%s", frag)
}
}
// 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")
}
}