Artifact fb045da75a9d6c8ad845d4e425ce9453169c8f917b0c15ba4dc54114a5d13a9b:
- File
internal/web/web_test.go
— part of check-in
[263cff2b6b]
at
2026-08-15 18:33:21
on branch trunk
— phase 3: live timeline via Datastar SSE (Principle 3 enhancement)
events.Watch coalesces per-repo change ticks; composition root hands web a Notifier interface (planes stay sealed); timeline.sse re-renders the tl-body fragment through the op path and morphs #tl; vendored datastar v1.0.0-RC.7 (MIT, ledger row) is the page's only script; the no-JS page is unchanged. data-init, not the removed data-on-load. (user: dmestas, size: 13527) [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/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: 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).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).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).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).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).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).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).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.Fold(batch); err != nil { t.Fatal(err) } r := ops.NewRegistry() if err := api.RegisterAll(r, api.Deps{Version: "9.9.9-test", Repos: map[string]*api.Repo{"test": {Index: ix}}}); err != nil { t.Fatal(err) } rec := httptest.NewRecorder() Handler(r, 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) } if got := strings.Count(body, "<svg "); 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.Fold([]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: 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)) 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) } }