internal/control/help_test.go
276 lines · 10294 bytes
14 symbols in this file
helpOutTestHelpVerbTestHelpVerbUsageKeepsRequiredFlagsTestHelpVerbUsageKeepsAlternativeTestHelpNounTestEveryNounHasASummaryusageFlagtrailingRedirectTestHelpIsCompleteTestCmdUsagePrefixesTheProgramTestShownBelowTestHelpPrintsTheCLIPathTestHelpRendersAnAliasedNounWithTheRegistryLayoutTestHelpRendersAnAliasedNounInRegisteredFormOverSSH
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}