Artifact a7b3540c3e1d0d816041573a1041c9cb3944efe8659cf3ed5fbb03e45eca2e27:
- File
internal/web/web_test.go
— part of check-in
[4a479e6ce2]
at
2026-08-18 01:15:43
on branch trunk
— api: make the repo set a concurrent registry; serve rejects positionals
Deps.Repos was a bare map, read by every op on the request path and written only at startup. Adding repositories at runtime — the point of the registry ticket — makes that a data race with no symptom until it corrupts one. api.Repos owns the lock; a *Repo is published complete or not at all, and Delete hands back the entry so the caller can close the index and stop the pipeline.
serve now refuses leftover arguments. Go's flag package stops at the first non-flag and returns the rest, so one unquoted shell variable silently dropped twelve --repo flags and quarry served the remaining eleven without a word. (user: dmestas, size: 24459) [annotate] [blame] [check-ins using]
package web import ( "bufio" "fmt" "io" "net/http/httptest" "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() if !strings.Contains(string(body), `data-init="@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 := "" for !strings.Contains(got, "datastar-patch-elements") || !strings.Contains(got, "fleet tip") { 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 }