internal/control/help_test.go

f8b976a97290a20d552056a999511f5d27d8e8ec
gitbay/internal/control/help_test.go history · blame · raw

276 lines · 10294 bytes

  1package control
  2
  3import (
  4	"bytes"
  5	"regexp"
  6	"slices"
  7	"strings"
  8	"testing"
  9
 10	"gitbay.org/gitbay/internal/protocol"
 11)
 12
 13func helpOut(t *testing.T, term Term, prefix ...string) string {
 14	t.Helper()
 15	var out, errOut bytes.Buffer
 16	c := &Ctx{Stdout: &out, Stderr: &errOut, Term: term, Scope: "full"}
 17	c.Cfg.Server.SiteURL = "https://forge.test"
 18	if code := Dispatch(c, append([]string{"help"}, prefix...)); code != protocol.ExitOK {
 19		t.Fatalf("help %v: exit %d: %s", prefix, code, errOut.String())
 20	}
 21	return out.String()
 22}
 23
 24func TestHelpVerb(t *testing.T) {
 25	out := helpOut(t, Term{Cols: 100}, "issue", "list")
 26	for _, want := range []string{
 27		"list issues\n",
 28		"USAGE\n  gitbay issue list [<owner/name>] [flags]\n",
 29		"FLAGS\n",
 30		"  --state open|closed|all",
 31		"which issues (default open)\n",
 32		"  --json",
 33		"EXAMPLES\n  gitbay issue list krz/gitbay --label bug --state all\n",
 34	} {
 35		if !strings.Contains(out, want) {
 36			t.Errorf("missing %q in:\n%s", want, out)
 37		}
 38	}
 39	plain := helpOut(t, Term{}, "issue", "list")
 40	if !strings.Contains(plain, "  ssh git@forge.test issue list krz/gitbay --label bug --state all\n") {
 41		t.Errorf("plain examples not ssh:\n%s", plain)
 42	}
 43	if !strings.Contains(plain, "USAGE\n  ssh git@forge.test issue list <owner/name> [flags]\n") {
 44		t.Errorf("plain usage:\n%s", plain)
 45	}
 46}
 47
 48// TestHelpVerbUsageKeepsRequiredFlags covers a usage line with no
 49// optional flag to cut at: it prints whole, not truncated to "[flags]"
 50// as though --yes were optional.
 51func TestHelpVerbUsageKeepsRequiredFlags(t *testing.T) {
 52	out := helpOut(t, Term{Cols: 100}, "repo", "delete")
 53	if !strings.Contains(out, "USAGE\n  gitbay repo delete [<owner/name>] --yes\n") {
 54		t.Errorf("missing required --yes in usage:\n%s", out)
 55	}
 56	if strings.Contains(out, "[flags]") {
 57		t.Errorf("repo delete has no optional flags; should not print [flags]:\n%s", out)
 58	}
 59}
 60
 61// TestHelpVerbUsageKeepsAlternative covers a usage line whose flag is
 62// one side of a "|" alternative, not an optional extra.
 63func TestHelpVerbUsageKeepsAlternative(t *testing.T) {
 64	out := helpOut(t, Term{Cols: 100}, "notifications", "read")
 65	if !strings.Contains(out, "USAGE\n  gitbay notifications read <id>... | --all\n") {
 66		t.Errorf("missing whole alternative in usage:\n%s", out)
 67	}
 68}
 69
 70func TestHelpNoun(t *testing.T) {
 71	out := helpOut(t, Term{Cols: 100}, "issue")
 72	for _, want := range []string{"issues\n", "READ\n", "WRITE\n", "  list ", "  create ", "gitbay issue <verb> --help for flags.\n"} {
 73		if !strings.Contains(out, want) {
 74			t.Errorf("missing %q in:\n%s", want, out)
 75		}
 76	}
 77	if strings.Index(out, "  list ") > strings.Index(out, "WRITE") {
 78		t.Errorf("list is not under READ:\n%s", out)
 79	}
 80}
 81
 82func TestEveryNounHasASummary(t *testing.T) {
 83	for _, cmd := range Commands() {
 84		if nounSummaries[cmd.Path[0]] == "" {
 85			t.Errorf("no noun summary for %q", cmd.Path[0])
 86		}
 87	}
 88}
 89
 90var usageFlag = regexp.MustCompile(`--[a-z][a-z0-9-]*`)
 91
 92// trailingRedirect strips a shell redirect an example ends with, the way a
 93// person's shell would before gitbay ever sees argv: protocol.Tokenize
 94// rejects a bare < or > as a shell metacharacter, so the redirected part
 95// is not something the command's own tokenizer parses.
 96var trailingRedirect = regexp.MustCompile(`^(.*?)\s+[<>]\s*\S+$`)
 97
 98// Help is written once, in the registry. Every flag in a usage line has
 99// a description, every description names a flag in the usage line, and
100// every command has an example that runs it.
101func TestHelpIsComplete(t *testing.T) {
102	for _, cmd := range Commands() {
103		path := strings.Join(cmd.Path, " ")
104		inUsage := map[string]bool{}
105		for _, f := range usageFlag.FindAllString(cmd.Usage, -1) {
106			if f != "--json" {
107				inUsage[f] = true
108			}
109		}
110		described := map[string]bool{}
111		for _, f := range cmd.Flags {
112			described[f.Name] = true
113			if f.Desc == "" {
114				t.Errorf("%s: %s has no description", path, f.Name)
115			}
116			if !inUsage[f.Name] {
117				t.Errorf("%s: %s is described but not in the usage", path, f.Name)
118			}
119		}
120		for f := range inUsage {
121			if !described[f] {
122				t.Errorf("%s: %s is in the usage with no description", path, f)
123			}
124		}
125		if len(cmd.Examples) == 0 {
126			t.Errorf("%s: no example", path)
127		}
128		for _, ex := range cmd.Examples {
129			toParse := ex
130			if m := trailingRedirect.FindStringSubmatch(ex); m != nil {
131				toParse = m[1]
132			}
133			argv, err := protocol.Tokenize(toParse)
134			if err != nil {
135				t.Errorf("%s: example %q: %v", path, ex, err)
136				continue
137			}
138			got, _, ok := Lookup(argv)
139			if !ok || !slices.Equal(got.Path, cmd.Path) {
140				t.Errorf("%s: example %q runs %v", path, ex, got.Path)
141			}
142		}
143	}
144}
145
146func TestCmdUsagePrefixesTheProgram(t *testing.T) {
147	c := &Ctx{Cmd: Command{Path: []string{"keys", "remove"}, Usage: "keys remove <fingerprint>"}}
148	c.Cfg.Server.SiteURL = "https://forge.test"
149	if got := c.cmdUsage(); got != "ssh git@forge.test keys remove <fingerprint>" {
150		t.Errorf("ssh form: %q", got)
151	}
152	c.Term = Term{Cols: 100}
153	if got := c.cmdUsage(); got != "gitbay keys remove <fingerprint>" {
154		t.Errorf("cli form, no CLIPath sent: %q", got)
155	}
156	c.CLIPath = "auth keys remove"
157	if got := c.cmdUsage(); got != "gitbay auth keys remove <fingerprint>" {
158		t.Errorf("cli form, mismatched registered path: %q", got)
159	}
160	c.Term = Term{}
161	if got := c.cmdUsage(); got != "gitbay auth keys remove <fingerprint>" {
162		t.Errorf("cli form off a terminal, mismatched registered path: %q", got)
163	}
164
165	c2 := &Ctx{Cmd: Command{Path: []string{"repo", "tree"}, Usage: "repo tree <owner/name> [<path>] [--ref <ref>]"}, Term: Term{Cols: 100}}
166	if got := c2.cmdUsage(); got != "gitbay repo tree [<owner/name>] [<path>] [--ref <ref>]" {
167		t.Errorf("optional owner/name: %q", got)
168	}
169	c2.CLIPath = "repo tree"
170	if got := c2.cmdUsage(); got != "gitbay repo tree [<owner/name>] [<path>] [--ref <ref>]" {
171		t.Errorf("matching CLIPath changes nothing: %q", got)
172	}
173
174	c3 := &Ctx{Cmd: Command{Path: []string{"repo", "topics", "add"}, Usage: "repo topics add <owner/name> <topic>..."}, Term: Term{Cols: 100}, CLIPath: "repo topics list"}
175	if got := c3.cmdUsage(); got != "gitbay repo topics add [<owner/name>] <topic>..." {
176		t.Errorf("CLIPath naming another command: %q", got)
177	}
178	c3.Cmd = Command{Path: []string{"repo", "topics"}, Usage: "repo topics <owner/name>"}
179	if got := c3.cmdUsage(); got != "gitbay repo topics list [<owner/name>]" {
180		t.Errorf("CLIPath extending the registered path: %q", got)
181	}
182	c3.CLIPath = " "
183	if got := c3.cmdUsage(); got != "gitbay repo topics [<owner/name>]" {
184		t.Errorf("blank CLIPath: %q", got)
185	}
186}
187
188// TestShownBelow: another command listed beside the one the CLI named
189// takes the CLI's parent when the CLI only regrouped the command (auth
190// keys remove), and keeps its registered path when the CLI renamed the
191// leaf (repo topics list for repo topics), since that says nothing about
192// what the other command is called.
193func TestShownBelow(t *testing.T) {
194	cases := []struct {
195		cliPath, registered, other, want string
196	}{
197		{"", "keys remove", "keys list", "keys list"},
198		{"keys remove", "keys remove", "keys list", "keys list"},
199		{"auth keys remove", "keys remove", "keys list", "auth keys list"},
200		{"auth export", "account export", "account export extra", "auth export extra"},
201		{"repo topics list", "repo topics", "repo topics add", "repo topics add"},
202	}
203	for _, tc := range cases {
204		c := &Ctx{CLIPath: tc.cliPath}
205		if got := c.shownBelow(tc.registered, tc.other); got != tc.want {
206			t.Errorf("CLIPath %q, %q beside %q: got %q, want %q", tc.cliPath, tc.other, tc.registered, got, tc.want)
207		}
208	}
209}
210
211// TestHelpPrintsTheCLIPath: help reached through the CLI with a --path=
212// prints the CLI's path in USAGE, the noun header and SEE ALSO.
213func TestHelpPrintsTheCLIPath(t *testing.T) {
214	via := func(t *testing.T, term Term, argv ...string) string {
215		t.Helper()
216		var out, errOut bytes.Buffer
217		c := &Ctx{Stdout: &out, Stderr: &errOut, Term: term, Scope: "full"}
218		c.Cfg.Server.SiteURL = "https://forge.test"
219		if code := Dispatch(c, argv); code != protocol.ExitOK {
220			t.Fatalf("%v: exit %d: %s", argv, code, errOut.String())
221		}
222		return out.String()
223	}
224	verb := via(t, Term{Cols: 100}, "--path=auth keys remove", "help", "keys", "remove")
225	if !strings.Contains(verb, "USAGE\n  gitbay auth keys remove <fingerprint>") {
226		t.Errorf("verb usage:\n%s", verb)
227	}
228	topics := via(t, Term{Cols: 100}, "--path=repo topics list", "help", "repo", "topics")
229	for _, want := range []string{"USAGE\n  gitbay repo topics list [<owner/name>]", "SEE ALSO\n  gitbay repo topics add\n"} {
230		if !strings.Contains(topics, want) {
231			t.Errorf("missing %q in:\n%s", want, topics)
232		}
233	}
234	if strings.Contains(topics, "topics list add") {
235		t.Errorf("SEE ALSO renamed a child after the leaf:\n%s", topics)
236	}
237	noun := via(t, Term{Cols: 100}, "--path=auth keys", "help", "keys")
238	for _, want := range []string{"USAGE\n  gitbay auth keys <verb> ...\n", "gitbay auth keys <verb> --help for flags.\n"} {
239		if !strings.Contains(noun, want) {
240			t.Errorf("missing %q in:\n%s", want, noun)
241		}
242	}
243}
244
245func TestHelpRendersAnAliasedNounWithTheRegistryLayout(t *testing.T) {
246	var out, errOut bytes.Buffer
247	c := &Ctx{Stdout: &out, Stderr: &errOut, Term: Term{Cols: 100}, Scope: "full", CLIPath: "auth"}
248	c.Cfg.Server.SiteURL = "https://forge.test"
249	if code := Dispatch(c, []string{"help", "auth"}); code != protocol.ExitOK {
250		t.Fatalf("exit %d: %s", code, errOut.String())
251	}
252	got := out.String()
253	for _, want := range []string{"auth whoami", "auth keys list", "auth pgp add", "auth token create", "auth export"} {
254		if !strings.Contains(got, want) {
255			t.Errorf("missing %q in:\n%s", want, got)
256		}
257	}
258	if strings.Contains(got, "no command matches") {
259		t.Errorf("auth did not resolve: %s", got)
260	}
261}
262
263func TestHelpRendersAnAliasedNounInRegisteredFormOverSSH(t *testing.T) {
264	got := helpOut(t, Term{}, "auth")
265	for _, want := range []string{"whoami", "keys list", "pgp add", "token create", "account export"} {
266		if !strings.Contains(got, want) {
267			t.Errorf("missing %q in:\n%s", want, got)
268		}
269	}
270	if strings.Contains(got, "auth keys list") {
271		t.Errorf("stock ssh should not see the CLI-only auth prefix: %s", got)
272	}
273	if strings.Contains(got, " auth <verb>") {
274		t.Errorf("stock ssh should not be told to type auth <verb>, which the server has no command for: %s", got)
275	}
276}