package web
import (
"errors"
"fmt"
"html"
"net/http"
"net/url"
"regexp"
"strings"
"testing"
"sourcecraft.dev/bigbes/sr-ht-dolt/beads"
"sourcecraft.dev/bigbes/sr-ht-dolt/browse"
"sourcecraft.dev/bigbes/sr-ht-dolt/core"
)
// The projection these tests drive — the fingerprint, the lanes, the ready rule,
// the detail/epic assembly — is tested in the beads package. What is left here
// is what only this package can answer: that beads.html renders what the
// projection produced, over the real router and template set.
// --- fixtures ----------------------------------------------------------------
// beadsTables is a schema fingerprint the beads view should accept: issues (with
// id + status) + dependencies both present.
func beadsTables() []browse.TableInfo {
return []browse.TableInfo{
{Name: "issues", Columns: []browse.ColumnInfo{
{Name: "id", PrimaryKey: true}, {Name: "status"},
}},
{Name: "dependencies", Columns: []browse.ColumnInfo{{Name: "id", PrimaryKey: true}}},
{Name: "labels"},
}
}
// beadsFixture wires a fakeSession whose per-table Rows model a small parade:
// - i-open : open, ready → Lined Up
// - i-prog : in_progress → Rolling
// - i-done : closed → Past Stand
// - i-blocked: open, blocked by i-open (a "blocks" dep to a non-closed target)
// and also carries is_blocked=1 → Stalled
func beadsFixture() *fakeSession {
issues := &browse.RowPage{
Columns: []string{"id", "title", "status", "priority", "issue_type", "assignee", "created_at", "closed_at", "close_reason", "is_blocked"},
Rows: [][]string{
{"i-open", "Ready to roll", "open", "1", "feature", "alice", "2024-01-01", "NULL", "NULL", "0"},
{"i-prog", "Under way", "in_progress", "0", "bug", "bob", "2024-01-02", "NULL", "NULL", "0"},
{"i-done", "Finished", "closed", "2", "chore", "carol", "2024-01-03", "2024-01-04", "Fixed in commit abc123", "0"},
{"i-blocked", "Waiting", "open", "1", "feature", "dave", "2024-01-04", "NULL", "NULL", "1"},
},
Total: 4,
}
// i-blocked depends on i-open (blocks, target open → keeps it Stalled).
deps := &browse.RowPage{
Columns: []string{"id", "issue_id", "depends_on_issue_id", "type"},
Rows: [][]string{
{"d1", "i-blocked", "i-open", "blocks"},
},
Total: 1,
}
labels := &browse.RowPage{
Columns: []string{"issue_id", "label"},
Rows: [][]string{
{"i-open", "backend"},
{"i-open", "urgent"},
},
Total: 2,
}
statuses := &browse.RowPage{
Columns: []string{"name", "category"},
Rows: [][]string{
{"open", "open"},
{"in_progress", "in_progress"},
{"closed", "closed"},
},
Total: 3,
}
comments := &browse.RowPage{
Columns: []string{"issue_id", "author", "text", "created_at"},
Rows: [][]string{
{"i-open", "alice", "first!", "2024-01-05"},
{"i-prog", "bob", "not this one", "2024-01-06"},
},
Total: 2,
}
// i-done's closure is recorded as a `closed` audit event carrying the reason
// (the only place the reason now surfaces — there is no standalone block).
events := &browse.RowPage{
Columns: []string{"id", "issue_id", "event_type", "actor", "old_value", "new_value", "comment", "created_at"},
Rows: [][]string{
{"e1", "i-done", "closed", "carol", "NULL", "Fixed in commit abc123", "NULL", "2024-01-03 12:00:00"},
},
Total: 1,
}
return &fakeSession{
branches: []browse.Branch{{Name: "main", Head: "abcdef1234567890"}},
tables: beadsTables(),
rowsByTable: map[string]*browse.RowPage{
"issues": issues,
"dependencies": deps,
"labels": labels,
"custom_statuses": statuses,
"comments": comments,
"events": events,
},
}
}
// beadsEpicFixture models an epic (i-epic) with three parent-child children —
// one closed, one open, one in-progress — plus a comment and audit events on the
// epic, so both the subtask rollup and the merged history reach the template.
func beadsEpicFixture() *fakeSession {
issues := &browse.RowPage{
Columns: []string{"id", "title", "status", "priority", "issue_type", "assignee", "created_at", "is_blocked"},
Rows: [][]string{
{"i-epic", "Big Epic", "open", "1", "epic", "", "2024-01-01", "0"},
{"i-c1", "Child one", "open", "2", "task", "alice", "2024-01-02", "0"},
{"i-c2", "Child two", "closed", "1", "task", "bob", "2024-01-03", "0"},
{"i-c3", "Child three", "in_progress", "0", "bug", "carol", "2024-01-04", "0"},
},
Total: 4,
}
deps := &browse.RowPage{
Columns: []string{"id", "issue_id", "depends_on_issue_id", "type", "created_at", "created_by"},
Rows: [][]string{
{"d1", "i-c1", "i-epic", "parent-child", "2024-01-01 09:00:00", "Eugene"},
{"d2", "i-c2", "i-epic", "parent-child", "2024-01-01 09:05:00", "Eugene"},
{"d3", "i-c3", "i-epic", "parent-child", "2024-01-01 09:10:00", "Eugene"},
},
Total: 3,
}
statuses := &browse.RowPage{
Columns: []string{"name", "category"},
Rows: [][]string{
{"open", "open"}, {"in_progress", "in_progress"}, {"closed", "closed"},
},
Total: 3,
}
comments := &browse.RowPage{
Columns: []string{"issue_id", "author", "text", "created_at"},
Rows: [][]string{
{"i-epic", "alice", "kickoff", "2024-01-05 09:00:00"},
{"i-c1", "bob", "unrelated", "2024-01-06 09:00:00"},
},
Total: 2,
}
events := &browse.RowPage{
Columns: []string{"id", "issue_id", "event_type", "actor", "old_value", "new_value", "comment", "created_at"},
Rows: [][]string{
{"e1", "i-epic", "created", "Eugene", "NULL", "NULL", "NULL", "2024-01-01 08:00:00"},
{"e2", "i-epic", "status_changed", "Eugene", `{"status":"open"}`, `{"status":"in_progress"}`, "NULL", "2024-01-02 10:00:00"},
{"e3", "i-epic", "updated", "Eugene", "NULL", `{"priority":0}`, "NULL", "2024-01-03 11:00:00"},
{"e4", "i-epic", "label_added", "Eugene", "NULL", "NULL", "Added label: milestone:m3", "2024-01-04 09:00:00"},
{"e9", "i-c1", "created", "Eugene", "NULL", "NULL", "NULL", "2024-01-02 08:00:00"},
},
Total: 5,
}
return &fakeSession{
branches: []browse.Branch{{Name: "main", Head: "abcdef1234567890"}},
tables: beadsTables(),
rowsByTable: map[string]*browse.RowPage{
"issues": issues,
"dependencies": deps,
"custom_statuses": statuses,
"comments": comments,
"events": events,
},
}
}
// beadsClippedFixture is a tracker of beads.Max+5 issues as a capped read hands
// it to the projection: the first beads.Max rows, and a Total saying the rest
// exists. Ids run i-0000 upwards in table order, so i-2000 and up are the tail
// no page here ever sees; beadsClippedTail names one.
//
// The rows are built whole and then clipped, rather than a short page given a
// large Total by hand: the tail id has to be *genuinely* absent from what the
// projection reads, or a test asserts the wording while never producing the
// situation the wording is about. The clip is applied here because this
// package's fake session answers every read whole — the store applies it at
// limit, and beads/truncation_test.go drives the same fixtures through a seam
// that does.
func beadsClippedFixture() *fakeSession {
statuses := &browse.RowPage{
Columns: []string{"name", "category"},
Rows: [][]string{
{"open", "open"}, {"in_progress", "in_progress"}, {"closed", "closed"},
},
Total: 3,
}
return &fakeSession{
branches: []browse.Branch{{Name: "main", Head: "abcdef1234567890"}},
tables: beadsTables(),
rowsByTable: map[string]*browse.RowPage{
"issues": clipPage(manyIssues(beads.Max+5), beads.Max),
"custom_statuses": statuses,
"dependencies": {
Columns: []string{"id", "issue_id", "depends_on_issue_id", "type"},
Rows: [][]string{{"d1", "i-0007", "i-0001", "blocks"}},
Total: 1,
},
},
}
}
// manyIssues builds n issue rows, ids i-0000 upwards, cycling the three status
// categories so every lane is populated.
func manyIssues(n int) *browse.RowPage {
statuses := []string{"open", "in_progress", "closed"}
rows := make([][]string, 0, n)
for i := range n {
rows = append(rows, []string{
fmt.Sprintf("i-%04d", i),
fmt.Sprintf("Issue %d", i),
statuses[i%len(statuses)],
"1", "task", "alice", "2024-01-01", "0",
})
}
return &browse.RowPage{
Columns: []string{"id", "title", "status", "priority", "issue_type", "assignee", "created_at", "is_blocked"},
Rows: rows,
Total: n,
}
}
// clipPage is the store's own answer to a read of limit rows: the first limit of
// them, and the table's true row count beside them.
func clipPage(p *browse.RowPage, limit int) *browse.RowPage {
rows := p.Rows
if len(rows) > limit {
rows = rows[:limit]
}
return &browse.RowPage{Columns: p.Columns, Rows: rows, Total: len(p.Rows)}
}
// beadsClippedTail is an id of beadsClippedFixture that exists in the tracker and
// sits past the rows any read of it returns.
const beadsClippedTail = "i-2003"
// beadsClippedLabelsFixture is beadsFixture with one table past the cap: labels.
// Four issues, every one of them read, every lane and count exactly as the
// complete fixture renders them — and beads.Max+3 label rows of which the read
// returns the first beads.Max.
//
// That invariance is the case itself. A clipped labels table takes the pills off
// the cards and the options out of the label filter while leaving "N issues"
// true, so a count-shaped flag has nothing to say about it and the board is
// degraded in silence.
func beadsClippedLabelsFixture() *fakeSession {
sess := beadsFixture()
sess.rowsByTable["labels"] = clipPage(manyLabels(beads.Max+3), beads.Max)
return sess
}
// beadsClippedDepsFixture is beadsFixture with the dependencies table past the
// cap and nothing else: the issue set is whole, so the board's count line has
// nothing to report and the dependencies entry carries the whole message.
func beadsClippedDepsFixture() *fakeSession {
sess := beadsFixture()
sess.rowsByTable["dependencies"] = clipPage(manyDeps(beads.Max+1), beads.Max)
return sess
}
// manyLabels builds n label rows on i-open, named label-0000 upwards.
func manyLabels(n int) *browse.RowPage {
rows := make([][]string, 0, n)
for i := range n {
rows = append(rows, []string{"i-open", fmt.Sprintf("label-%04d", i)})
}
return &browse.RowPage{Columns: []string{"issue_id", "label"}, Rows: rows, Total: n}
}
// manyDeps builds n "blocks" edges from i-blocked to i-open.
func manyDeps(n int) *browse.RowPage {
rows := make([][]string, 0, n)
for i := range n {
rows = append(rows, []string{fmt.Sprintf("d%d", i), "i-blocked", "i-open", "blocks"})
}
return &browse.RowPage{
Columns: []string{"id", "issue_id", "depends_on_issue_id", "type"},
Rows: rows,
Total: n,
}
}
// --- end to end --------------------------------------------------------------
func TestBeadsHandleViewBoard(t *testing.T) {
h := newHarness(t)
h.store.add(&core.Repo{Name: "db", OwnerID: 1, OwnerName: "alice", Path: "/d", Visibility: core.VisibilityPublic})
h.browse.sess = beadsFixture()
setViews(t, h, &beadsView{})
rec := h.do("GET", "/~alice/db/view/beads", nil, nil)
if rec.Code != http.StatusOK {
t.Fatalf("board: got %d, want 200; body=%s", rec.Code, rec.Body.String())
}
body := rec.Body.String()
for _, want := range []string{"Rolling", "Lined Up", "Stalled", "Past Stand", "Ready to roll", "beads-summary"} {
if !strings.Contains(body, want) {
t.Errorf("board body missing %q", want)
}
}
// The Tables tab must remain reachable from the view.
if !strings.Contains(body, "/~alice/db/tree/") {
t.Errorf("board missing Tables tab link; body=%s", body)
}
// The filter bar renders with option lists drawn from the data, plus the
// Ready-only toggle; i-open is ready, so a ready dot renders on the board.
for _, want := range []string{`class="beads-filter"`, "All types", ">feature<", "All priorities", "Ready only", "ready-dot"} {
if !strings.Contains(body, want) {
t.Errorf("board missing control %q", want)
}
}
}
func TestBeadsDepTreeRender(t *testing.T) {
h := newHarness(t)
h.store.add(&core.Repo{Name: "db", OwnerID: 1, OwnerName: "alice", Path: "/d", Visibility: core.VisibilityPublic})
h.browse.sess = &fakeSession{
branches: []browse.Branch{{Name: "main", Head: "abcdef1234567890"}},
tables: beadsTables(),
rowsByTable: map[string]*browse.RowPage{
"issues": {Columns: []string{"id", "title", "status"},
Rows: [][]string{{"a", "Aye", "open"}, {"b", "Bee", "open"}, {"c", "Cee", "open"}}, Total: 3},
"dependencies": {Columns: []string{"id", "issue_id", "depends_on_issue_id", "type"},
Rows: [][]string{{"d1", "a", "b", "blocks"}, {"d2", "b", "c", "blocks"}}, Total: 2},
},
}
setViews(t, h, &beadsView{})
rec := h.do("GET", "/~alice/db/view/beads?issue=a", nil, nil)
if rec.Code != http.StatusOK {
t.Fatalf("detail: got %d; body=%s", rec.Code, rec.Body.String())
}
body := rec.Body.String()
// The transitive chain section appears with the depth-1 node c and an indent.
for _, want := range []string{"Prerequisite chain", "dep-tree", "--depth: 1", ">c<"} {
if !strings.Contains(body, want) {
t.Errorf("dep-tree render missing %q; body=%s", want, body)
}
}
}
func TestBeadsBoardFilterRender(t *testing.T) {
h := newHarness(t)
h.store.add(&core.Repo{Name: "db", OwnerID: 1, OwnerName: "alice", Path: "/d", Visibility: core.VisibilityPublic})
h.browse.sess = beadsFixture()
setViews(t, h, &beadsView{})
rec := h.do("GET", "/~alice/db/view/beads?type=feature", nil, nil)
if rec.Code != http.StatusOK {
t.Fatalf("filtered board: got %d; body=%s", rec.Code, rec.Body.String())
}
body := rec.Body.String()
// The active type is preselected and a Clear link appears.
if !strings.Contains(body, `value="feature" selected`) {
t.Errorf("type filter not preselected; body=%s", body)
}
if !strings.Contains(body, "beads-filter-clear") {
t.Errorf("Clear link missing when a filter is active")
}
// Only feature issues on the board; the bug (i-prog) is filtered out.
if !strings.Contains(body, "i-open") || strings.Contains(body, "i-prog") {
t.Errorf("filtered board should show features only; body=%s", body)
}
}
func TestBeadsHandleViewDetail(t *testing.T) {
h := newHarness(t)
h.store.add(&core.Repo{Name: "db", OwnerID: 1, OwnerName: "alice", Path: "/d", Visibility: core.VisibilityPublic})
h.browse.sess = beadsFixture()
setViews(t, h, &beadsView{})
rec := h.do("GET", "/~alice/db/view/beads?issue=i-blocked", nil, nil)
if rec.Code != http.StatusOK {
t.Fatalf("detail: got %d, want 200; body=%s", rec.Code, rec.Body.String())
}
body := rec.Body.String()
if !strings.Contains(body, "Waiting") {
t.Errorf("detail missing issue title; body=%s", body)
}
if !strings.Contains(body, "Depends on") || !strings.Contains(body, "i-open") {
t.Errorf("detail missing dependency edge; body=%s", body)
}
if !strings.Contains(body, "Back to the parade") {
t.Errorf("detail missing back link; body=%s", body)
}
}
// A closed issue's detail pane must surface its close reason (and the closed
// timestamp), so the resolution recorded by `bd close -r` is not lost.
// TestBeadsDetailShowsCloseReason: the reason appears twice by design — once in
// the Comments tab (which has no closed event) as a Close reason block, and once
// in the History tab as the humanized `closed` event.
func TestBeadsDetailShowsCloseReason(t *testing.T) {
h := newHarness(t)
h.store.add(&core.Repo{Name: "db", OwnerID: 1, OwnerName: "alice", Path: "/d", Visibility: core.VisibilityPublic})
h.browse.sess = beadsFixture()
setViews(t, h, &beadsView{})
rec := h.do("GET", "/~alice/db/view/beads?issue=i-done", nil, nil)
if rec.Code != http.StatusOK {
t.Fatalf("detail: got %d, want 200; body=%s", rec.Code, rec.Body.String())
}
body := rec.Body.String()
// Comments-tab block.
if !strings.Contains(body, "<h4>Close reason</h4>") {
t.Errorf("Comments tab should show a Close reason block; body=%s", body)
}
// History-tab closed event.
if !strings.Contains(body, "closed the issue") {
t.Errorf("History should carry the closed event; body=%s", body)
}
// The reason text itself is present (both places).
if !strings.Contains(body, "Fixed in commit abc123") {
t.Errorf("close reason text missing; body=%s", body)
}
}
// --- a read that was clipped -------------------------------------------------
// The projection reads at most beads.Max rows of a table. A detail pane built
// from a clipped read says so, quietly and in the board banner's own idiom: the
// edges, the thread and the history on it may be short.
func TestBeadsDetailReportsAClippedRead(t *testing.T) {
h := newHarness(t)
h.store.add(&core.Repo{Name: "db", OwnerID: 1, OwnerName: "alice", Path: "/d", Visibility: core.VisibilityPublic})
h.browse.sess = beadsClippedFixture()
setViews(t, h, &beadsView{})
rec := h.do("GET", "/~alice/db/view/beads?issue=i-0007", nil, nil)
if rec.Code != http.StatusOK {
t.Fatalf("detail: got %d, want 200; body=%s", rec.Code, rec.Body.String())
}
body := rec.Body.String()
if !strings.Contains(body, "This read was clipped") {
t.Errorf("clipped detail pane says nothing about the clip; body=%s", body)
}
// The number is the tracker's true size, which is what makes the line
// checkable rather than a bare warning.
if !strings.Contains(body, "The tracker holds 2005 issues") {
t.Errorf("clipped detail pane does not name the tracker's true size; body=%s", body)
}
if !strings.Contains(body, "Issue 7") {
t.Errorf("the issue itself still renders; body=%s", body)
}
}
// And a complete read makes no such claim: the line is a fact about the read,
// not decoration on every detail pane.
func TestBeadsDetailOnACompleteReadReportsNoClip(t *testing.T) {
h := newHarness(t)
h.store.add(&core.Repo{Name: "db", OwnerID: 1, OwnerName: "alice", Path: "/d", Visibility: core.VisibilityPublic})
h.browse.sess = beadsFixture()
setViews(t, h, &beadsView{})
rec := h.do("GET", "/~alice/db/view/beads?issue=i-open", nil, nil)
if rec.Code != http.StatusOK {
t.Fatalf("detail: got %d, want 200; body=%s", rec.Code, rec.Body.String())
}
if body := rec.Body.String(); strings.Contains(body, "This read was clipped") {
t.Errorf("nothing was clipped, so the pane must not say it was; body=%s", body)
}
}
// An id past the cap is not an absence: the tracker carries i-2003, the page did
// not read that far, and saying "not found" would be the page claiming something
// it cannot see.
func TestBeadsDetailMissPastTheCapIsNotAbsence(t *testing.T) {
h := newHarness(t)
h.store.add(&core.Repo{Name: "db", OwnerID: 1, OwnerName: "alice", Path: "/d", Visibility: core.VisibilityPublic})
sess := beadsClippedFixture()
h.browse.sess = sess
setViews(t, h, &beadsView{})
// The id exists in the tracker and not in the rows a read of it returns.
if got := sess.rowsByTable["issues"].Rows; len(got) != beads.Max {
t.Fatalf("the fixture is meant to hand over %d rows, got %d", beads.Max, len(got))
}
if strings.Contains(rowsText(sess.rowsByTable["issues"]), beadsClippedTail) {
t.Fatalf("%s is supposed to be past the rows read", beadsClippedTail)
}
rec := h.do("GET", "/~alice/db/view/beads?issue="+beadsClippedTail, nil, nil)
if rec.Code != http.StatusOK {
t.Fatalf("detail: got %d, want 200; body=%s", rec.Code, rec.Body.String())
}
body := rec.Body.String()
if !strings.Contains(body, "Not among the issues read") {
t.Errorf("a miss past the cap must say the id was not read; body=%s", body)
}
if !strings.Contains(body, "The tracker holds 2005 issues") {
t.Errorf("and name what it did not read all of; body=%s", body)
}
if strings.Contains(body, "Issue not found.") {
t.Errorf("this read cannot support \"not found\"; body=%s", body)
}
}
// The other half of the split: nothing was clipped, so the id genuinely is not
// there and the page says the plain thing it always did.
func TestBeadsDetailMissOnACompleteReadIsAbsence(t *testing.T) {
h := newHarness(t)
h.store.add(&core.Repo{Name: "db", OwnerID: 1, OwnerName: "alice", Path: "/d", Visibility: core.VisibilityPublic})
h.browse.sess = beadsFixture()
setViews(t, h, &beadsView{})
rec := h.do("GET", "/~alice/db/view/beads?issue=i-nope", nil, nil)
if rec.Code != http.StatusOK {
t.Fatalf("detail: got %d, want 200; body=%s", rec.Code, rec.Body.String())
}
body := rec.Body.String()
if !strings.Contains(body, "Issue not found.") {
t.Errorf("a complete read that lacks the id is an absence; body=%s", body)
}
if strings.Contains(body, "Not among the issues read") {
t.Errorf("and it must not be dressed up as a clip; body=%s", body)
}
}
// The board's own banner is untouched by all of the above: same sentence, same
// two numbers, in the branch it has always lived in.
func TestBeadsBoardTruncationBannerIsUnchanged(t *testing.T) {
h := newHarness(t)
h.store.add(&core.Repo{Name: "db", OwnerID: 1, OwnerName: "alice", Path: "/d", Visibility: core.VisibilityPublic})
h.browse.sess = beadsClippedFixture()
setViews(t, h, &beadsView{})
rec := h.do("GET", "/~alice/db/view/beads", nil, nil)
if rec.Code != http.StatusOK {
t.Fatalf("board: got %d, want 200; body=%s", rec.Code, rec.Body.String())
}
body := rec.Body.String()
if !strings.Contains(body, "Showing the first 2000 of 2005 issues.") {
t.Errorf("the board banner changed; body=%s", body)
}
if strings.Contains(body, "This read was clipped") {
t.Errorf("the detail pane's line is the detail pane's; body=%s", body)
}
}
// The gap this closes: labels is a table the board draws from — every card's
// pills and the whole label filter — and a clipped read of it moves no count, so
// the board used to render a degraded page and say nothing at all.
func TestBeadsBoardReportsAClippedLabelsRead(t *testing.T) {
h := newHarness(t)
h.store.add(&core.Repo{Name: "db", OwnerID: 1, OwnerName: "alice", Path: "/d", Visibility: core.VisibilityPublic})
h.browse.sess = beadsClippedLabelsFixture()
setViews(t, h, &beadsView{})
rec := h.do("GET", "/~alice/db/view/beads", nil, nil)
if rec.Code != http.StatusOK {
t.Fatalf("board: got %d, want 200; body=%s", rec.Code, rec.Body.String())
}
body := rec.Body.String()
// The table, both numbers, and what the board lost by them — the numbers are
// what make the line checkable rather than a bare warning.
if !strings.Contains(body, "<code>labels</code> table was clipped at 2000 of 2003 rows") {
t.Errorf("the board says nothing about a clipped labels read; body=%s", body)
}
if !strings.Contains(body, "the label filter offers only the labels that were read") {
t.Errorf("the board names the table without saying what it cost; body=%s", body)
}
// And it must not borrow the count line: every issue was read, so "the first
// N of M issues" would be false.
if strings.Contains(body, "Showing the first") {
t.Errorf("the issue set is whole; the count line has nothing to report; body=%s", body)
}
// The degradation itself, on the page: the labels past the cap reach neither
// a card nor the filter's options.
if strings.Contains(body, "label-2002") {
t.Errorf("a label past the cap cannot be on the page; body=%s", body)
}
}
// custom_statuses decides which lane every card stands in, so it is the board's
// too.
func TestBeadsBoardReportsAClippedStatusesRead(t *testing.T) {
h := newHarness(t)
h.store.add(&core.Repo{Name: "db", OwnerID: 1, OwnerName: "alice", Path: "/d", Visibility: core.VisibilityPublic})
sess := beadsFixture()
statuses := sess.rowsByTable["custom_statuses"]
rows := statuses.Rows
for i := len(rows); i < beads.Max+1; i++ {
rows = append(rows, []string{fmt.Sprintf("status-%04d", i), "open"})
}
sess.rowsByTable["custom_statuses"] = clipPage(
&browse.RowPage{Columns: statuses.Columns, Rows: rows, Total: len(rows)}, beads.Max)
h.browse.sess = sess
setViews(t, h, &beadsView{})
rec := h.do("GET", "/~alice/db/view/beads", nil, nil)
if rec.Code != http.StatusOK {
t.Fatalf("board: got %d, want 200; body=%s", rec.Code, rec.Body.String())
}
body := rec.Body.String()
if !strings.Contains(body, "<code>custom_statuses</code> table was clipped at 2000 of 2001 rows") {
t.Errorf("the board says nothing about a clipped custom_statuses read; body=%s", body)
}
if !strings.Contains(body, "a card may be in the wrong lane") {
t.Errorf("the board names the table without saying what it cost; body=%s", body)
}
}
// dependencies has always flipped the flag, and the flag has always printed the
// count line — which says "the first 4 of 4 issues" when the issue set is whole.
// The table now says the thing that is actually true of this read.
func TestBeadsBoardReportsAClippedDependenciesRead(t *testing.T) {
h := newHarness(t)
h.store.add(&core.Repo{Name: "db", OwnerID: 1, OwnerName: "alice", Path: "/d", Visibility: core.VisibilityPublic})
h.browse.sess = beadsClippedDepsFixture()
setViews(t, h, &beadsView{})
rec := h.do("GET", "/~alice/db/view/beads", nil, nil)
if rec.Code != http.StatusOK {
t.Fatalf("board: got %d, want 200; body=%s", rec.Code, rec.Body.String())
}
body := rec.Body.String()
if !strings.Contains(body, "<code>dependencies</code> table was clipped at 2000 of 2001 rows") {
t.Errorf("the board says nothing about a clipped dependencies read; body=%s", body)
}
if strings.Contains(body, "Showing the first") {
t.Errorf("every issue was read, so the count line must stay silent; body=%s", body)
}
}
// And a board over complete reads stays quiet: the lines are facts about a read,
// not decoration on every board.
func TestBeadsBoardOnACompleteReadReportsNoClip(t *testing.T) {
h := newHarness(t)
h.store.add(&core.Repo{Name: "db", OwnerID: 1, OwnerName: "alice", Path: "/d", Visibility: core.VisibilityPublic})
h.browse.sess = beadsFixture()
setViews(t, h, &beadsView{})
rec := h.do("GET", "/~alice/db/view/beads", nil, nil)
if rec.Code != http.StatusOK {
t.Fatalf("board: got %d, want 200; body=%s", rec.Code, rec.Body.String())
}
body := rec.Body.String()
for _, unwanted := range []string{"was clipped at", "Showing the first"} {
if strings.Contains(body, unwanted) {
t.Errorf("nothing was clipped, so the board must not say %q; body=%s", unwanted, body)
}
}
}
// rowsText is every cell of a page as one string, for asserting that an id is
// nowhere in the rows a fixture hands over.
func rowsText(p *browse.RowPage) string {
var b strings.Builder
for _, r := range p.Rows {
for _, c := range r {
b.WriteString(c)
b.WriteByte(' ')
}
}
return b.String()
}
// --- the stream layout -------------------------------------------------------
func TestBeadsStreamRender(t *testing.T) {
h := newHarness(t)
h.store.add(&core.Repo{Name: "db", OwnerID: 1, OwnerName: "alice", Path: "/d", Visibility: core.VisibilityPublic})
h.browse.sess = beadsFixture()
setViews(t, h, &beadsView{})
rec := h.do("GET", "/~alice/db/view/beads?layout=stream", nil, nil)
if rec.Code != http.StatusOK {
t.Fatalf("stream: got %d, want 200; body=%s", rec.Code, rec.Body.String())
}
body := rec.Body.String()
// One column of sections, the board's lanes gone, and the same cards in it.
for _, want := range []string{
`class="beads-stream"`, `class="stream-head"`, `class="stream-body"`,
"Rolling", "Lined Up", "Stalled", "Past Stand",
"Ready to roll", "ready-dot", `class="bead-row"`,
} {
if !strings.Contains(body, want) {
t.Errorf("stream body missing %q", want)
}
}
if strings.Contains(body, `class="beads-lanes"`) {
t.Errorf("stream body should not render the board's lane row; body=%s", body)
}
// Past Stand is the one collapsed section: a <details> with no open attribute.
if !strings.Contains(body, `<details class="beads-section past-stand">`) {
t.Errorf("Past Stand should render as a collapsed <details>; body=%s", body)
}
if strings.Contains(body, "<details open") {
t.Errorf("no section should render as an open <details>; body=%s", body)
}
// The filter form must carry the layout, or filtering would drop back to the
// board; the toggle marks Stream as current and links to the board.
if !strings.Contains(body, `<input type="hidden" name="layout" value="stream">`) {
t.Errorf("stream form missing the layout carrier; body=%s", body)
}
if !strings.Contains(body, `<span class="current" aria-current="page">Stream</span>`) {
t.Errorf("toggle should mark Stream as current; body=%s", body)
}
if !strings.Contains(body, `href="/~alice/db/view/beads">Board</a>`) {
t.Errorf("toggle should link back to an unfiltered board; body=%s", body)
}
// No JavaScript is added by this layout.
if strings.Contains(body, "<script") {
t.Errorf("the stream layout must add no script; body=%s", body)
}
}
// The board is what an unknown ?layout= renders — a stale link answers with the
// default page, not an error.
func TestBeadsUnknownLayoutRendersBoard(t *testing.T) {
h := newHarness(t)
h.store.add(&core.Repo{Name: "db", OwnerID: 1, OwnerName: "alice", Path: "/d", Visibility: core.VisibilityPublic})
h.browse.sess = beadsFixture()
setViews(t, h, &beadsView{})
rec := h.do("GET", "/~alice/db/view/beads?layout=parade", nil, nil)
if rec.Code != http.StatusOK {
t.Fatalf("unknown layout: got %d, want 200; body=%s", rec.Code, rec.Body.String())
}
body := rec.Body.String()
if !strings.Contains(body, `class="beads-lanes"`) {
t.Errorf("an unknown layout should render the board; body=%s", body)
}
if strings.Contains(body, `class="beads-stream"`) {
t.Errorf("an unknown layout rendered the stream; body=%s", body)
}
// The toggle offers the stream, carrying the unknown value along untouched —
// the query is rebuilt with one key replaced, not rewritten.
if !strings.Contains(body, `href="/~alice/db/view/beads?layout=stream"`) {
t.Errorf("board toggle should link to the stream; body=%s", body)
}
}
// Switching layouts must keep the page pointed at the same issues: every active
// filter survives the toggle, in both directions.
func TestBeadsLayoutToggleKeepsFilters(t *testing.T) {
h := newHarness(t)
h.store.add(&core.Repo{Name: "db", OwnerID: 1, OwnerName: "alice", Path: "/d", Visibility: core.VisibilityPublic})
h.browse.sess = beadsFixture()
setViews(t, h, &beadsView{})
// Board → Stream: the toggle adds layout=stream to everything already set.
rec := h.do("GET", "/~alice/db/view/beads?q=ready&type=feature&priority=1&assignee=alice&label=urgent&ready=1&ref=main", nil, nil)
if rec.Code != http.StatusOK {
t.Fatalf("filtered board: got %d; body=%s", rec.Code, rec.Body.String())
}
wantStream := `href="/~alice/db/view/beads?assignee=alice&label=urgent&layout=stream&priority=1&q=ready&ready=1&ref=main&type=feature"`
if !strings.Contains(rec.Body.String(), wantStream) {
t.Errorf("stream link should carry every filter, want %s; body=%s", wantStream, rec.Body.String())
}
// Stream → Board: the same query with layout dropped, nothing else touched.
rec = h.do("GET", "/~alice/db/view/beads?q=ready&type=feature&priority=1&assignee=alice&label=urgent&ready=1&ref=main&layout=stream", nil, nil)
if rec.Code != http.StatusOK {
t.Fatalf("filtered stream: got %d; body=%s", rec.Code, rec.Body.String())
}
body := rec.Body.String()
wantBoard := `href="/~alice/db/view/beads?assignee=alice&label=urgent&priority=1&q=ready&ready=1&ref=main&type=feature"`
if !strings.Contains(body, wantBoard) {
t.Errorf("board link should carry every filter and drop the layout, want %s; body=%s", wantBoard, body)
}
// The filters are still applied and still sticky in the form.
if !strings.Contains(body, `value="feature" selected`) || !strings.Contains(body, `name="q" value="ready"`) {
t.Errorf("filtered stream lost its sticky form state; body=%s", body)
}
// Clearing filters keeps the layout: it is how the page is read, not a filter.
if !strings.Contains(body, `href="/~alice/db/view/beads?ref=main&layout=stream">Clear</a>`) {
t.Errorf("Clear should keep ref and layout; body=%s", body)
}
}
// ?issue= wins over any layout: a link to a card is a link to a card.
func TestBeadsStreamIssueDetailWins(t *testing.T) {
h := newHarness(t)
h.store.add(&core.Repo{Name: "db", OwnerID: 1, OwnerName: "alice", Path: "/d", Visibility: core.VisibilityPublic})
h.browse.sess = beadsFixture()
setViews(t, h, &beadsView{})
rec := h.do("GET", "/~alice/db/view/beads?layout=stream&issue=i-blocked", nil, nil)
if rec.Code != http.StatusOK {
t.Fatalf("detail under layout=stream: got %d; body=%s", rec.Code, rec.Body.String())
}
body := rec.Body.String()
if !strings.Contains(body, "Waiting") || !strings.Contains(body, "Back to the parade") {
t.Errorf("layout=stream should not disturb the detail pane; body=%s", body)
}
if strings.Contains(body, `class="beads-stream"`) {
t.Errorf("the detail pane rendered a stream; body=%s", body)
}
}
// --- copy-ready bd commands ---------------------------------------------------
// The commands offered follow the issue's status, and nothing is offered that bd
// would refuse: an open issue can be claimed or closed, an in-progress one closed
// or put back to open, a closed one only reopened.
func TestBeadsDetailCommandsFollowStatus(t *testing.T) {
for _, tc := range []struct {
name string
issue string
want []string
notWant []string
}{
{
name: "open",
issue: "i-open",
want: []string{"bd update i-open --claim", "bd close i-open"},
notWant: []string{"bd reopen", "--status=open"},
},
{
name: "in progress",
issue: "i-prog",
want: []string{"bd close i-prog", "bd update i-prog --status=open"},
notWant: []string{"--claim", "bd reopen"},
},
{
name: "closed",
issue: "i-done",
want: []string{"bd reopen i-done"},
notWant: []string{"--claim", "bd close i-done", "--status=open"},
},
} {
t.Run(tc.name, func(t *testing.T) {
h := newHarness(t)
h.store.add(&core.Repo{Name: "db", OwnerID: 1, OwnerName: "alice", Path: "/d", Visibility: core.VisibilityPublic})
h.browse.sess = beadsFixture()
setViews(t, h, &beadsView{})
rec := h.do("GET", "/~alice/db/view/beads?issue="+tc.issue, nil, nil)
if rec.Code != http.StatusOK {
t.Fatalf("detail: got %d, want 200; body=%s", rec.Code, rec.Body.String())
}
body := rec.Body.String()
if !strings.Contains(body, `class="bead-commands"`) {
t.Fatalf("detail pane missing the command block; body=%s", body)
}
for _, want := range tc.want {
if !strings.Contains(body, want) {
t.Errorf("command block missing %q; body=%s", want, body)
}
}
for _, notWant := range tc.notWant {
if strings.Contains(body, notWant) {
t.Errorf("command block offers %q for a %s issue; body=%s", notWant, tc.name, body)
}
}
// One click selects one command, and nothing here needs a script.
if !strings.Contains(body, "user-select: all") {
t.Errorf("command lines are not user-select: all; body=%s", body)
}
if strings.Contains(body, "<script") {
t.Errorf("the command block must add no script; body=%s", body)
}
})
}
}
// The command names the issue and nothing about paths: this service does not
// know where the tracker is checked out.
func TestBeadsDetailCommandsNameNoPath(t *testing.T) {
h := newHarness(t)
h.store.add(&core.Repo{Name: "db", OwnerID: 1, OwnerName: "alice", Path: "/d", Visibility: core.VisibilityPublic})
h.browse.sess = beadsFixture()
setViews(t, h, &beadsView{})
rec := h.do("GET", "/~alice/db/view/beads?issue=i-open", nil, nil)
if rec.Code != http.StatusOK {
t.Fatalf("detail: got %d, want 200; body=%s", rec.Code, rec.Body.String())
}
body := rec.Body.String()
for _, notWant := range []string{"bd -C", "--dir", "cd ~", "/var/lib/dolt"} {
if strings.Contains(body, notWant) {
t.Errorf("the command should carry no path, found %q; body=%s", notWant, body)
}
}
}
// The id goes into the command exactly as stored, escaped by the template: an id
// carrying markup must reach the page as text, not as HTML.
func TestBeadsDetailCommandEscapesIssueID(t *testing.T) {
const id = `i-a&b<x>"'`
h := newHarness(t)
h.store.add(&core.Repo{Name: "db", OwnerID: 1, OwnerName: "alice", Path: "/d", Visibility: core.VisibilityPublic})
h.browse.sess = &fakeSession{
branches: []browse.Branch{{Name: "main", Head: "abcdef1234567890"}},
tables: beadsTables(),
rowsByTable: map[string]*browse.RowPage{
"issues": {Columns: []string{"id", "title", "status"},
Rows: [][]string{{id, "Odd id", "open"}}, Total: 1},
"dependencies": {Columns: []string{"id", "issue_id", "depends_on_issue_id", "type"}, Total: 0},
},
}
setViews(t, h, &beadsView{})
rec := h.do("GET", "/~alice/db/view/beads?issue="+url.QueryEscape(id), nil, nil)
if rec.Code != http.StatusOK {
t.Fatalf("detail: got %d, want 200; body=%s", rec.Code, rec.Body.String())
}
body := rec.Body.String()
want := `bd update i-a&b<x>"' --claim`
if !strings.Contains(body, want) {
t.Errorf("command missing the escaped id %q; body=%s", want, body)
}
if strings.Contains(body, "bd update "+id) {
t.Errorf("the id reached the page unescaped; body=%s", body)
}
// Escaped, the command still says exactly the stored id.
if got := html.UnescapeString(want); got != "bd update "+id+" --claim" {
t.Errorf("escaped command decodes to %q, want the stored id verbatim", got)
}
}
// The epic pane is the detail pane with a rollup, so it carries the block too.
func TestBeadsEpicCommandsRender(t *testing.T) {
h := newHarness(t)
h.store.add(&core.Repo{Name: "db", OwnerID: 1, OwnerName: "alice", Path: "/d", Visibility: core.VisibilityPublic})
h.browse.sess = beadsEpicFixture()
setViews(t, h, &beadsView{})
rec := h.do("GET", "/~alice/db/view/beads?issue=i-epic", nil, nil)
if rec.Code != http.StatusOK {
t.Fatalf("epic: got %d, want 200; body=%s", rec.Code, rec.Body.String())
}
body := rec.Body.String()
for _, want := range []string{`class="bead-commands"`, "bd update i-epic --claim", "bd close i-epic"} {
if !strings.Contains(body, want) {
t.Errorf("epic pane missing %q; body=%s", want, body)
}
}
}
// The block belongs to one issue, so the board must not carry it.
func TestBeadsBoardHasNoCommandBlock(t *testing.T) {
h := newHarness(t)
h.store.add(&core.Repo{Name: "db", OwnerID: 1, OwnerName: "alice", Path: "/d", Visibility: core.VisibilityPublic})
h.browse.sess = beadsFixture()
setViews(t, h, &beadsView{})
rec := h.do("GET", "/~alice/db/view/beads", nil, nil)
if rec.Code != http.StatusOK {
t.Fatalf("board: got %d, want 200; body=%s", rec.Code, rec.Body.String())
}
body := rec.Body.String()
for _, notWant := range []string{`class="bead-commands"`, "bd update ", "bd close ", "bd reopen "} {
if strings.Contains(body, notWant) {
t.Errorf("the board rendered the command block (%q); body=%s", notWant, body)
}
}
}
// --- cross-database issue links ------------------------------------------------
// The database whose detail pane these tests render: prefix "alpha", one issue
// whose four bodies, one comment and history name issues in other databases —
// one visible, one this caller may not browse, one belonging to nobody — plus a
// script tag stored in the description.
func beadsLinkFixture() *fakeSession {
sess := linkTracker("h-alpha", "alpha")
sess.rowsByTable["issues"] = &browse.RowPage{
Columns: []string{"id", "title", "status", "priority", "issue_type", "assignee",
"created_at", "description", "design", "acceptance_criteria", "notes"},
Rows: [][]string{{
"alpha-1", "The one with references", "open", "1", "task", "alice", "2024-01-01",
"blocked by beta-46c.2, and by alpha-2.\nnosuch-9z is nobody's.\n<script>alert('x')</script>",
"the shape is beta-nex's",
"beta-46c.2 is closed",
"secret-9a1 stays a secret",
}},
Total: 1,
}
sess.rowsByTable["comments"] = &browse.RowPage{
Columns: []string{"issue_id", "author", "text", "created_at"},
Rows: [][]string{
{"alpha-1", "alice", "landing with beta-46c.2", "2024-01-05 09:00:00"},
},
Total: 1,
}
sess.rowsByTable["events"] = &browse.RowPage{
Columns: []string{"id", "issue_id", "event_type", "actor", "old_value", "new_value", "comment", "created_at"},
Rows: [][]string{
{"e1", "alpha-1", "updated", "alice", "NULL", `{"design":"the shape is beta-nex's"}`, "NULL", "2024-01-06 09:00:00"},
},
Total: 1,
}
return sess
}
// linkTracker is a beads database whose config names an issue prefix, with no
// issues in it. It stands for the other databases on the instance: what the
// index reads out of one is the prefix and nothing else.
func linkTracker(head, prefix string) *fakeSession {
return &fakeSession{
branches: []browse.Branch{{Name: "main", Head: head}},
tables: beadsTables(),
rowsByTable: map[string]*browse.RowPage{
"issues": {Columns: []string{"id", "title", "status"}, Total: 0},
"dependencies": {Columns: []string{"id", "issue_id", "depends_on_issue_id", "type"},
Total: 0},
"config": {
Columns: []string{"key", "value"},
Rows: [][]string{
{"compact_tier2_days", "30"},
{"issue_prefix", prefix},
},
Total: 2,
},
},
}
}
// linkHarness is the instance these tests read: the current database
// (alice/alpha), a second public one whose prefix is "beta", and a PRIVATE one
// whose prefix is "secret" and which the caller may not browse.
func linkHarness(t *testing.T) (h *harness, beta, secret *fakeSession) {
t.Helper()
h = newHarness(t)
beta = linkTracker("h-beta", "beta")
secret = linkTracker("h-secret", "secret")
addTracker(h, "alice", 1, "alpha", core.VisibilityPublic, beadsLinkFixture())
addTracker(h, "bob", 2, "beta", core.VisibilityPublic, beta)
addTracker(h, "dave", 9, "secrets", core.VisibilityPrivate, secret)
setViews(t, h, &beadsView{})
return h, beta, secret
}
// An id whose prefix names a database this caller may browse becomes a link to
// that database's detail pane; an id in the current database keeps linking to
// this one; an id whose prefix matches nothing is left as it was written.
func TestBeadsDetailLinksIdsToTheDatabaseThatOwnsThem(t *testing.T) {
h, _, _ := linkHarness(t)
rec := h.do("GET", "/~alice/alpha/view/beads?issue=alpha-1", nil, nil)
if rec.Code != http.StatusOK {
t.Fatalf("detail: got %d, want 200; body=%s", rec.Code, rec.Body.String())
}
body := rec.Body.String()
// The sibling database, in each of the long-text bodies that name it.
want := `<a href="/~bob/beta/view/beads?issue=beta-46c.2">beta-46c.2</a>`
for _, field := range []string{"Description", "Acceptance criteria"} {
if got := fieldBody(t, body, field); !strings.Contains(got, want) {
t.Errorf("the %s body did not link the sibling's id: %s", field, got)
}
}
// The design body's id, which is another database's too.
if got := fieldBody(t, body, "Design"); !strings.Contains(got,
`<a href="/~bob/beta/view/beads?issue=beta-nex">beta-nex</a>`) {
t.Errorf("the design body's id did not link: %s", got)
}
// This database's own id links where it has always linked: here.
if !strings.Contains(body, `<a href="/~alice/alpha/view/beads?issue=alpha-2">alpha-2</a>`) {
t.Errorf("an id in the current database did not link to it; body=%s", body)
}
// A prefix no database on this instance claims is not an id.
if !strings.Contains(body, "nosuch-9z is nobody's.") {
t.Errorf("an unknown prefix should be left alone; body=%s", body)
}
if strings.Contains(body, "issue=nosuch-9z") {
t.Errorf("an unknown prefix was linked; body=%s", body)
}
}
// A database this caller may not browse is not in the index, so the ids it owns
// render as plain text — with nothing at all to distinguish them from an id that
// matches nothing. No tooltip, no class, no marker: each of those would publish
// the existence of a database this caller is not allowed to know about.
func TestBeadsDetailLeavesAnInvisibleDatabaseUnlinked(t *testing.T) {
h, _, secret := linkHarness(t)
rec := h.do("GET", "/~alice/alpha/view/beads?issue=alpha-1", nil, nil)
if rec.Code != http.StatusOK {
t.Fatalf("detail: got %d, want 200; body=%s", rec.Code, rec.Body.String())
}
body := rec.Body.String()
// The text says what it said.
if !strings.Contains(body, "secret-9a1 stays a secret") {
t.Errorf("the notes body lost its text; body=%s", body)
}
// And says nothing else: not a link, not a marker, not the database's name,
// its owner, or its head.
for _, notWant := range []string{
"issue=secret-9a1", ">secret-9a1</a>", "secrets", "dave", "h-secret", "unknown tracker",
} {
if strings.Contains(body, notWant) {
t.Errorf("the page revealed %q about a database the caller may not browse; body=%s", notWant, body)
}
}
if secret.opens != 0 {
t.Errorf("a database the caller may not browse was opened %d times", secret.opens)
}
// Its owner, who may browse it, gets the link — which is what makes the
// absence above a visibility rule and not a broken index.
owner := h.do("GET", "/~alice/alpha/view/beads?issue=alpha-1", testCaller(9, "dave"), nil)
if owner.Code != http.StatusOK {
t.Fatalf("owner detail: got %d; body=%s", owner.Code, owner.Body.String())
}
if !strings.Contains(owner.Body.String(), `<a href="/~dave/secrets/view/beads?issue=secret-9a1">secret-9a1</a>`) {
t.Errorf("the owner of the private tracker should see the link; body=%s", owner.Body.String())
}
}
// Stored text is escaped first and linked second. A description carrying a
// script tag reaches the page as text, and the anchors this rendering generated
// are the only markup in the body it produced.
func TestBeadsDetailEscapesBeforeItLinks(t *testing.T) {
h, _, _ := linkHarness(t)
rec := h.do("GET", "/~alice/alpha/view/beads?issue=alpha-1", nil, nil)
if rec.Code != http.StatusOK {
t.Fatalf("detail: got %d, want 200; body=%s", rec.Code, rec.Body.String())
}
body := rec.Body.String()
if strings.Contains(body, "<script") {
t.Fatalf("a stored script tag reached the page as markup; body=%s", body)
}
if !strings.Contains(body, "<script>alert('x')</script>") {
t.Errorf("the stored script tag is not escaped; body=%s", body)
}
// Inside the rendered description, the only tags are the anchors.
desc := fieldBody(t, body, "Description")
anchor := regexp.MustCompile(`^(?:<a href="/~[a-z0-9/?=.&;-]+">|</a>)$`)
for _, tag := range regexp.MustCompile(`<[^>]*>`).FindAllString(desc, -1) {
if !anchor.MatchString(tag) {
t.Errorf("unexpected markup %q in the rendered description: %s", tag, desc)
}
}
// Escaped, the description still says exactly what was stored.
if got := html.UnescapeString(regexp.MustCompile(`</?a[^>]*>`).ReplaceAllString(desc, "")); got !=
"blocked by beta-46c.2, and by alpha-2.\nnosuch-9z is nobody's.\n<script>alert('x')</script>" {
t.Errorf("the rendered description does not decode to the stored text: %q", got)
}
}
// The ids in a comment body and in a history summary are linked too: they are
// where a hand-off between two trackers is usually written.
func TestBeadsDetailLinksCommentsAndHistory(t *testing.T) {
h, _, _ := linkHarness(t)
rec := h.do("GET", "/~alice/alpha/view/beads?issue=alpha-1", nil, nil)
if rec.Code != http.StatusOK {
t.Fatalf("detail: got %d, want 200; body=%s", rec.Code, rec.Body.String())
}
body := rec.Body.String()
comment := `<div class="c-body">landing with <a href="/~bob/beta/view/beads?issue=beta-46c.2">beta-46c.2</a></div>`
if !strings.Contains(body, comment) {
t.Errorf("a comment body's id did not link; body=%s", body)
}
summary := `updated design to the shape is <a href="/~bob/beta/view/beads?issue=beta-nex">beta-nex</a>'s`
if !strings.Contains(body, summary) {
t.Errorf("a history summary's id did not link; body=%s", body)
}
}
// The index is bounded exactly as /ready is: a second render whose heads have
// not moved reads no rows again. Counted on the fake, never timed.
func TestBeadsDetailPrefixIndexIsBounded(t *testing.T) {
h, beta, _ := linkHarness(t)
first := h.do("GET", "/~alice/alpha/view/beads?issue=alpha-1", nil, nil)
if first.Code != http.StatusOK {
t.Fatalf("first detail: got %d", first.Code)
}
reads := beta.rowReads
if reads == 0 {
t.Fatalf("the first render must read the sibling's config")
}
second := h.do("GET", "/~alice/alpha/view/beads?issue=alpha-1", nil, nil)
if second.Code != http.StatusOK {
t.Fatalf("second detail: got %d", second.Code)
}
if beta.rowReads != reads {
t.Errorf("an unmoved head must cost no row reads: %d then %d", reads, beta.rowReads)
}
// The store is still opened and its branches listed — that is what the gate
// is gated on, and it is the cheap half.
if beta.opens != 2 {
t.Errorf("the sibling should be opened once per render, got %d", beta.opens)
}
// And the page is the same page, cache or not.
if first.Body.String() != second.Body.String() {
t.Errorf("the cached render differs from the first one")
}
}
// The board carries no stored prose, so it builds no index and opens no other
// database. The index is paid for by the one rendering that needs it.
func TestBeadsBoardBuildsNoLinkIndex(t *testing.T) {
h, beta, _ := linkHarness(t)
rec := h.do("GET", "/~alice/alpha/view/beads", nil, nil)
if rec.Code != http.StatusOK {
t.Fatalf("board: got %d; body=%s", rec.Code, rec.Body.String())
}
if beta.opens != 0 {
t.Errorf("the board opened another database %d times", beta.opens)
}
if strings.Contains(rec.Body.String(), "/~bob/beta/view/beads") {
t.Errorf("the board linked into another database; body=%s", rec.Body.String())
}
}
// An index that could not be built at all costs the page its links and not the
// page: the ids are text, exactly as they were before this existed.
func TestBeadsDetailSurvivesAnUnbuildableIndex(t *testing.T) {
h, _, _ := linkHarness(t)
h.store.listErr = errors.New("db: list repositories: connection refused")
rec := h.do("GET", "/~alice/alpha/view/beads?issue=alpha-1", nil, nil)
if rec.Code != http.StatusOK {
t.Fatalf("detail: got %d, want 200; body=%s", rec.Code, rec.Body.String())
}
body := rec.Body.String()
if !strings.Contains(body, "blocked by beta-46c.2, and by alpha-2.") {
t.Errorf("the description is missing; body=%s", body)
}
if strings.Contains(body, "issue=beta-46c.2") {
t.Errorf("an index that was never built linked something; body=%s", body)
}
if strings.Contains(body, "connection refused") {
t.Errorf("the listing error reached the reader; body=%s", body)
}
}
// fieldBody returns the contents of the <pre class="field-body"> that follows
// the named field label.
func fieldBody(t *testing.T, body, label string) string {
t.Helper()
head := `<div class="field-label">` + label + `</div><pre class="field-body">`
i := strings.Index(body, head)
if i < 0 {
t.Fatalf("no %s body in the page: %s", label, body)
}
rest := body[i+len(head):]
j := strings.Index(rest, "</pre>")
if j < 0 {
t.Fatalf("unterminated %s body", label)
}
return rest[:j]
}
// --- the stored rows section ---------------------------------------------------
// beadsNullFixture is one issue carrying, in one row, the three states a raw
// view may never conflate: a cell that holds no value (closed_at), a cell that
// stores the four characters "NULL" (assignee), and a cell that stores the empty
// string (notes). The mask is what tells the first two apart, so it is set here
// exactly as browse fills it for a real read.
func beadsNullFixture() *fakeSession {
issues := &browse.RowPage{
Columns: []string{"id", "title", "status", "assignee", "closed_at", "notes"},
Rows: [][]string{
{"i-null", "Three states", "open", "NULL", "NULL", ""},
},
Nulls: [][]bool{
{false, false, false, false, true, false},
},
Total: 1,
}
return &fakeSession{
branches: []browse.Branch{{Name: "main", Head: "abcdef1234567890"}},
tables: beadsTables(),
rowsByTable: map[string]*browse.RowPage{
"issues": issues,
"dependencies": {
Columns: []string{"id", "issue_id", "depends_on_issue_id", "type"},
Nulls: [][]bool{},
Total: 0,
},
},
}
}
// rawSection returns the markup of the collapsed stored-rows block.
func rawSection(t *testing.T, body string) string {
t.Helper()
i := strings.Index(body, `<details class="bead-raw">`)
if i < 0 {
t.Fatalf("no stored-rows section in the page: %s", body)
}
rest := body[i:]
j := strings.Index(rest, "</details>")
if j < 0 {
t.Fatalf("unterminated stored-rows section")
}
return rest[:j]
}
// rawTableBlock returns one table's markup inside the stored-rows section.
func rawTableBlock(t *testing.T, body, table string) string {
t.Helper()
sec := rawSection(t, body)
head := `<div class="raw-table" data-table="` + table + `">`
i := strings.Index(sec, head)
if i < 0 {
t.Fatalf("no %s rows in the stored-rows section: %s", table, sec)
}
rest := sec[i+len(head):]
if j := strings.Index(rest, `<div class="raw-table"`); j >= 0 {
rest = rest[:j]
}
return rest
}
// rawCellValue returns the rendered value cell for one column of one table's
// first row in the stored-rows section.
func rawCellValue(t *testing.T, body, table, column string) string {
t.Helper()
block := rawTableBlock(t, body, table)
head := `<td class="raw-col">` + column + `</td>`
i := strings.Index(block, head)
if i < 0 {
t.Fatalf("no %s.%s cell in the stored-rows section: %s", table, column, block)
}
rest := block[i+len(head):]
open := `<td class="raw-val">`
k := strings.Index(rest, open)
if k < 0 {
t.Fatalf("no value cell after %s.%s", table, column)
}
rest = rest[k+len(open):]
j := strings.Index(rest, "</td>")
if j < 0 {
t.Fatalf("unterminated value cell for %s.%s", table, column)
}
return rest[:j]
}
// The detail pane carries the rows it was built from, and carries them closed:
// this is a tool for checking the rendering, not the reason a reader opened the
// page, so it is a <details> with no open attribute — the stream layout's Past
// Stand idiom.
func TestBeadsDetailShowsTheStoredRowsCollapsed(t *testing.T) {
h := newHarness(t)
h.store.add(&core.Repo{Name: "db", OwnerID: 1, OwnerName: "alice", Path: "/d", Visibility: core.VisibilityPublic})
h.browse.sess = beadsFixture()
setViews(t, h, &beadsView{})
rec := h.do("GET", "/~alice/db/view/beads?issue=i-open", nil, nil)
if rec.Code != http.StatusOK {
t.Fatalf("detail: got %d, want 200; body=%s", rec.Code, rec.Body.String())
}
body := rec.Body.String()
if !strings.Contains(body, `<details class="bead-raw">`) {
t.Fatalf("no stored-rows section on the detail pane; body=%s", body)
}
if strings.Contains(body, `<details class="bead-raw" open`) || strings.Contains(body, `<details open class="bead-raw"`) {
t.Errorf("the stored-rows section opens expanded; body=%s", body)
}
if !strings.Contains(body, "Stored rows") {
t.Errorf("the section has no summary a reader can find it by; body=%s", body)
}
// Every table that holds rows of this issue is there; custom_statuses is read
// for the lane but holds no row of this issue, so it is not.
for _, table := range []string{"issues", "labels", "dependencies", "comments"} {
if !strings.Contains(body, `data-table="`+table+`"`) {
t.Errorf("the stored-rows section is missing the %s rows; body=%s", table, body)
}
}
if strings.Contains(body, `data-table="custom_statuses"`) {
t.Errorf("custom_statuses has no row of this issue and should not be listed; body=%s", body)
}
// The issue's own row is rendered column by column, values as stored.
if got := rawCellValue(t, body, "issues", "title"); got != `<span class="raw-text">Ready to roll</span>` {
t.Errorf("issues.title rendered as %q", got)
}
if got := rawCellValue(t, body, "comments", "text"); got != `<span class="raw-text">first!</span>` {
t.Errorf("comments.text rendered as %q", got)
}
}
// The three states, on the page. A cell that holds no value renders as a NULL
// chip, a cell storing those four characters renders as the text it stores, and
// a cell storing the empty string says it is empty — the rendering used to make
// the first two identical and the last two indistinguishable.
func TestBeadsDetailStoredRowsSeparateNullFromEmpty(t *testing.T) {
h := newHarness(t)
h.store.add(&core.Repo{Name: "db", OwnerID: 1, OwnerName: "alice", Path: "/d", Visibility: core.VisibilityPublic})
h.browse.sess = beadsNullFixture()
setViews(t, h, &beadsView{})
rec := h.do("GET", "/~alice/db/view/beads?issue=i-null", nil, nil)
if rec.Code != http.StatusOK {
t.Fatalf("detail: got %d, want 200; body=%s", rec.Code, rec.Body.String())
}
body := rec.Body.String()
absent := rawCellValue(t, body, "issues", "closed_at")
stored := rawCellValue(t, body, "issues", "assignee")
empty := rawCellValue(t, body, "issues", "notes")
if absent != `<span class="raw-null">NULL</span>` {
t.Errorf("a cell holding no value rendered as %q", absent)
}
if stored != `<span class="raw-text">NULL</span>` {
t.Errorf("a cell storing the text \"NULL\" rendered as %q", stored)
}
if empty != `<span class="raw-text"></span>` {
t.Errorf("a cell storing the empty string rendered as %q", empty)
}
if absent == stored || absent == empty || stored == empty {
t.Errorf("the three states are not three renderings: %q / %q / %q", absent, stored, empty)
}
}
// Stored values are escaped, and they are not linkified. A script tag stored in
// a description reaches this section as text, and the ids in it stay the
// characters that are stored — the bodies above the section link them, but a raw
// view whose values have been rewritten is no longer showing what is stored.
func TestBeadsDetailStoredRowsEscapeAndDoNotLink(t *testing.T) {
h, _, _ := linkHarness(t)
rec := h.do("GET", "/~alice/alpha/view/beads?issue=alpha-1", nil, nil)
if rec.Code != http.StatusOK {
t.Fatalf("detail: got %d, want 200; body=%s", rec.Code, rec.Body.String())
}
body := rec.Body.String()
desc := rawCellValue(t, body, "issues", "description")
if strings.Contains(desc, "<script") {
t.Fatalf("a stored script tag reached the stored-rows section as markup: %s", desc)
}
if !strings.Contains(desc, "<script>alert('x')</script>") {
t.Errorf("the stored script tag is not escaped in the stored-rows section: %s", desc)
}
if strings.Contains(desc, "<a ") {
t.Errorf("the stored-rows section linkified a value: %s", desc)
}
// The bodies above still link, so this is the section's own choice and not a
// linkifier that stopped working.
if !strings.Contains(fieldBody(t, body, "Description"), `<a href="/~bob/beta/view/beads?issue=beta-46c.2">`) {
t.Errorf("the description above the section stopped linking; body=%s", body)
}
// Escaped and unlinked, the cell still decodes to exactly what is stored.
want := "blocked by beta-46c.2, and by alpha-2.\nnosuch-9z is nobody's.\n<script>alert('x')</script>"
got := html.UnescapeString(strings.TrimSuffix(strings.TrimPrefix(desc, `<span class="raw-text">`), "</span>"))
if got != want {
t.Errorf("the stored description does not render as stored: %q", got)
}
}
// The board renders no stored rows: it is not a detail pane, and a board that
// carried every row behind every card would be the table browser with lanes
// drawn on it.
func TestBeadsBoardHasNoStoredRowsSection(t *testing.T) {
h := newHarness(t)
h.store.add(&core.Repo{Name: "db", OwnerID: 1, OwnerName: "alice", Path: "/d", Visibility: core.VisibilityPublic})
h.browse.sess = beadsFixture()
setViews(t, h, &beadsView{})
rec := h.do("GET", "/~alice/db/view/beads", nil, nil)
if rec.Code != http.StatusOK {
t.Fatalf("board: got %d, want 200; body=%s", rec.Code, rec.Body.String())
}
body := rec.Body.String()
for _, notWant := range []string{`class="bead-raw"`, "Stored rows", `<td class="raw-col">`} {
if strings.Contains(body, notWant) {
t.Errorf("the board rendered the stored-rows section (%q); body=%s", notWant, body)
}
}
}
func TestBeadsEpicViewRender(t *testing.T) {
h := newHarness(t)
h.store.add(&core.Repo{Name: "db", OwnerID: 1, OwnerName: "alice", Path: "/d", Visibility: core.VisibilityPublic})
h.browse.sess = beadsEpicFixture()
setViews(t, h, &beadsView{})
rec := h.do("GET", "/~alice/db/view/beads?issue=i-epic", nil, nil)
if rec.Code != http.StatusOK {
t.Fatalf("epic view: got %d, want 200; body=%s", rec.Code, rec.Body.String())
}
body := rec.Body.String()
for _, want := range []string{"Subtasks", "1 of 3 done", "epic-progress", "Child three", "History", "changed status to in_progress"} {
if !strings.Contains(body, want) {
t.Errorf("epic render missing %q", want)
}
}
}