@@ 1,7 1,9 @@
package web
import (
+ "html"
"net/http"
+ "net/url"
"strings"
"testing"
@@ 432,6 434,165 @@ func TestBeadsStreamIssueDetailWins(t *testing.T) {
}
}
+// --- 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)
+ }
+ }
+}
+
func TestBeadsEpicViewRender(t *testing.T) {
h := newHarness(t)
h.store.add(&core.Repo{Name: "db", OwnerID: 1, OwnerName: "alice", Path: "/d", Visibility: core.VisibilityPublic})