Skip to content

Commit 2884995

Browse files
committed
Add a golden test for help and fish completion output
Add a test which renders various help pages and fish completion for a few commands exercising most of the template features, and compares the result with a golden file. Since CI runs tests both with and without urfave_cli_no_template tag, this ensures both implementations produce the exact same output. To regenerate the golden file, run the test with UPDATE_GOLDEN=1. Signed-off-by: Kir Kolyshkin <kolyshkin@gmail.com>
1 parent 5227fa6 commit 2884995

2 files changed

Lines changed: 701 additions & 0 deletions

File tree

‎help_output_test.go‎

Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,222 @@
1+
package cli
2+
3+
import (
4+
"bytes"
5+
"context"
6+
"io"
7+
"os"
8+
"strings"
9+
"testing"
10+
11+
"github.com/stretchr/testify/require"
12+
)
13+
14+
const helpOutputFile = "testdata/expected-help-output.txt"
15+
16+
type testAuthor struct {
17+
Name, Email string
18+
}
19+
20+
type testStringerAuthor struct {
21+
Name string
22+
}
23+
24+
func (a testStringerAuthor) String() string {
25+
return a.Name + " (stringer)"
26+
}
27+
28+
func buildHelpOutputTestCommand() *Command {
29+
cmd := buildMinimalTestCommand()
30+
*cmd = Command{
31+
Name: "app",
32+
Usage: "a test application\nwith a multi-line usage",
33+
Version: "1.2.3",
34+
Description: "This is a long description of the application,\n" +
35+
"spanning multiple lines.\n\nAnd paragraphs.",
36+
Authors: []any{
37+
"Alice <alice@example.com>",
38+
testAuthor{Name: "Bob", Email: "bob@example.com"},
39+
&testAuthor{Name: "Carol", Email: "carol@example.com"},
40+
testStringerAuthor{Name: "Dave"},
41+
nil,
42+
},
43+
Copyright: "(c) 2026 Somebody\nAll rights reserved.",
44+
Arguments: []Argument{&StringArg{Name: "root-arg"}},
45+
Flags: []Flag{
46+
&StringFlag{Name: "config", Aliases: []string{"c"}, Usage: "config `FILE`", Category: "Configuration"},
47+
&BoolFlag{Name: "debug", Usage: "enable debug output", Category: "Logging"},
48+
&StringFlag{Name: "log", Usage: "log file\nwith a multi-line usage", Category: "Logging"},
49+
&IntFlag{Name: "level", Value: 3, Usage: "some level"},
50+
&BoolFlag{Name: "hidden", Hidden: true},
51+
},
52+
Commands: []*Command{
53+
{
54+
Name: "create",
55+
Aliases: []string{"cr", "new"},
56+
Usage: "create a thing",
57+
Category: "Lifecycle",
58+
Description: "Creates a thing.\nWith a multi-line description.",
59+
Arguments: []Argument{
60+
&StringArg{Name: "id", Required: true},
61+
&StringArg{Name: "bundle"},
62+
&StringArg{Name: "extra", UsageText: "[extra...]"},
63+
},
64+
Flags: []Flag{
65+
&StringFlag{Name: "pid-file", Usage: "write pid to `FILE`", Local: true},
66+
&BoolFlag{Name: "detach", Aliases: []string{"d"}, Usage: "detach", Category: "Run options", Local: true},
67+
},
68+
},
69+
{
70+
Name: "delete",
71+
Usage: "delete a thing",
72+
Category: "Lifecycle",
73+
Flags: []Flag{
74+
&BoolFlag{Name: "force", Aliases: []string{"f"}, Usage: "force deletion", Local: true},
75+
},
76+
},
77+
{
78+
Name: "exec",
79+
Usage: "execute a thing",
80+
ArgsUsage: "<id> <command> [args...]",
81+
},
82+
{
83+
Name: "list",
84+
Aliases: []string{"ls"},
85+
Usage: "list things",
86+
UsageText: "app list [options]\n\napp ls",
87+
},
88+
{
89+
Name: "hidden-command",
90+
Hidden: true,
91+
},
92+
{
93+
Name: "plugin",
94+
Usage: "manage plugins",
95+
UsageText: "app plugin <command>\n\napp plugin help",
96+
Category: "Extensions",
97+
Commands: []*Command{
98+
{Name: "install", Usage: "install a plugin"},
99+
},
100+
},
101+
{
102+
Name: "config",
103+
Aliases: []string{"cfg"},
104+
Usage: "manage configuration",
105+
Commands: []*Command{
106+
{
107+
Name: "show",
108+
Aliases: []string{"s"},
109+
Usage: "show configuration",
110+
},
111+
{
112+
Name: "set-a-very-long-name",
113+
Aliases: []string{"set"},
114+
Usage: "set\nconfiguration",
115+
Flags: []Flag{
116+
&BoolFlag{Name: "global", Aliases: []string{"g"}, Usage: "set globally", Local: true},
117+
},
118+
},
119+
},
120+
},
121+
},
122+
EnableShellCompletion: true,
123+
}
124+
return cmd
125+
}
126+
127+
func buildHelpOutputMinimalCommand() *Command {
128+
cmd := buildMinimalTestCommand()
129+
cmd.Name = "mini"
130+
cmd.Version = "0.1"
131+
cmd.HideVersion = true
132+
cmd.Authors = []any{"Single Author"}
133+
cmd.HideHelp = true
134+
return cmd
135+
}
136+
137+
func buildHelpOutputUsageTextCommand() *Command {
138+
cmd := buildMinimalTestCommand()
139+
cmd.Name = "ut"
140+
cmd.UsageText = "ut [options] <thing>\n\nut other-form"
141+
cmd.Flags = []Flag{&BoolFlag{Name: "yes", Aliases: []string{"y"}, Usage: "assume yes"}}
142+
return cmd
143+
}
144+
145+
func buildHelpOutputArgsUsageCommand() *Command {
146+
cmd := buildMinimalTestCommand()
147+
cmd.Name = "au"
148+
cmd.ArgsUsage = "<source> <destination>"
149+
cmd.HideHelp = true
150+
return cmd
151+
}
152+
153+
// TestHelpOutput checks the output of help and fish completion against
154+
// a golden file. It is run both with and without urfave_cli_no_template
155+
// build tag, making sure both produce the exact same output.
156+
//
157+
// To regenerate the golden file, run with UPDATE_GOLDEN=1 (without the tag).
158+
func TestHelpOutput(t *testing.T) {
159+
type testCase struct {
160+
name string
161+
cmd func() *Command
162+
args []string
163+
wrap int
164+
// If set, call ShowRootCommandHelp directly instead of cmd.Run.
165+
direct bool
166+
}
167+
tests := []testCase{
168+
{name: "root", cmd: buildHelpOutputTestCommand, args: []string{"--help"}},
169+
{name: "root help command", cmd: buildHelpOutputTestCommand, args: []string{"help"}},
170+
{name: "command", cmd: buildHelpOutputTestCommand, args: []string{"create", "--help"}},
171+
{name: "help command", cmd: buildHelpOutputTestCommand, args: []string{"help", "create"}},
172+
{name: "command no categories", cmd: buildHelpOutputTestCommand, args: []string{"delete", "--help"}},
173+
{name: "command args usage", cmd: buildHelpOutputTestCommand, args: []string{"exec", "--help"}},
174+
{name: "command usage text", cmd: buildHelpOutputTestCommand, args: []string{"list", "--help"}},
175+
{name: "subcommand", cmd: buildHelpOutputTestCommand, args: []string{"config", "--help"}},
176+
{name: "help subcommand", cmd: buildHelpOutputTestCommand, args: []string{"help", "config"}},
177+
{name: "subcommand usage text and category", cmd: buildHelpOutputTestCommand, args: []string{"plugin", "--help"}},
178+
{name: "sub-subcommand", cmd: buildHelpOutputTestCommand, args: []string{"config", "set", "--help"}},
179+
{name: "root wrapped", cmd: buildHelpOutputTestCommand, args: []string{"--help"}, wrap: 40},
180+
{name: "command wrapped", cmd: buildHelpOutputTestCommand, args: []string{"create", "--help"}, wrap: 40},
181+
{name: "subcommand wrapped", cmd: buildHelpOutputTestCommand, args: []string{"config", "--help"}, wrap: 30},
182+
{name: "minimal", cmd: buildHelpOutputMinimalCommand, direct: true},
183+
{name: "usage text", cmd: buildHelpOutputUsageTextCommand, args: []string{"--help"}},
184+
{name: "args usage", cmd: buildHelpOutputArgsUsageCommand, direct: true},
185+
}
186+
187+
var got strings.Builder
188+
for _, tc := range tests {
189+
t.Run(tc.name, func(t *testing.T) {
190+
if tc.wrap != 0 {
191+
defer func(old HelpPrinterFunc) { HelpPrinter = old }(HelpPrinter)
192+
HelpPrinter = func(w io.Writer, templ string, data any) {
193+
HelpPrinterCustom(w, templ, data, map[string]any{
194+
"wrapAt": func() int { return tc.wrap },
195+
})
196+
}
197+
}
198+
199+
var out bytes.Buffer
200+
cmd := tc.cmd()
201+
cmd.Writer = &out
202+
cmd.ErrWriter = io.Discard
203+
if tc.direct {
204+
require.NoError(t, ShowRootCommandHelp(cmd))
205+
} else {
206+
require.NoError(t, cmd.Run(context.Background(), append([]string{cmd.Name}, tc.args...)))
207+
}
208+
209+
got.WriteString("=== " + tc.name + ": " + strings.Join(tc.args, " ") + "\n")
210+
got.WriteString(out.String())
211+
})
212+
}
213+
214+
fish, err := buildHelpOutputTestCommand().ToFishCompletion()
215+
require.NoError(t, err)
216+
got.WriteString("=== fish completion\n" + fish)
217+
218+
if os.Getenv("UPDATE_GOLDEN") != "" {
219+
require.NoError(t, os.WriteFile(helpOutputFile, []byte(got.String()), 0o644))
220+
}
221+
expectFileContent(t, helpOutputFile, got.String())
222+
}

0 commit comments

Comments
 (0)