Skip to content
quarry Artifact [5d6c92e570]
Login

Artifact [5d6c92e570]

Artifact 5d6c92e57019e9b9153787c42a75270206b56e7c14740bb5271d7fba2c239a40:


package web

import (
	"bufio"
	"fmt"
	"io"
	"net/http"
	"net/http/httptest"
	"net/url"
	"path/filepath"
	"strings"
	"testing"
	"time"

	"craftdesign.group/quarry/internal/api"
	"craftdesign.group/quarry/internal/artifact"
	"craftdesign.group/quarry/internal/chat"
	"craftdesign.group/quarry/internal/errlog"
	"craftdesign.group/quarry/internal/index"
	"craftdesign.group/quarry/internal/ops"
)

func testRegistry(t *testing.T) *ops.Registry {
	t.Helper()
	// An empty (but real) index so entity lookups answer not-found
	// rather than "no store configured".
	ix, err := index.Open(filepath.Join(t.TempDir(), "index.db"))
	if err != nil {
		t.Fatal(err)
	}
	t.Cleanup(func() { ix.Close() })
	r := ops.NewRegistry()
	if err := api.RegisterAll(r, api.Deps{Version: "9.9.9-test", Repos: api.NewRepos(map[string]*api.Repo{"test": {Index: ix}})}); err != nil {
		t.Fatal(err)
	}
	return r
}

func TestHomePageRendersThroughVersionOp(t *testing.T) {
	rec := httptest.NewRecorder()
	Handler(testRegistry(t), nil, nil).ServeHTTP(rec, httptest.NewRequest("GET", "/", nil))
	if rec.Code != 200 {
		t.Fatalf("status %d", rec.Code)
	}
	body := rec.Body.String()
	if !strings.Contains(body, "quarry 9.9.9-test") {
		t.Fatalf("footer missing op-served version:\n%s", body)
	}
	if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/html") {
		t.Fatalf("content-type %q", ct)
	}
	// Principle 3: the ONLY script is the vendored Datastar module (the
	// one allowed framework, pure enhancement); no inline code.
	if n := strings.Count(body, "<script"); n != 1 || !strings.Contains(body, `src="/assets/datastar.js"`) {
		t.Fatalf("script policy violated (%d script tags):\n%s", n, body)
	}
	if strings.Contains(body, "<script>") {
		t.Fatal("inline script on the base page")
	}
	if !strings.Contains(body, "prefers-color-scheme") {
		t.Fatal("dark theme missing")
	}
}

func TestUnknownPathIs404(t *testing.T) {
	rec := httptest.NewRecorder()
	Handler(testRegistry(t), nil, nil).ServeHTTP(rec, httptest.NewRequest("GET", "/nope", nil))
	if rec.Code != 404 {
		t.Fatalf("status %d", rec.Code)
	}
}

func TestThemed404Page(t *testing.T) {
	for _, path := range []string{"/nope", "/r/test/wiki/NoSuchPage", "/r/test/checkin/deadbeefdead", "/r/test/ticket/deadbeef"} {
		rec := httptest.NewRecorder()
		Handler(testRegistry(t), nil, nil).ServeHTTP(rec, httptest.NewRequest("GET", path, nil))
		if rec.Code != 404 {
			t.Errorf("%s: status %d", path, rec.Code)
			continue
		}
		body := rec.Body.String()
		if !strings.HasPrefix(rec.Header().Get("Content-Type"), "text/html") {
			t.Errorf("%s: content-type %q — bare default 404?", path, rec.Header().Get("Content-Type"))
		}
		if !strings.Contains(body, "quarry 9.9.9-test") {
			t.Errorf("%s: 404 page missing version footer", path)
		}
		if !strings.Contains(body, `href="/"`) {
			t.Errorf("%s: 404 page has no way back into the site", path)
		}
	}
}

func TestPersistentNavOnEveryPage(t *testing.T) {
	// Ticket 010: every repo page carries that repo's nav and search
	// form; every page (fleet and 404 included) carries the site link.
	for _, path := range []string{"/r/test/timeline", "/r/test/wiki", "/r/test/ticket", "/r/test/search"} {
		rec := httptest.NewRecorder()
		Handler(testRegistry(t), nil, nil).ServeHTTP(rec, httptest.NewRequest("GET", path, nil))
		body := rec.Body.String()
		for _, want := range []string{`<nav>`, `href="/r/test/timeline"`, `href="/r/test/wiki"`, `href="/r/test/ticket"`, `action="/r/test/search"`} {
			if !strings.Contains(body, want) {
				t.Errorf("%s: missing %s in header", path, want)
			}
		}
	}
	for _, path := range []string{"/", "/nope"} {
		rec := httptest.NewRecorder()
		Handler(testRegistry(t), nil, nil).ServeHTTP(rec, httptest.NewRequest("GET", path, nil))
		if !strings.Contains(rec.Body.String(), `class="site" href="/"`) {
			t.Errorf("%s: missing site link", path)
		}
	}
}

func TestFleetPageListsRepos(t *testing.T) {
	rec := httptest.NewRecorder()
	Handler(testRegistry(t), nil, nil).ServeHTTP(rec, httptest.NewRequest("GET", "/", nil))
	if rec.Code != 200 {
		t.Fatalf("status %d", rec.Code)
	}
	// A repository's name opens the repository, which is its files —
	// not the timeline. Landing on what changed rather than on what the
	// project is was the old behaviour.
	if !strings.Contains(rec.Body.String(), `href="/r/test"`) {
		t.Fatalf("fleet page missing repo link:\n%s", rec.Body.String())
	}
}

func TestTablesScrollInWrapper(t *testing.T) {
	// Ticket 120: wide tables scroll inside .tw, not the page.
	rec := httptest.NewRecorder()
	Handler(testRegistry(t), nil, nil).ServeHTTP(rec, httptest.NewRequest("GET", "/r/test/timeline", nil))
	body := rec.Body.String()
	if strings.Contains(body, "<table>") && !strings.Contains(body, `<div class="tw"><table>`) {
		t.Fatalf("timeline table not wrapped:\n%s", body)
	}
}

func TestTimelineRendersAllRowsWithBranches(t *testing.T) {
	// Regression: a template field mismatch once truncated the page at
	// the first row — Execute's error was discarded. Render a POPULATED
	// timeline and assert every row, branch labels, and a complete page.
	ix, err := index.Open(filepath.Join(t.TempDir(), "index.db"))
	if err != nil {
		t.Fatal(err)
	}
	t.Cleanup(func() { ix.Close() })
	var batch []index.Parsed
	for i := 0; i < 5; i++ {
		h := strings.Repeat(fmt.Sprintf("%02d", i+1), 32)
		art := &artifact.Artifact{Type: artifact.Manifest, User: "dan",
			Date:    fmt.Sprintf("2026-08-15T0%d:00:00", i),
			Comment: fmt.Sprintf("commit %d", i)}
		if i == 0 {
			art.Tags = []artifact.Tag{{Name: "*branch", Target: "*", Value: "trunk"}}
		}
		if i > 0 {
			art.Parents = []string{strings.Repeat(fmt.Sprintf("%02d", i), 32)}
		}
		batch = append(batch, index.Parsed{Hash: h, Art: art})
	}
	if err := ix.FoldNow(batch); err != nil {
		t.Fatal(err)
	}
	r := ops.NewRegistry()
	if err := api.RegisterAll(r, api.Deps{Version: "9.9.9-test",
		Repos: api.NewRepos(map[string]*api.Repo{"test": {Index: ix}})}); err != nil {
		t.Fatal(err)
	}
	rec := httptest.NewRecorder()
	Handler(r, nil, nil).ServeHTTP(rec, httptest.NewRequest("GET", "/r/test/timeline", nil))
	body := rec.Body.String()
	if got := strings.Count(body, `class="tl-card"`); got != 5 {
		t.Fatalf("rendered %d rows, want 5:\n%s", got, body)
	}
	// Count the GRAPH's layers specifically. A bare "<svg " count also
	// catches the file-listing icons, which live as data URIs in the
	// base stylesheet and have nothing to do with the timeline.
	if got := strings.Count(body, `<svg class="tl-`); got != 10 { // head + tail per row
		t.Fatalf("rendered %d graph layers, want 10", got)
	}
	if got := strings.Count(body, `<svg class="tl-head`); got != 5 {
		t.Fatalf("rendered %d head layers, want 5", got)
	}
	if !strings.Contains(body, `<span class="tl-pill">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", 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), branchStroke("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", entries)
	rows := days[0].Rows
	dev, unknown := branchStroke("dev"), branchStroke("")

	// 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", []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, branchStroke("")) {
		t.Fatalf("off-page merge rail not neutral:\n%s", head)
	}
}

type fakeNotifier struct{ ch chan struct{} }

func (f *fakeNotifier) Watch(string) (<-chan struct{}, func()) { return f.ch, func() {} }

func TestLiveTimelineSSE(t *testing.T) {
	// The live endpoint streams a Datastar element patch containing the
	// re-rendered #tl fragment when the notifier ticks.
	ix, err := index.Open(filepath.Join(t.TempDir(), "index.db"))
	if err != nil {
		t.Fatal(err)
	}
	t.Cleanup(func() { ix.Close() })
	h := strings.Repeat("99", 32)
	if err := ix.FoldNow([]index.Parsed{{Hash: h, Art: &artifact.Artifact{
		Type: artifact.Manifest, Date: "2026-08-15T10:00:00", User: "dan", Comment: "live one"}}}); err != nil {
		t.Fatal(err)
	}
	r := ops.NewRegistry()
	if err := api.RegisterAll(r, api.Deps{Version: "v",
		Repos: api.NewRepos(map[string]*api.Repo{"test": {Index: ix}})}); err != nil {
		t.Fatal(err)
	}
	fn := &fakeNotifier{ch: make(chan struct{}, 1)}
	srv := httptest.NewServer(Handler(r, fn, nil))
	t.Cleanup(srv.Close)

	// The page advertises the live hookup.
	page, _ := srv.Client().Get(srv.URL + "/r/test/timeline")
	body, _ := io.ReadAll(page.Body)
	page.Body.Close()
	if !strings.Contains(string(body), `data-init="@get('/r/test/timeline.sse?`) {
		t.Fatalf("page missing live hookup:\n%s", body)
	}

	resp, err := srv.Client().Get(srv.URL + "/r/test/timeline.sse")
	if err != nil {
		t.Fatal(err)
	}
	t.Cleanup(func() { resp.Body.Close() })
	if ct := resp.Header.Get("Content-Type"); ct != "text/event-stream" {
		t.Fatalf("content-type %q", ct)
	}
	fn.ch <- struct{}{}
	rd := bufio.NewReader(resp.Body)
	deadline := time.After(5 * time.Second)
	got := ""
	for !strings.Contains(got, "datastar-patch-elements") || !strings.Contains(got, "live one") {
		lineCh := make(chan string, 1)
		go func() {
			l, _ := rd.ReadString('\n')
			lineCh <- l
		}()
		select {
		case l := <-lineCh:
			got += l
		case <-deadline:
			t.Fatalf("no patch within deadline; got:\n%s", got)
		}
	}
	if !strings.Contains(got, `data: elements <div id="tl">`) {
		t.Fatalf("patch not a #tl morph:\n%s", got)
	}
}

// TestWikiHistoryPages covers the wiki history list, revision view with
// its banner, and the timeline linking wiki rows to their revision.
func TestWikiHistoryPages(t *testing.T) {
	ix, err := index.Open(filepath.Join(t.TempDir(), "index.db"))
	if err != nil {
		t.Fatal(err)
	}
	t.Cleanup(func() { ix.Close() })
	if err := ix.FoldNow([]index.Parsed{
		{Hash: strings.Repeat("a1", 20), Art: &artifact.Artifact{Type: artifact.Wiki,
			WikiTitle: "Guide", Date: "2026-08-15T09:00:00", User: "dan",
			Mimetype: "text/x-markdown", Body: []byte("first draft\n")}},
		{Hash: strings.Repeat("b2", 20), Art: &artifact.Artifact{Type: artifact.Wiki,
			WikiTitle: "Guide", Date: "2026-08-15T10:00:00", User: "eve",
			Mimetype: "text/x-markdown", Body: []byte("polished\n")}},
	}); err != nil {
		t.Fatal(err)
	}
	r := ops.NewRegistry()
	if err := api.RegisterAll(r, api.Deps{Version: "t", Repos: api.NewRepos(map[string]*api.Repo{"test": {Index: ix}})}); err != nil {
		t.Fatal(err)
	}
	h := Handler(r, nil, nil)

	get := func(path string) string {
		rec := httptest.NewRecorder()
		h.ServeHTTP(rec, httptest.NewRequest("GET", path, nil))
		if rec.Code != 200 {
			t.Fatalf("%s: status %d\n%s", path, rec.Code, rec.Body.String())
		}
		return rec.Body.String()
	}

	// Latest view: history link, no revision banner.
	body := get("/r/test/wiki/Guide")
	if !strings.Contains(body, "polished") || !strings.Contains(body, `/r/test/wiki/Guide/history`) {
		t.Fatalf("latest view:\n%s", body)
	}
	if strings.Contains(body, "Historical revision") {
		t.Fatal("banner on latest view")
	}

	// History lists both edits, newest first, old one linked with ?rev=.
	body = get("/r/test/wiki/Guide/history")
	if !strings.Contains(body, "?rev="+strings.Repeat("a1", 6)) {
		t.Fatalf("old revision not linked:\n%s", body)
	}
	if i, j := strings.Index(body, strings.Repeat("b2", 6)), strings.Index(body, strings.Repeat("a1", 6)); i < 0 || j < 0 || i > j {
		t.Fatalf("history order wrong (b2 at %d, a1 at %d):\n%s", i, j, body)
	}

	// Revision view: old content plus the banner back to latest.
	body = get("/r/test/wiki/Guide?rev=" + strings.Repeat("a1", 6))
	if !strings.Contains(body, "first draft") || strings.Contains(body, "polished") {
		t.Fatalf("revision content:\n%s", body)
	}
	if !strings.Contains(body, "Historical revision") {
		t.Fatalf("banner missing:\n%s", body)
	}

	// Timeline wiki rows link the revision they represent.
	body = get("/r/test/timeline")
	if !strings.Contains(body, "/r/test/wiki/Guide?rev="+strings.Repeat("a1", 6)) {
		t.Fatalf("timeline wiki row not revision-linked:\n%s", body)
	}
}

// Real stores carry wiki titles with slashes (tcl's "(Initi/Fin)alize");
// every generated link must escape them so the mux sees one segment.
func TestWikiTitleWithSlash(t *testing.T) {
	ix, err := index.Open(filepath.Join(t.TempDir(), "index.db"))
	if err != nil {
		t.Fatal(err)
	}
	t.Cleanup(func() { ix.Close() })
	if err := ix.FoldNow([]index.Parsed{
		{Hash: strings.Repeat("c3", 20), Art: &artifact.Artifact{Type: artifact.Wiki,
			WikiTitle: "(Initi/Fin)alize", Date: "2026-08-15T09:00:00", User: "dan",
			Mimetype: "text/x-markdown", Body: []byte("lifecycle\n")}},
	}); err != nil {
		t.Fatal(err)
	}
	r := ops.NewRegistry()
	if err := api.RegisterAll(r, api.Deps{Version: "t", Repos: api.NewRepos(map[string]*api.Repo{"test": {Index: ix}})}); err != nil {
		t.Fatal(err)
	}
	h := Handler(r, nil, nil)

	rec := httptest.NewRecorder()
	h.ServeHTTP(rec, httptest.NewRequest("GET", "/r/test/wiki", nil))
	if !strings.Contains(rec.Body.String(), `/r/test/wiki/%28Initi%2FFin%29alize`) {
		t.Fatalf("wiki list link not escaped:\n%s", rec.Body.String())
	}
	for _, path := range []string{
		"/r/test/wiki/%28Initi%2FFin%29alize",
		"/r/test/wiki/%28Initi%2FFin%29alize/history",
	} {
		rec := httptest.NewRecorder()
		h.ServeHTTP(rec, httptest.NewRequest("GET", path, nil))
		if rec.Code != 200 || !strings.Contains(rec.Body.String(), "(Initi/Fin)alize") {
			t.Fatalf("%s: status %d\n%s", path, rec.Code, rec.Body.String())
		}
	}
}

// TestLiveFleetRevStream: the landing page is live AT LOAD, and the
// stream that makes it live carries only an invalidation signal. The
// old stream carried rendered rows and therefore the filter it was
// opened with — opened at load, that meant repainting a reader's
// filtered view with everything a few seconds after they typed. A
// revision number cannot be stale: the client re-requests the listing
// with its CURRENT form state.
func TestLiveFleetRevStream(t *testing.T) {
	ix, err := index.Open(filepath.Join(t.TempDir(), "index.db"))
	if err != nil {
		t.Fatal(err)
	}
	t.Cleanup(func() { ix.Close() })
	if err := ix.FoldNow([]index.Parsed{{Hash: strings.Repeat("77", 32), Art: &artifact.Artifact{
		Type: artifact.Manifest, Date: "2026-08-15T10:00:00", User: "dan", Comment: "fleet tip"}}}); err != nil {
		t.Fatal(err)
	}
	r := ops.NewRegistry()
	if err := api.RegisterAll(r, api.Deps{Version: "v",
		Repos: api.NewRepos(map[string]*api.Repo{"test": {Index: ix}})}); err != nil {
		t.Fatal(err)
	}
	fn := &fakeNotifier{ch: make(chan struct{}, 1)}
	srv := httptest.NewServer(Handler(r, fn, nil))
	t.Cleanup(srv.Close)

	page, _ := srv.Client().Get(srv.URL + "/")
	body, _ := io.ReadAll(page.Body)
	page.Body.Close()
	// Live at load: the signal stream opens with the page…
	if !strings.Contains(string(body), `data-init="@get('/fleet.rev')"`) {
		t.Fatalf("home does not open the revision stream at load:\n%s", body)
	}
	// …and every refresh goes through the form, so it carries the filter
	// as it is NOW. The && guard stops the effect's first run (rev 0)
	// from re-fetching what the server just rendered.
	if !strings.Contains(string(body), `data-effect="$fleetRev && @get('/fleet.frag', {contentType: 'form'})"`) {
		t.Fatalf("no revision-driven refresh effect:\n%s", body)
	}
	if !strings.Contains(string(body), `data-on:input__debounce.300ms="@get('/fleet.frag'`) {
		t.Fatalf("filter input does not refresh through the one-shot:\n%s", body)
	}
	// The signal stream must NEVER render rows: rendered rows are what
	// carried the stale filter.
	resp, err := srv.Client().Get(srv.URL + "/fleet.rev")
	if err != nil {
		t.Fatal(err)
	}
	t.Cleanup(func() { resp.Body.Close() })
	fn.ch <- struct{}{}
	rd := bufio.NewReader(resp.Body)
	deadline := time.After(5 * time.Second)
	got := ""
	for !strings.Contains(got, "fleetRev") {
		lineCh := make(chan string, 1)
		go func() {
			l, _ := rd.ReadString('\n')
			lineCh <- l
		}()
		select {
		case l := <-lineCh:
			got += l
		case <-deadline:
			t.Fatalf("no signal within deadline; got:\n%s", got)
		}
	}
	if !strings.Contains(got, "datastar-patch-signals") {
		t.Fatalf("revision did not arrive as a signal patch:\n%s", got)
	}
	if strings.Contains(got, "datastar-patch-elements") {
		t.Fatalf("the signal stream rendered elements; that is the stale-filter bug reborn:\n%s", got)
	}
}

// TestForumTechnotePages renders the forum and technote read surfaces.
func TestForumTechnotePages(t *testing.T) {
	ix, err := index.Open(filepath.Join(t.TempDir(), "index.db"))
	if err != nil {
		t.Fatal(err)
	}
	t.Cleanup(func() { ix.Close() })
	root := strings.Repeat("aa", 20)
	if err := ix.FoldNow([]index.Parsed{
		{Hash: root, Art: &artifact.Artifact{Type: artifact.Forum,
			ThreadTitle: "First topic", Date: "2026-08-16T09:00:00", User: "dan",
			Body: []byte("opening post body\n")}},
		{Hash: strings.Repeat("bb", 20), Art: &artifact.Artifact{Type: artifact.Forum,
			ThreadRoot: root, InReplyTo: root, Date: "2026-08-16T10:00:00", User: "eve",
			Body: []byte("a considered reply\n")}},
		{Hash: strings.Repeat("cc", 20), Art: &artifact.Artifact{Type: artifact.Technote,
			EventID: strings.Repeat("dd", 20), EventTime: "2026-08-16T08:00:00",
			Date: "2026-08-16T08:00:00", User: "dan", Mimetype: "text/x-markdown",
			Body: []byte("# Note\n\ntechnote body text\n")}},
	}); err != nil {
		t.Fatal(err)
	}
	r := ops.NewRegistry()
	if err := api.RegisterAll(r, api.Deps{Version: "t", Repos: api.NewRepos(map[string]*api.Repo{"test": {Index: ix}})}); err != nil {
		t.Fatal(err)
	}
	h := Handler(r, nil, nil)
	get := func(path string) string {
		rec := httptest.NewRecorder()
		h.ServeHTTP(rec, httptest.NewRequest("GET", path, nil))
		if rec.Code != 200 {
			t.Fatalf("%s: status %d\n%s", path, rec.Code, rec.Body.String())
		}
		return rec.Body.String()
	}

	if b := get("/r/test/forum"); !strings.Contains(b, "First topic") || !strings.Contains(b, "2 posts") {
		t.Fatalf("forum list:\n%s", b)
	}
	if b := get("/r/test/forum/" + root[:16]); !strings.Contains(b, "opening post body") || !strings.Contains(b, "a considered reply") {
		t.Fatalf("forum thread:\n%s", b)
	}
	if b := get("/r/test/technote"); !strings.Contains(b, "/r/test/technote/"+strings.Repeat("dd", 8)) {
		t.Fatalf("technote list:\n%s", b)
	}
	if b := get("/r/test/technote/" + strings.Repeat("dd", 8)); !strings.Contains(b, "technote body text") {
		t.Fatalf("technote page:\n%s", b)
	}
	// Nav links present on every repo page.
	if b := get("/r/test/timeline"); !strings.Contains(b, `/r/test/forum`) || !strings.Contains(b, `/r/test/technote`) {
		t.Fatal("nav missing forum/technote links")
	}
}

// fakeChatLive delivers preset messages to the SSE handler.
type fakeChatLive struct{ ch chan ChatMessage }

func (f *fakeChatLive) SubscribeChat(string) (<-chan ChatMessage, func()) {
	return f.ch, func() {}
}

func TestChatPage(t *testing.T) {
	ix, err := index.Open(filepath.Join(t.TempDir(), "index.db"))
	if err != nil {
		t.Fatal(err)
	}
	t.Cleanup(func() { ix.Close() })
	r := ops.NewRegistry()
	fc := &webChatStub{msgs: []chat.Message{{User: "dan", Text: "hello world", At: "2026-08-16T12:00:00Z"}}}
	if err := api.RegisterAll(r, api.Deps{Version: "t",
		Repos: api.NewRepos(map[string]*api.Repo{"test": {Index: ix, Code: "c1"}}), Chat: fc}); err != nil {
		t.Fatal(err)
	}
	live := &fakeChatLive{ch: make(chan ChatMessage, 1)}
	h := Handler(r, nil, live)

	// The page shows history + the post form + the live hookup + nav link.
	rec := httptest.NewRecorder()
	h.ServeHTTP(rec, httptest.NewRequest("GET", "/r/test/chat", nil))
	body := rec.Body.String()
	if !strings.Contains(body, "hello world") || !strings.Contains(body, `action="/r/test/chat"`) {
		t.Fatalf("chat page:\n%s", body)
	}
	if !strings.Contains(body, `data-init="@get('/r/test/chat.sse')"`) {
		t.Fatal("chat page missing live hookup")
	}
	if !strings.Contains(body, `/r/test/chat`) || !strings.Contains(rec.Body.String(), `id="chatlog"`) {
		t.Fatal("chat log container missing")
	}

	// The SSE stream appends a new message as a datastar patch. Close
	// the response before the server so Close doesn't block on the
	// still-open stream.
	srv := httptest.NewServer(h)
	resp, err := srv.Client().Get(srv.URL + "/r/test/chat.sse")
	if err != nil {
		srv.Close()
		t.Fatal(err)
	}
	live.ch <- ChatMessage{User: "eve", Text: "live line", At: "now"}
	rd := bufio.NewReader(resp.Body)
	deadline := time.After(5 * time.Second)
	got := ""
	for !strings.Contains(got, "live line") || !strings.Contains(got, "mode append") {
		lineCh := make(chan string, 1)
		go func() { l, _ := rd.ReadString('\n'); lineCh <- l }()
		select {
		case l := <-lineCh:
			got += l
		case <-deadline:
			resp.Body.Close()
			srv.Close()
			t.Fatalf("no chat patch:\n%s", got)
		}
	}
	resp.Body.Close()
	srv.Close()
	if !strings.Contains(got, "selector #chatlog") {
		t.Fatalf("patch not targeting #chatlog:\n%s", got)
	}
}

type webChatStub struct{ msgs []chat.Message }

func (s *webChatStub) Post(code string, m chat.Message) error                 { s.msgs = append(s.msgs, m); return nil }
func (s *webChatStub) History(code string, limit int) ([]chat.Message, error) { return s.msgs, nil }

// The live stream re-renders the whole listing, so it has to know what
// the reader is looking at. A stream that does not carry the filter
// replaces the filtered listing with the default about a second after
// it appears — which reads as the filter simply not working.
func TestLiveFleetStreamCarriesTheFilter(t *testing.T) {
	ix, err := index.Open(filepath.Join(t.TempDir(), "index.db"))
	if err != nil {
		t.Fatal(err)
	}
	t.Cleanup(func() { ix.Close() })
	r := ops.NewRegistry()
	if err := api.RegisterAll(r, api.Deps{Version: "v", Repos: api.NewRepos(
		map[string]*api.Repo{"alpha": {Index: ix}, "beta": {Index: ix}})}); err != nil {
		t.Fatal(err)
	}
	fn := &fakeNotifier{ch: make(chan struct{}, 1)}
	srv := httptest.NewServer(Handler(r, fn, nil))
	t.Cleanup(srv.Close)

	// The page must point the stream at its own view, not at the bare URL.
	page, _ := srv.Client().Get(srv.URL + "/?q=alpha")
	body, _ := io.ReadAll(page.Body)
	page.Body.Close()
	// The filter reaches the stream through the form itself now: the
	// request carries q= because Datastar serializes the surrounding
	// form, so the page need not encode it in the URL.
	if !strings.Contains(string(body), `value="alpha"`) {
		t.Fatalf("the filter is not reflected in the form:\n%s", body)
	}

	// And the one-shot must actually apply it — and CLOSE: a held
	// connection is what carried the stale filter. ReadAll returning is
	// the closure assertion.
	resp, err := srv.Client().Get(srv.URL + "/fleet.frag?q=alpha")
	if err != nil {
		t.Fatal(err)
	}
	defer resp.Body.Close()
	done := make(chan []byte, 1)
	go func() { b, _ := io.ReadAll(resp.Body); done <- b }()
	var frag string
	select {
	case b := <-done:
		frag = string(b)
	case <-time.After(3 * time.Second):
		t.Fatal("/fleet.frag held its connection open; a one-shot must close")
	}
	if !strings.Contains(frag, "alpha") {
		t.Errorf("filtered stream dropped the match:\n%s", frag)
	}
	if strings.Contains(frag, "beta") {
		t.Errorf("filtered stream includes a repo the filter excludes:\n%s", frag)
	}
	// The controls come back with the repaint, so they must carry the
	// filter too or the box empties itself while the listing stays filtered.
	// The controls are NOT in the fragment any more — they live outside
	// the morphed region so a repaint cannot tear the input out from
	// under the reader mid-keystroke. What the fragment must carry is
	// the count, which describes the rows it is replacing.
	if strings.Contains(frag, `name="q"`) {
		t.Errorf("the filter input is inside the morphed region again:\n%s", frag)
	}
	if !strings.Contains(frag, "repositories") {
		t.Errorf("the repaint dropped the count that describes its rows:\n%s", frag)
	}
}

// The first SSE repaint arrives immediately on connect and REPLACES the
// server-rendered listing, so the fragment must be given everything the
// full page gave the same template. Any field it omits renders on load
// and then vanishes a moment later — which is what happened to the
// "23 of 23" count and the New repository link, and before them to the
// filter and sort.
//
// The admin control is per-connection: it must appear for an admin's
// stream and for nobody else's.
func TestFleetFragmentCarriesEverythingThePageDoes(t *testing.T) {
	ix, err := index.Open(filepath.Join(t.TempDir(), "index.db"))
	if err != nil {
		t.Fatal(err)
	}
	t.Cleanup(func() { ix.Close() })
	r := ops.NewRegistry()
	if err := api.RegisterAll(r, api.Deps{Version: "v", Repos: api.NewRepos(
		map[string]*api.Repo{"alpha": {Index: ix}, "beta": {Index: ix}})}); err != nil {
		t.Fatal(err)
	}

	anon, err := fleetFragment(r, url.Values{}, false)
	if err != nil {
		t.Fatal(err)
	}
	// Unfiltered the listing states the total outright; the point is
	// that the number survives the repaint at all.
	if !strings.Contains(anon, "2 repositories") {
		t.Errorf("the fragment dropped the fleet total, so the count vanishes on repaint:\n%s", anon)
	}
	// Filtered, the denominator is the fleet rather than the match
	// count: "1 of 1" is true and tells a reader nothing.
	one, err := fleetFragment(r, url.Values{"q": {"alpha"}}, false)
	if err != nil {
		t.Fatal(err)
	}
	if !strings.Contains(one, "1 of 2 repositories") {
		t.Errorf("a filtered listing does not count against the fleet:\n%s", one)
	}
	// The persistent New repository link lives with the controls now,
	// outside the morph. What the fragment still decides is the
	// empty-fleet prompt, which is admin-only — and must stay so.
	empty := api.NewRepos(map[string]*api.Repo{})
	r2 := ops.NewRegistry()
	if err := api.RegisterAll(r2, api.Deps{Version: "v", Repos: empty}); err != nil {
		t.Fatal(err)
	}
	anonEmpty, err := fleetFragment(r2, url.Values{}, false)
	if err != nil {
		t.Fatal(err)
	}
	if strings.Contains(anonEmpty, "Create one") {
		t.Error("an anonymous stream is offered the admin-only create prompt")
	}
	adminEmpty, err := fleetFragment(r2, url.Values{}, true)
	if err != nil {
		t.Fatal(err)
	}
	if !strings.Contains(adminEmpty, "Create one") {
		t.Error("an admin's stream loses the create prompt on repaint")
	}
}

// stubAccts is the AccountStore the dashboard needs; everything else
// no-ops.
type stubAccts struct{ grants map[string]map[string]string }

func (s stubAccts) Accounts() ([]api.Account, error)      { return nil, nil }
func (s stubAccts) AddAccount(string, string, bool) error { return nil }
func (s stubAccts) SetPassword(string, string) error      { return nil }
func (s stubAccts) DeleteAccount(string) error            { return nil }
func (s stubAccts) Grant(string, string, string) error    { return nil }
func (s stubAccts) Revoke(string, string) error           { return nil }
func (s stubAccts) GrantsFor(a string) (map[string]string, error) {
	return s.grants[a], nil
}

// A signed-in reader's "/" is their dashboard; anonymous readers and
// ?view=explore get the fleet unchanged — GitHub's rule. The dashboard
// must never 500 the homepage: an account with no grants falls back to
// the fleet.
func TestRootSwitchesOnSession(t *testing.T) {
	ix, err := index.Open(filepath.Join(t.TempDir(), "index.db"))
	if err != nil {
		t.Fatal(err)
	}
	t.Cleanup(func() { ix.Close() })
	if err := ix.FoldNow([]index.Parsed{{Hash: strings.Repeat("55", 32), Art: &artifact.Artifact{
		Type: artifact.Manifest, Date: "2026-08-19T09:00:00", User: "dan", Comment: "dash tip"}}}); err != nil {
		t.Fatal(err)
	}
	r := ops.NewRegistry()
	if err := api.RegisterAll(r, api.Deps{Version: "v",
		Repos:    api.NewRepos(map[string]*api.Repo{"alpha": {Index: ix}}),
		Accounts: stubAccts{grants: map[string]map[string]string{"dan": {"alpha": "write"}}},
	}); err != nil {
		t.Fatal(err)
	}
	sess := fakeSessions{user: "dan"}
	srv := httptest.NewServer(HandlerWith(r, nil, nil, []Option{WithSessions(sess)}))
	t.Cleanup(srv.Close)

	get := func(path string, cookie bool) string {
		req, _ := http.NewRequest("GET", srv.URL+path, nil)
		if cookie {
			req.AddCookie(&http.Cookie{Name: sessionCookie, Value: "good"})
		}
		resp, err := srv.Client().Do(req)
		if err != nil {
			t.Fatal(err)
		}
		defer resp.Body.Close()
		b, _ := io.ReadAll(resp.Body)
		return string(b)
	}

	home := get("/", true)
	for _, want := range []string{"Your repositories", "dash tip", "Activity", "Your recent work", "explore all repositories"} {
		if !strings.Contains(home, want) {
			t.Fatalf("signed-in home missing %q:\n%s", want, home)
		}
	}
	anon := get("/", false)
	if strings.Contains(anon, "Your repositories") {
		t.Fatal("anonymous home rendered a dashboard")
	}
	if !strings.Contains(anon, "filter") {
		t.Fatalf("anonymous home is not the fleet:\n%s", anon)
	}
	explore := get("/?view=explore", true)
	if strings.Contains(explore, "Your repositories") {
		t.Fatal("?view=explore did not escape the dashboard")
	}
}

// Every funnel that answers "internal error" must leave a journal
// entry: the web fail() default, and the panic recovery (which before
// this existed dropped the connection with a stack trace nobody saw).
// The /fleet page then renders the journal — that page saying "ok"
// while the web layer burned is the failure this whole surface exists
// to end.
func TestServerErrorsAreJournaled(t *testing.T) {
	before, _, _ := errlog.Default.Stats()

	r := ops.NewRegistry()
	if err := api.RegisterAll(r, api.Deps{Version: "v",
		Repos:  api.NewRepos(map[string]*api.Repo{}),
		Errors: errlog.Default}); err != nil {
		t.Fatal(err)
	}
	h := HandlerWith(r, nil, nil, []Option{WithSessions(fakeSessions{
		user: "root", roles: map[string]ops.Auth{"": ops.Admin}})})
	mux := http.NewServeMux()
	mux.Handle("/", h)
	mux.Handle("GET /boom", recovered(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
		panic("kaboom")
	})))
	srv := httptest.NewServer(mux)
	t.Cleanup(srv.Close)

	// A panic becomes a clean 500 and a journal entry.
	resp, err := srv.Client().Get(srv.URL + "/boom")
	if err != nil {
		t.Fatalf("panic dropped the connection: %v", err)
	}
	resp.Body.Close()
	if resp.StatusCode != http.StatusInternalServerError {
		t.Fatalf("panic answered %d, want 500", resp.StatusCode)
	}
	after, _, _ := errlog.Default.Stats()
	if after <= before {
		t.Fatal("the panic left no journal entry")
	}
	found := false
	for _, e := range errlog.Default.Recent(0) {
		if e.Surface == "panic" && strings.Contains(e.Message, "kaboom") {
			found = true
		}
	}
	if !found {
		t.Fatalf("journal lacks the panic: %+v", errlog.Default.Recent(5))
	}

	// The admin fleet page renders it.
	req, _ := http.NewRequest("GET", srv.URL+"/fleet", nil)
	req.AddCookie(&http.Cookie{Name: sessionCookie, Value: "good"})
	fresp, err := srv.Client().Do(req)
	if err != nil {
		t.Fatal(err)
	}
	defer fresp.Body.Close()
	body, _ := io.ReadAll(fresp.Body)
	if !strings.Contains(string(body), "Server errors") || !strings.Contains(string(body), "kaboom") {
		t.Fatalf("/fleet does not show the journal:\n%s", body)
	}
}