package web
import (
"bufio"
"context"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"net/url"
"path/filepath"
"regexp"
"strings"
"sync"
"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: TWO scripts, both first-party, both content-hashed,
// neither inline. Datastar is the one allowed framework (pure
// enhancement). The error reporter is the deliberate second: it is
// not a framework and adds no behaviour to the page — it only
// listens, so that a failure on a reader's phone reaches the
// Exceptions panel instead of dying in a console nobody opens.
// Without script both are simply absent and every page still works.
if n := strings.Count(body, "<script"); n != 2 {
t.Fatalf("script policy violated (%d script tags):\n%s", n, body)
}
for _, want := range []string{datastarURL, onerrorURL} {
if !strings.Contains(body, `src="`+want+`"`) {
t.Fatalf("page does not serve %s:\n%s", want, body)
}
}
if strings.Contains(body, "<script>") {
t.Fatal("inline script on the base page")
}
// The themes live in the served stylesheet now, so the page must
// LINK it and the sheet must carry them. Asserting only on the page
// would pass a build that shipped no dark theme at all.
if !strings.Contains(body, `rel="stylesheet"`) || !strings.Contains(body, sheetURL) {
t.Fatalf("the page does not link the stylesheet:\n%s", body)
}
if !strings.Contains(sheet, "prefers-color-scheme") {
t.Fatal("dark theme missing from the stylesheet")
}
}
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 — on the CONTROLS, so the
// refresh serializes them.
page, _ := srv.Client().Get(srv.URL + "/r/test/timeline")
body, _ := io.ReadAll(page.Body)
page.Body.Close()
for _, want := range []string{
`data-init="@get('/r/test/timeline.rev')"`,
`timeline.frag', {contentType: 'form'}`,
`data-signals="{tlRev: 0}"`,
} {
if !strings.Contains(string(body), want) {
t.Fatalf("page missing %s:\n%s", want, body)
}
}
// The filter must NOT be baked into the stream URL. That is the
// whole defect: a stream carries the filter it was opened with, and
// every later tick repaints the reader's page with a view they left.
if strings.Contains(string(body), "timeline.rev?") {
t.Error("the live stream URL carries query parameters; it can go stale")
}
resp, err := srv.Client().Get(srv.URL + "/r/test/timeline.rev")
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-signals") || !strings.Contains(got, "tlRev") {
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, "tlRev") {
t.Fatalf("the signal does not carry a revision:\n%s", got)
}
// It says THAT the repository changed, never what it looks like.
if strings.Contains(got, "datastar-patch-elements") || strings.Contains(got, "<div id=\"tl\"") {
t.Fatalf("the revision stream is still shipping rendered rows:\n%s", got)
}
}
// The fragment is a ONE-SHOT that renders whatever filter the request
// carries. Nothing holds a connection, so nothing can capture a filter
// and outlive it.
func TestTimelineFragmentAnswersTheRequestsOwnFilter(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{"test": {Index: ix}})}); err != nil {
t.Fatal(err)
}
srv := httptest.NewServer(Handler(r, &fakeNotifier{ch: make(chan struct{}, 1)}, nil))
t.Cleanup(srv.Close)
resp, err := srv.Client().Get(srv.URL + "/r/test/timeline.frag?y=w")
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if ct := resp.Header.Get("Content-Type"); ct != "text/event-stream" {
t.Fatalf("content-type %q", ct)
}
if !strings.Contains(string(body), "datastar-patch-elements") {
t.Fatalf("fragment is not an element patch:\n%s", body)
}
if !strings.Contains(string(body), `data: elements <div id="tl">`) {
t.Fatalf("fragment is not a #tl morph:\n%s", body)
}
}
// 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 — forum is a tab, notes are
// deliberately NOT. fossil's own default menu carries no technote
// entry and libfossil, which uses them as a milestone feed and
// overrode the menu to add TODOs and FIXMEs, did not add one
// either. The bar already overflows on a phone; notes lose that
// competition on every real repository.
tl := get("/r/test/timeline")
if !strings.Contains(tl, `/r/test/forum`) {
t.Fatal("nav missing forum link")
}
if strings.Contains(tl, `href="/r/test/technote"`) {
t.Fatal("notes are back in the nav; they are reached from the About panel, " +
"the docs page and the timeline filter instead")
}
// ...but they must stay REACHABLE, or dropping the tab loses them.
if !strings.Contains(tl, `<option value="e"`) {
t.Fatal("timeline lost its notes filter")
}
// ...and the filtered view is not a dead end. Without a tab, this is
// where a reader browsing notes is standing.
if b := get("/r/test/timeline?y=e"); !strings.Contains(b, `href="/r/test/technote"`) {
t.Fatalf("notes-filtered timeline has no way to the notes page:\n%s", b)
}
if b := get("/r/test/docs"); !strings.Contains(b, `href="/r/test/technote"`) {
t.Fatalf("docs page has no door to notes:\n%s", b)
}
}
// fakeChatLive is ChatLive over a fixed backlog plus a bridge for
// messages pushed after a Tail call is already open — enough to drive
// liveChat's resume-from-Last-Event-ID and append/replace behaviour
// without a real NATS.
type fakeChatLive struct {
mu sync.Mutex
sent []ChatMessage
push chan ChatMessage
joined []string
left []string
here []string
}
func newFakeChatLive() *fakeChatLive { return &fakeChatLive{push: make(chan ChatMessage, 16)} }
func (f *fakeChatLive) postLive(m ChatMessage) {
f.mu.Lock()
f.sent = append(f.sent, m)
f.mu.Unlock()
f.push <- m
}
// The presence half of ChatLive: joins are recorded so a test can see
// a stream arrive and leave; Present answers whatever the test set.
func (f *fakeChatLive) Join(repo, user string) func() {
f.mu.Lock()
f.joined = append(f.joined, user)
f.mu.Unlock()
return func() {
f.mu.Lock()
f.left = append(f.left, user)
f.mu.Unlock()
}
}
func (f *fakeChatLive) Present(repo string) []string { return f.here }
func (f *fakeChatLive) PresenceChanged(repo string) (<-chan struct{}, func()) {
return make(chan struct{}), func() {}
}
func (f *fakeChatLive) Tail(repo string, from uint64) (<-chan ChatMessage, func()) {
f.mu.Lock()
backlog := append([]ChatMessage(nil), f.sent...)
f.mu.Unlock()
out := make(chan ChatMessage, len(backlog)+16)
for _, m := range backlog {
if m.Seq > from {
out <- m
}
}
done := make(chan struct{})
go func() {
for {
select {
case m := <-f.push:
select {
case out <- m:
case <-done:
return
}
case <-done:
return
}
}
}()
return out, func() { close(done) }
}
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{{Seq: 1, Kind: chat.KindMsg, 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 := newFakeChatLive()
h := HandlerWith(r, nil, live, []Option{WithSessions(fakeSessions{user: "dan"})})
// The page shows history + the post form + the live hookup + nav link.
rec := httptest.NewRecorder()
h.ServeHTTP(rec, withCookie("/r/test/chat"))
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, `data-seq="1"`) || !strings.Contains(rec.Body.String(), `id="chatlog"`) {
t.Fatal("chat log container missing its data-seq hook")
}
// The SSE stream appends a new message as a datastar patch, carrying
// an id: line — the resume cursor a reconnecting client resends.
srv := httptest.NewServer(h)
resp, err := srv.Client().Get(srv.URL + "/r/test/chat.sse")
if err != nil {
srv.Close()
t.Fatal(err)
}
live.postLive(ChatMessage{Seq: 2, Kind: chat.KindMsg, User: "eve", Text: "live line", HTML: "live line", At: "now"})
rd := bufio.NewReader(resp.Body)
deadline := time.After(5 * time.Second)
got := ""
// "before", not "append": a new row is inserted BEFORE the end
// sentinel so #chat-latest stays the last child of the log and the
// jump-to-latest anchor keeps naming the latest message.
for !strings.Contains(got, "live line") || !strings.Contains(got, "mode before") ||
!strings.Contains(got, "chatTick") {
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 #chat-latest") {
t.Fatalf("patch not targeting the end sentinel:\n%s", got)
}
// And it says a message arrived, so the page can follow the log when
// the reader is at the end and offer the jump when they are not.
if !strings.Contains(got, "datastar-patch-signals") || !strings.Contains(got, "chatTick") {
t.Fatalf("live patch carries no arrival signal:\n%s", got)
}
if !strings.Contains(got, "id: 2") {
t.Fatalf("live patch missing its id: line (the resume cursor):\n%s", got)
}
}
// Reconnecting with Last-Event-ID replays everything after that seq —
// never the messages already delivered, never a gap.
func TestChatStreamResumesFromLastEventID(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: "t",
Repos: api.NewRepos(map[string]*api.Repo{"test": {Index: ix, Code: "c1"}}), Chat: &webChatStub{}}); err != nil {
t.Fatal(err)
}
live := newFakeChatLive()
live.postLive(ChatMessage{Seq: 1, Kind: chat.KindMsg, User: "dan", Text: "one"})
live.postLive(ChatMessage{Seq: 2, Kind: chat.KindMsg, User: "dan", Text: "two"})
live.postLive(ChatMessage{Seq: 3, Kind: chat.KindMsg, User: "dan", Text: "three"})
srv := httptest.NewServer(Handler(r, nil, live))
defer srv.Close()
req, _ := http.NewRequest("GET", srv.URL+"/r/test/chat.sse", nil)
req.Header.Set("Last-Event-ID", "1")
resp, err := srv.Client().Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
rd := bufio.NewReader(resp.Body)
got := ""
deadline := time.After(5 * time.Second)
for !strings.Contains(got, "id: 3") {
lineCh := make(chan string, 1)
go func() { l, _ := rd.ReadString('\n'); lineCh <- l }()
select {
case l := <-lineCh:
got += l
case <-deadline:
t.Fatalf("resume from Last-Event-ID never reached seq 3:\n%s", got)
}
}
if strings.Contains(got, "id: 1") {
t.Fatalf("Last-Event-ID: 1 must not replay seq 1 itself:\n%s", got)
}
if !strings.Contains(got, "id: 2") {
t.Fatalf("Last-Event-ID: 1 must replay seq 2:\n%s", got)
}
}
// The unread divider sits immediately before the first message the
// viewer has not seen — never before an already-read message, never
// missing when everything is unread.
func TestChatPageDividerAtReadSeq(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{
{Seq: 1, Kind: chat.KindMsg, User: "dan", Text: "seen", At: "2026-08-16T12:00:00Z"},
{Seq: 2, Kind: chat.KindMsg, User: "dan", Text: "unseen", At: "2026-08-16T12:00:01Z"},
}}
forum := &webForumReadStub{}
if err := forum.MarkChatRead("test", "dan", 1); err != nil {
t.Fatal(err)
}
if err := api.RegisterAll(r, api.Deps{Version: "t",
Repos: api.NewRepos(map[string]*api.Repo{"test": {Index: ix, Code: "c1"}}), Chat: fc, Forum: forum,
CanRead: func(context.Context, string) bool { return true }}); err != nil {
t.Fatal(err)
}
h := HandlerWith(r, nil, newFakeChatLive(), []Option{WithSessions(fakeSessions{user: "dan"})})
rec := httptest.NewRecorder()
h.ServeHTTP(rec, withCookie("/r/test/chat"))
body := rec.Body.String()
if !strings.Contains(body, `data-seq="1"`) {
t.Fatalf("chat page:\n%s", body)
}
i1 := strings.Index(body, `data-seq="1"`)
idiv := strings.Index(body, `class="chat-divider"`)
i2 := strings.Index(body, `data-seq="2"`)
if idiv < 0 || idiv < i1 || idiv > i2 {
t.Fatalf("divider not between seq 1 (read) and seq 2 (unread): divider=%d seq1=%d seq2=%d\n%s", idiv, i1, i2, body)
}
}
// The composer form is a plain method=post form with the Datastar
// attribute layered on additively (Principle 3): both must be present
// so the no-JS path keeps working exactly as written.
func TestChatComposerCarriesBothPaths(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: "t",
Repos: api.NewRepos(map[string]*api.Repo{"test": {Index: ix, Code: "c1"}}), Chat: &webChatStub{},
Forum: &webForumReadStub{}, CanRead: func(context.Context, string) bool { return true }}); err != nil {
t.Fatal(err)
}
h := HandlerWith(r, nil, newFakeChatLive(), []Option{WithSessions(fakeSessions{user: "dan"})})
rec := httptest.NewRecorder()
h.ServeHTTP(rec, withCookie("/r/test/chat"))
body := rec.Body.String()
if !strings.Contains(body, `method="post" action="/r/test/chat"`) {
t.Fatalf("composer missing its plain-form fallback:\n%s", body)
}
if !strings.Contains(body, `data-on:submit__prevent="`) ||
!strings.Contains(body, `@post(evt.target.action`) {
t.Fatalf("composer missing its Datastar enhancement:\n%s", body)
}
}
// The permalink page's aged-out state carries a data hook a test (or a
// popover implementation) can assert on structurally.
func TestChatPermalinkExpiredHook(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{{Seq: 50, Kind: chat.KindMsg, User: "dan", Text: "recent", 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)
}
h := Handler(r, nil, newFakeChatLive())
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest("GET", "/r/test/chat/5", nil))
body := rec.Body.String()
if !strings.Contains(body, `data-hook="chat-expired"`) {
t.Fatalf("permalink for an aged-out seq missing its expired hook:\n%s", body)
}
}
type webChatStub struct {
mu sync.Mutex
msgs []chat.Message
}
func (s *webChatStub) Post(code string, m chat.Message) (uint64, error) {
s.mu.Lock()
defer s.mu.Unlock()
if m.User == "" {
m.User = "anonymous"
}
if m.Kind == "" {
m.Kind = chat.KindMsg
}
m.Seq = uint64(len(s.msgs) + 1)
s.msgs = append(s.msgs, m)
return m.Seq, nil
}
func (s *webChatStub) History(code string, before uint64, limit int) ([]chat.Message, uint64, error) {
s.mu.Lock()
defer s.mu.Unlock()
var out []chat.Message
for _, m := range s.msgs {
if before == 0 || m.Seq < before {
out = append(out, m)
}
}
return out, 0, nil
}
func (s *webChatStub) Purge(string, ...uint64) error { return nil }
func (s *webChatStub) Forward(code string, after uint64, limit int) ([]chat.Message, uint64, error) {
s.mu.Lock()
defer s.mu.Unlock()
var out []chat.Message
for _, m := range s.msgs {
if m.Seq > after {
out = append(out, m)
}
}
var next uint64
if limit > 0 && len(out) > limit {
out = out[:limit]
next = out[len(out)-1].Seq
}
return out, next, nil
}
func (s *webChatStub) Range(code string, from, to uint64) ([]chat.Message, error) {
s.mu.Lock()
defer s.mu.Unlock()
var out []chat.Message
for _, m := range s.msgs {
if m.Seq >= from && (to == 0 || m.Seq <= to) {
out = append(out, m)
}
}
return out, nil
}
func (s *webChatStub) Bounds(code string) (uint64, uint64, error) {
s.mu.Lock()
defer s.mu.Unlock()
if len(s.msgs) == 0 {
return 0, 0, nil
}
return s.msgs[0].Seq, s.msgs[len(s.msgs)-1].Seq, nil
}
func (s *webChatStub) Unread(code string, since uint64) (int, error) {
s.mu.Lock()
defer s.mu.Unlock()
n := 0
for _, m := range s.msgs {
if m.Seq > since {
n++
}
}
return n, nil
}
func (s *webChatStub) Tail(code string, from uint64) (<-chan chat.Message, func()) {
ch := make(chan chat.Message)
close(ch)
return ch, func() {}
}
// webForumReadStub is the slice of api.ForumStore this file's chat
// tests need — chat read-state only, everything else a no-op.
type webForumReadStub struct {
mu sync.Mutex
marks map[string]uint64 // "repo\x00account" -> seq
}
func (s *webForumReadStub) key(repo, account string) string { return repo + "\x00" + account }
func (s *webForumReadStub) MarkChatRead(repo, account string, seq uint64) error {
s.mu.Lock()
defer s.mu.Unlock()
if s.marks == nil {
s.marks = map[string]uint64{}
}
if seq > s.marks[s.key(repo, account)] {
s.marks[s.key(repo, account)] = seq
}
return nil
}
func (s *webForumReadStub) ChatReadSeq(repo, account string) uint64 {
s.mu.Lock()
defer s.mu.Unlock()
return s.marks[s.key(repo, account)]
}
func (s *webForumReadStub) ChatReadAll(account string) (map[string]uint64, error) {
s.mu.Lock()
defer s.mu.Unlock()
out := map[string]uint64{}
suffix := "\x00" + account
for k, v := range s.marks {
if strings.HasSuffix(k, suffix) {
out[strings.TrimSuffix(k, suffix)] = v
}
}
return out, nil
}
func (*webForumReadStub) SetThreadCategory(repo, thread, category string) error { return nil }
func (*webForumReadStub) ThreadCategories(repo string) (map[string]string, error) { return nil, nil }
func (*webForumReadStub) SetPollVote(repo, account, post string, choice int) error { return nil }
func (*webForumReadStub) PollCounts(repo, post string) (map[int]int, error) { return nil, nil }
func (*webForumReadStub) PollChoice(repo, account, post string) (int, bool) { return 0, false }
func (*webForumReadStub) SetModeration(repo string, enabled bool) error { return nil }
func (*webForumReadStub) ModerationEnabled(repo string) bool { return false }
func (*webForumReadStub) StagePost(repo, kind, root, title, body, mimetype, author string) (int64, error) {
return 0, nil
}
func (*webForumReadStub) StagedPending(repo string) ([]oltp.Staged, error) { return nil, nil }
func (*webForumReadStub) StagedByID(repo string, id int64) (oltp.Staged, bool, error) {
return oltp.Staged{}, false, nil
}
func (*webForumReadStub) ResolveStaged(repo string, id int64, state string) error { return nil }
func (*webForumReadStub) ReopenStaged(repo string, id int64) error { return nil }
func (*webForumReadStub) SetStagedAI(repo string, id int64, action string, score float64, reason string) error {
return nil
}
func (*webForumReadStub) AppendModLog(repo, actor, action, target, reason string) error { return nil }
func (*webForumReadStub) ModLog(repo string, limit int) ([]oltp.ModEntry, error) { return nil, nil }
func (*webForumReadStub) HidePost(repo, post, reason, actor string) error { return nil }
func (*webForumReadStub) UnhidePost(repo, post string) error { return nil }
func (*webForumReadStub) HiddenPosts(repo string) (map[string]string, error) { return nil, nil }
func (*webForumReadStub) SetForumSub(repo, account, thread, state string) error { return nil }
func (*webForumReadStub) AutoSubscribe(repo, account, thread string) error { return nil }
func (*webForumReadStub) ForumSubscribers(repo, thread string) ([]string, error) { return nil, nil }
func (*webForumReadStub) ForumSubState(repo, account, thread string) string { return "" }
func (*webForumReadStub) MarkForumRead(repo, account, thread, lastReply string) error { return nil }
func (*webForumReadStub) MarkAllForumRead(repo, account string) error { return nil }
func (*webForumReadStub) ForumRead(repo, account string, threads map[string]string) (map[string]bool, error) {
return nil, nil
}
func (*webForumReadStub) RecordMention(repo, ref, who, by string) error { return 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/exceptions", 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)
// Assert the journal ROW, not a heading: the surface badge next to the
// message is what the panel promises. The heading moved into the tab
// nav once /fleet grew three panels, and a test that pinned its words
// would have called that a regression.
if !strings.Contains(string(body), `<span class="tl-badge">panic</span>`) || !strings.Contains(string(body), "kaboom") {
t.Fatalf("the exceptions tab 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/exceptions")
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"`, `>details here</textarea>`,
// Subsystem is a select now, so the prefill arrives SELECTED
// rather than as an input value — and it must be an option at
// all, which is fieldWords carrying a word the vocabulary does
// not list.
`<option value="web" selected>web</option>`,
} {
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) {
// The shared .list-card shape is fine to share — it is the app's
// one list component. Borrowing .tickets, the ticket page's own
// class, is what this guards.
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 a fixed window
// was one long column and then a dead end, so the page carries a
// numbered pager. What must hold: every page is a real URL a reader can
// bookmark or reach with the back button, and paging is exact across
// repositories — the merge is ordered by (date, hash), 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. Ordering on date
// alone would make an offset name different rows on
// different requests.
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", 30)},
"beta": {Index: mk(t, "b", 30)},
}),
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)
}
events := regexp.MustCompile(`[ab]-\d\d`)
first := get("/")
if !strings.Contains(first, `class="t-pager"`) {
t.Fatalf("60 events and no pager:\n%s", first)
}
// Every page of the sixty is an address the pager offers, not just a
// next. Derived from dashFeedSize rather than written out: the page
// size is a product decision that has already changed once, and the
// claim under test is the ARITHMETIC — no gaps, no repeats — which
// holds at any size.
var offsets []string
for off := 0; off < 60; off += dashFeedSize {
offsets = append(offsets, fmt.Sprintf("/?offset=%d", off))
if off > 0 && !strings.Contains(first, fmt.Sprintf(`href="/?offset=%d"`, off)) {
t.Errorf("the pager cannot reach offset %d", off)
}
}
offsets[0] = "/"
// Walk every page and account for all sixty events exactly once.
seen := map[string]int{}
for _, path := range offsets {
page := get(path)
rows := events.FindAllString(page, -1)
if len(rows) == 0 {
t.Fatalf("%s is empty", path)
}
for _, row := range rows {
seen[row]++
}
}
if len(seen) != 60 {
t.Fatalf("walked the feed and saw %d of 60 events: paging skips", len(seen))
}
for name, n := range seen {
if n != 1 {
t.Fatalf("event %s appeared %d times: paging repeats", name, n)
}
}
// A page is a place: asking for it twice gives the same rows, which
// is what makes the back button and a bookmark work. The footer
// prints how long the render took, so only the rows are compared.
rowsOf := func(path string) string {
return strings.Join(events.FindAllString(get(path), -1), ",")
}
if a, b := rowsOf(offsets[1]), rowsOf(offsets[1]); a != b {
t.Errorf("the same page gave different rows:\n%s\n%s", a, b)
}
// The chip and the page are independent axes: narrowing must not
// silently drop the other, in either direction.
chip := get(fmt.Sprintf("/?kind=ci&offset=%d", dashFeedSize))
if !strings.Contains(chip, "kind=ci") || !strings.Contains(chip, "offset=") {
t.Errorf("the pager dropped the chip, or the chip dropped the page:\n%s", chip)
}
}
// A numbered pager has to stay the same shape as a reader moves through
// it, or the control jumps under the pointer. Both ends stay reachable,
// the current page keeps its neighbours, and a gap must hide more than
// one page — an ellipsis standing in for a single page has saved
// nothing and taken away a destination.
func TestPageWindow(t *testing.T) {
show := func(cur, last int) string {
var b strings.Builder
for i, p := range pageWindow(cur, last) {
if i > 0 {
b.WriteString(" ")
}
if p == 0 {
b.WriteString("…")
} else {
if p == cur {
b.WriteString("[")
}
fmt.Fprintf(&b, "%d", p)
if p == cur {
b.WriteString("]")
}
}
}
return b.String()
}
for _, c := range []struct {
cur, last int
want string
}{
{1, 1, "[1]"},
// Short enough to show whole: no gap can pay for itself.
{1, 6, "[1] 2 3 4 5 6"},
{3, 7, "1 2 [3] 4 5 6 7"},
// Long: both ends pinned, neighbours kept, the rest elided.
{1, 12, "[1] 2 3 … 12"},
{6, 12, "1 … 4 5 [6] 7 8 … 12"},
{12, 12, "1 … 10 11 [12]"},
// The page next to an end is not worth a gap: 1 … 3 would be
// an ellipsis hiding page 2 alone.
{4, 12, "1 2 3 [4] 5 6 … 12"},
{9, 12, "1 … 7 8 [9] 10 11 12"},
{0, 0, ""},
} {
if got := show(c.cur, c.last); got != c.want {
t.Errorf("page %d of %d = %q, want %q", c.cur, c.last, got, c.want)
}
}
}
// A chip's count and its pages must describe the SAME events. The feed
// used to fetch a window of the timeline and then drop the kinds it did
// not want, while counting the kind across the whole repository — so a
// tracker with 234 ticket events among thousands of check-ins offered
// ten pages and had rows on two of them. The filter has to be applied
// where the rows come from, not after they arrive.
func TestFeedChipPagesAreAllReal(t *testing.T) {
ix, err := index.Open(filepath.Join(t.TempDir(), "index.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { ix.Close() })
// The shape that broke it: check-ins crowd out the ticket events in
// any recent window, and the ticket events are the OLDEST rows.
var batch []index.Parsed
for i := 0; i < 40; i++ {
batch = append(batch, index.Parsed{Hash: fmt.Sprintf("t%063d", i),
Art: &artifact.Artifact{Type: artifact.Ticket,
TicketID: fmt.Sprintf("%040d", i), Date: "2026-08-01T09:00:00", User: "dan",
Fields: []artifact.Field{{Name: "status", Value: "Open"},
{Name: "title", Value: fmt.Sprintf("ticket-%02d", i)}}}})
}
for i := 0; i < 300; i++ {
batch = append(batch, index.Parsed{Hash: fmt.Sprintf("c%063d", i),
Art: &artifact.Artifact{Type: artifact.Manifest,
Date: "2026-08-20T09:00:00", User: "dan",
Comment: fmt.Sprintf("checkin-%03d", i)}})
}
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}}),
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)
}
rows := regexp.MustCompile(`ticket-\d\d`)
// Every page the chip's pager offers must have rows on it: the
// count promised forty, so walking the pages must find forty.
seen := map[string]bool{}
for off := 0; off < 40; off += dashFeedSize {
page := get(fmt.Sprintf("/?kind=t&offset=%d", off))
found := rows.FindAllString(page, -1)
if len(found) == 0 {
t.Errorf("page at offset %d is empty, but the pager offers it:\n%s",
off, page[max(0, strings.Index(page, "t-pager")-200):])
}
for _, f := range found {
seen[f] = true
}
}
if len(seen) != 40 {
t.Fatalf("the chip counts 40 ticket events but paging reaches %d", len(seen))
}
}
// A chip counts EVENTS, so it must name events. One ticket with nine
// comments is nine rows in this feed, and a chip that called that
// "tickets" reported 93,649 against a corpus of 17,000 tickets — the
// number was right and the noun was wrong. The repository sidebar's
// number counts tickets themselves, and the two must not read alike.
func TestFeedChipsNameEventsNotArtifacts(t *testing.T) {
ix, err := index.Open(filepath.Join(t.TempDir(), "index.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { ix.Close() })
// One ticket, three changes to it: three feed rows, one ticket.
id := strings.Repeat("ab", 20)
var batch []index.Parsed
for i, user := range []string{"dan", "eve", "dan"} {
batch = append(batch, index.Parsed{Hash: fmt.Sprintf("t%03d", i),
Art: &artifact.Artifact{Type: artifact.Ticket, TicketID: id,
Date: fmt.Sprintf("2026-08-1%dT09:00:00", i), User: user,
Fields: []artifact.Field{{Name: "status", Value: "Open"},
{Name: "title", Value: "one ticket"}}}})
}
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}}),
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)
html := string(b)
// The chip's count is of events: three, not one.
chip := regexp.MustCompile(`kind=t"[^>]*>.*?<span class="chip-w">([^<]*)</span>.*?<span class="n"><span title="(\d+)"`).FindStringSubmatch(html)
if chip == nil {
t.Fatalf("no ticket chip:\n%s", html)
}
if chip[2] != "3" {
t.Errorf("chip counts %s, want 3 events for one ticket changed three times", chip[2])
}
// And it says so: a chip counting changes must not be called by the
// name of the thing that was changed. Only the full label carries
// this — the narrow-screen short form is an abbreviation, and the
// full wording stays in aria-label for anyone who cannot see which
// one is rendered.
if label := strings.TrimSpace(chip[1]); label == "tickets" {
t.Errorf("the chip counts %s events but calls itself %q", chip[2], label)
}
// The sidebar's number counts the ticket itself — one — and is
// labelled for what it is, so the two numbers cannot be confused.
if !strings.Contains(html, `title="open tickets"`) {
t.Error("the repository's ticket count is unlabelled")
}
}
// chatSSE opens the live stream and reads until want appears (or the
// deadline), returning everything read. The stream never ends on its
// own, so the read has to be bounded by content rather than by EOF.
func chatSSE(t *testing.T, srv *httptest.Server, path string, header map[string]string, want string) string {
t.Helper()
req, _ := http.NewRequest("GET", srv.URL+path, nil)
for k, v := range header {
req.Header.Set(k, v)
}
resp, err := srv.Client().Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
rd := bufio.NewReader(resp.Body)
deadline := time.After(5 * time.Second)
got := ""
for !strings.Contains(got, want) {
lineCh := make(chan string, 1)
go func() { l, _ := rd.ReadString('\n'); lineCh <- l }()
select {
case l := <-lineCh:
got += l
case <-deadline:
t.Fatalf("waiting for %q in the stream:\n%s", want, got)
}
}
return got
}
// chatRegistry builds a registry over one repo whose visibility the
// caller controls — the shape both the page and the live stream must
// answer to identically.
func chatRegistry(t *testing.T, fc api.ChatPlane, forum api.ForumStore, canRead func(context.Context, string) bool) *ops.Registry {
t.Helper()
ix, err := index.Open(filepath.Join(t.TempDir(), "index.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { ix.Close() })
r := ops.NewRegistry()
deps := api.Deps{Version: "t", Chat: fc, Forum: forum, CanRead: canRead,
Repos: api.NewRepos(map[string]*api.Repo{
"test": {Index: ix, Code: "c1"},
"secret": {Index: ix, Code: "c2"},
})}
if err := api.RegisterAll(r, deps); err != nil {
t.Fatal(err)
}
return r
}
// The live stream is gated exactly like the page it feeds: a repository
// the viewer cannot read answers the same way on .sse as on /chat, with
// or without a resume cursor. A stream that skipped the check would leak
// a private channel's traffic to anyone who guessed the name.
func TestChatSSEIsGatedLikeThePage(t *testing.T) {
r := chatRegistry(t, &webChatStub{}, &webForumReadStub{},
func(_ context.Context, repo string) bool { return repo != "secret" })
h := Handler(r, nil, newFakeChatLive())
// The page's own answer for the unreadable repo, to compare against.
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest("GET", "/r/secret/chat", nil))
pageCode := rec.Code
if pageCode == http.StatusOK {
t.Fatalf("an unreadable repo's chat page returned 200: %s", rec.Body)
}
for _, tc := range []struct {
name string
header map[string]string
}{
{"fresh connect", nil},
{"resuming connect", map[string]string{"Last-Event-ID": "7"}},
} {
t.Run(tc.name, func(t *testing.T) {
// A gate that fails open would stream forever, so the
// request carries its own deadline and the test asserts on
// what came back rather than hanging.
ctx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond)
defer cancel()
req := httptest.NewRequest("GET", "/r/secret/chat.sse", nil).WithContext(ctx)
for k, v := range tc.header {
req.Header.Set(k, v)
}
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code == http.StatusOK {
t.Fatalf(".sse on an unreadable repo returned 200 (page said %d):\n%s", pageCode, rec.Body)
}
if rec.Code != http.StatusNotFound && rec.Code != http.StatusForbidden {
t.Fatalf(".sse status = %d, want 404 or 403 (page said %d)", rec.Code, pageCode)
}
if strings.Contains(rec.Header().Get("Content-Type"), "event-stream") {
t.Fatal(".sse opened a stream for an unreadable repo")
}
})
}
// A readable repo still streams. The handler only returns when the
// client goes away, so the request carries its own deadline.
ctx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond)
defer cancel()
req := httptest.NewRequest("GET", "/r/test/chat.sse", nil).WithContext(ctx)
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if !strings.Contains(rec.Header().Get("Content-Type"), "event-stream") {
t.Fatalf("readable repo did not stream: %d %s", rec.Code, rec.Body)
}
}
// A gap means the reader and the server no longer share a history, so
// appending would stack a second copy of the window onto the rows
// already on the page. The stream re-renders #chatlog instead.
func TestChatSSEGapRerendersInsteadOfAppending(t *testing.T) {
r := chatRegistry(t, &webChatStub{}, &webForumReadStub{},
func(context.Context, string) bool { return true })
live := newFakeChatLive()
srv := httptest.NewServer(Handler(r, nil, live))
defer srv.Close()
go func() {
time.Sleep(50 * time.Millisecond)
live.postLive(ChatMessage{Kind: "gap", Ref: 2, Seq: 9})
live.postLive(ChatMessage{Seq: 9, Kind: "msg", User: "dan", Text: "after the gap", HTML: "after the gap"})
}()
got := chatSSE(t, srv, "/r/test/chat.sse", map[string]string{"Last-Event-ID": "2"}, "after the gap")
gapAt := strings.Index(got, "data: mode outer")
if gapAt < 0 {
t.Fatalf("gap did not replace #chatlog outright:\n%s", got)
}
if !strings.Contains(got[gapAt:], "data: selector #chatlog") {
t.Fatalf("gap re-render does not target #chatlog:\n%s", got)
}
// Rows are inserted before the end sentinel, not appended to the
// list, so the sentinel stays last and "jump to latest" keeps
// meaning the latest.
appendAt := strings.Index(got, "mode before")
if appendAt < 0 {
t.Fatalf("no replayed row after the gap:\n%s", got)
}
if appendAt < gapAt {
t.Fatalf("appended before re-rendering — the DOM would carry duplicates:\n%s", got)
}
// The gap itself is never a row.
if strings.Contains(got, `data-kind="gap"`) {
t.Fatalf("gap signal rendered as a message row:\n%s", got)
}
}
// A tombstone's author is not the row's author. A moderator deleting
// someone else's message must not repaint the row under the moderator's
// name, and an edit must not restamp the message's time.
func TestChatSSEEditKeepsTheOriginalAuthor(t *testing.T) {
r := chatRegistry(t, &webChatStub{}, &webForumReadStub{},
func(context.Context, string) bool { return true })
live := newFakeChatLive()
srv := httptest.NewServer(Handler(r, nil, live))
defer srv.Close()
go func() {
time.Sleep(50 * time.Millisecond)
live.postLive(ChatMessage{Seq: 4, Kind: "del", Ref: 1, User: "moderator", At: "2026-08-29T00:00:00Z"})
}()
got := chatSSE(t, srv, "/r/test/chat.sse", nil, `id="chatbody-1"`)
if !strings.Contains(got, "data: selector #chatbody-1") {
t.Fatalf("tombstone patch does not target the ORIGINAL message's body:\n%s", got)
}
if strings.Contains(got, "<b>moderator</b>") {
t.Fatalf("tombstone repainted the row under the tombstone's own author:\n%s", got)
}
if strings.Contains(got, "chatmsg-4") || strings.Contains(got, `data-seq="4"`) {
t.Fatalf("tombstone rendered as a row of its own:\n%s", got)
}
if !strings.Contains(got, "data-deleted") {
t.Fatalf("deleted body not marked:\n%s", got)
}
}
// A fully-read channel has no unread divider. chat.unread omits a
// fully-read repository entirely, so a page sourcing its read mark from
// there would see 0 and draw the divider above every message — the
// exact opposite of what "you have read all of this" means.
func TestChatPageFullyReadHasNoDivider(t *testing.T) {
fc := &webChatStub{msgs: []chat.Message{
{Seq: 1, Kind: chat.KindMsg, User: "dan", Text: "one", At: "2026-08-16T12:00:00Z"},
{Seq: 2, Kind: chat.KindMsg, User: "dan", Text: "two", At: "2026-08-16T12:00:01Z"},
}}
forum := &webForumReadStub{}
if err := forum.MarkChatRead("test", "dan", 2); err != nil {
t.Fatal(err)
}
r := chatRegistry(t, fc, forum, func(context.Context, string) bool { return true })
h := HandlerWith(r, nil, newFakeChatLive(), []Option{WithSessions(fakeSessions{user: "dan"})})
rec := httptest.NewRecorder()
h.ServeHTTP(rec, withCookie("/r/test/chat"))
body := rec.Body.String()
if !strings.Contains(body, `data-seq="2"`) {
t.Fatalf("chat page did not render:\n%s", body)
}
if strings.Contains(body, `class="chat-divider"`) {
t.Fatalf("fully-read channel still shows an unread divider:\n%s", body)
}
}
// A live row goes through the same markdown renderer the page's rows
// did. A ChatLive with no markup pipeline of its own (the composition
// root's adapter, over raw chat.Tail) hands over plain text; the stream
// renders it rather than shipping a row that reads differently from the
// identical message after a reload.
func TestChatSSELiveRowsUseThePageRenderer(t *testing.T) {
r := chatRegistry(t, &webChatStub{}, &webForumReadStub{},
func(context.Context, string) bool { return true })
live := newFakeChatLive()
srv := httptest.NewServer(Handler(r, nil, live))
defer srv.Close()
go func() {
time.Sleep(50 * time.Millisecond)
live.postLive(ChatMessage{Seq: 3, Kind: "msg", User: "dan", Text: "a **bold** word"})
}()
got := chatSSE(t, srv, "/r/test/chat.sse", nil, "bold")
if !strings.Contains(got, "<strong>bold</strong>") {
t.Fatalf("live row shipped raw markdown instead of the rendered HTML:\n%s", got)
}
}
// Every fleet surface must be able to reach every other one. The nav is
// shared, so it is easy to give one member an attribute that only
// another member can honour: syncurl's data-on:click__prevent swallowed
// the click on /fleet/ops and /fleet/exceptions, which have no
// #fleet-panel to patch, and Stores and Mirrors became dead text there.
// The claim is the ROUTE, not the markup — a link that prevents its own
// default is only a link on a page that can answer instead.
func TestEveryFleetSurfaceCanReachTheOthers(t *testing.T) {
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)
// No fleet tab prevents its own navigation any more. They are
// ordinary links on every surface, like the repository bar's, so
// the ink's view transition runs on all four instead of on the two
// that happened to navigate.
for _, tc := range []struct {
path string
morph bool
}{
{"/fleet", false},
{"/fleet?tab=mirrors", false},
{"/fleet/ops", false},
{"/fleet/exceptions", false},
} {
req, _ := http.NewRequest("GET", srv.URL+tc.path, nil)
req.AddCookie(&http.Cookie{Name: sessionCookie, Value: "good"})
resp, err := srv.Client().Do(req)
if err != nil {
t.Fatal(err)
}
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
page := string(body)
for _, dest := range []string{`href="/fleet"`, `href="/fleet?tab=mirrors"`,
`href="/fleet/ops"`, `href="/fleet/exceptions"`} {
if !strings.Contains(page, dest) {
t.Errorf("%s cannot reach %s", tc.path, dest)
}
}
if got := strings.Contains(page, "click__prevent"); got != tc.morph {
t.Errorf("%s: click__prevent=%v, want %v — a fleet tab that "+
"prevents its own click performs no navigation, so its "+
"view transition never runs",
tc.path, got, tc.morph)
}
}
}
// Following a reference lands you in a window AROUND the message, both
// sides. It used to land on the page ENDING there, which erased the
// rest of the conversation from view — click "#4" mid-thread and every
// reply to it vanished. Both halves, merged in order, the pill standing
// down when the window reaches the tip.
func TestOpeningAtAMessageKeepsBothSides(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 msgs []chat.Message
for i := uint64(1); i <= 9; i++ {
msgs = append(msgs, chat.Message{Seq: i, Kind: chat.KindMsg, User: "dan",
Text: fmt.Sprintf("m%d", i), At: "2026-08-16T12:00:00Z"})
}
r := ops.NewRegistry()
if err := api.RegisterAll(r, api.Deps{Version: "t",
Repos: api.NewRepos(map[string]*api.Repo{"test": {Index: ix, Code: "c1"}}),
Chat: &webChatStub{msgs: msgs}, Forum: &webForumReadStub{},
CanRead: func(context.Context, string) bool { return true }}); err != nil {
t.Fatal(err)
}
h := HandlerWith(r, nil, newFakeChatLive(), []Option{WithSessions(fakeSessions{user: "dan"})})
rec := httptest.NewRecorder()
h.ServeHTTP(rec, withCookie("/r/test/chat?at=5"))
body := rec.Body.String()
for _, want := range []string{"m3", "m5", "m7", "m9"} {
if !strings.Contains(body, ">"+want+"<") {
t.Errorf("the window around #5 is missing %s:\n%s", want, body[:min(len(body), 400)])
}
}
if !strings.Contains(body, `class="chat-anchor"`) {
t.Error("the message opened at is not marked")
}
// The window reaches the tip, so this IS the live page: no pill
// that would send you to the page you are on.
if strings.Contains(body, `class="chat-jump" href="/r/test/chat"`) {
t.Error("a window that reaches the tip still draws the older-page jump")
}
if strings.Contains(body, "?after=") {
t.Error("a window that reaches the tip still offers later messages")
}
}
// Opening the live stream signed in puts you in the room; the request
// ending takes you out. Anonymous streams watch without standing in it.
func TestLiveStreamJoinsAndLeavesTheRoom(t *testing.T) {
live := newFakeChatLive()
rec := httptest.NewRecorder()
ctx, cancel := context.WithCancel(context.Background())
req := httptest.NewRequest("GET", "/r/test/chat.sse", nil).WithContext(ctx)
done := make(chan struct{})
go func() {
liveChatAs(live, "test", "dan", false, func(s string) string { return s }, rec, req)
close(done)
}()
// The join is immediate on open.
deadline := time.After(2 * time.Second)
for {
live.mu.Lock()
n := len(live.joined)
live.mu.Unlock()
if n == 1 {
break
}
select {
case <-deadline:
t.Fatal("opening the stream did not join the room")
case <-time.After(10 * time.Millisecond):
}
}
cancel()
<-done
live.mu.Lock()
defer live.mu.Unlock()
if len(live.left) != 1 || live.left[0] != "dan" {
t.Errorf("closing the stream did not leave the room: left=%v", live.left)
}
}