package web
import (
"bufio"
"context"
"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/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)
}
if !strings.Contains(rec.Body.String(), `href="/r/test/timeline"`) {
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())
}
}
}
// TestLiveFleetSSE: the home page advertises /fleet.sse and a tick on
// any repo streams a #fleet morph with fresh repo.list data.
func TestLiveFleetSSE(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()
// The stream is owned by the filter input, which lives OUTSIDE the
// morphed region — a control inside it would be replaced on every
// repaint. It opens on the reader's own typing rather than at load,
// because a stream opened at load carries the filter that existed
// at load and repaints over a later one.
if !strings.Contains(string(body), `data-on:input__debounce.300ms="@get('/fleet.sse'`) {
t.Fatalf("home missing live hookup:\n%s", body)
}
resp, err := srv.Client().Get(srv.URL + "/fleet.sse")
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 := ""
// The listing shows the repository and its counts, not the last
// commit message — an aggregator page is not the place for one.
for !strings.Contains(got, "datastar-patch-elements") || !strings.Contains(got, "/r/test/timeline") {
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="fleet">`) {
t.Fatalf("patch not a #fleet morph:\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 stream must actually apply it.
req, _ := http.NewRequest("GET", srv.URL+"/fleet.sse?q=alpha", nil)
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
resp, err := srv.Client().Do(req.WithContext(ctx))
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
// The preamble and the first repaint arrive in separate writes, so
// read until the patch shows up rather than trusting one Read.
var frag string
rd := bufio.NewReader(resp.Body)
for !strings.Contains(frag, "</div>") {
line, err := rd.ReadString('\n')
if err != nil {
break
}
frag += line
}
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")
}
}