test(bonfire): flags, exit codes and the widget matrix
- PeekGlobals table, Args round trip, hook and PeekGlobals agree (clusters too) - reserved flags, duplicate names, Call runs no OnGlobals hook - exit codes 0/1/2, Reported silence, scrubbed errors, wrong-directory hint - widget matrix: not a terminal byte for byte, -q, --no-ansi, -n, terminal - live models through Update and View and one program run into a buffer - prompts under -n, Secret from piped stdin, ConfirmProceed, PrintAbove
This commit is contained in:
184
modules/bonfire/errors_test.go
Normal file
184
modules/bonfire/errors_test.go
Normal file
@@ -0,0 +1,184 @@
|
||||
package bonfire
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestExitCodes: Execute returns 0 on success, 1 on a failure and 2 on an
|
||||
// unknown command, an unknown flag, a wrong argument count and an unknown
|
||||
// help topic; Fail returns 0 for nil and 1 otherwise.
|
||||
func TestExitCodes(t *testing.T) {
|
||||
plainEnv(t)
|
||||
commands := []Command{
|
||||
{Name: "acme:ok", Description: "Succeed", Run: func(context.Context, Input, Output) error { return nil }},
|
||||
{Name: "acme:fail", Description: "Fail", Run: func(context.Context, Input, Output) error { return errors.New("boom") }},
|
||||
{Name: "acme:usage", Description: "Wrapped usage", Run: func(context.Context, Input, Output) error {
|
||||
return fmt.Errorf("acme:usage: %w", Usage(errors.New("pick one of a, b")))
|
||||
}},
|
||||
{Name: "acme:one", Description: "Exactly one", Args: []Arg{{Name: "x", Required: true}}, Run: func(context.Context, Input, Output) error { return nil }},
|
||||
{Name: "acme:min", Description: "At least one", Args: []Arg{{Name: "x", Required: true}, {Name: "y"}}, Run: func(context.Context, Input, Output) error { return nil }},
|
||||
}
|
||||
cases := []struct {
|
||||
args []string
|
||||
want int
|
||||
}{
|
||||
{nil, ExitOK},
|
||||
{[]string{"acme:ok"}, ExitOK},
|
||||
{[]string{"-q", "acme:ok", "-n"}, ExitOK},
|
||||
{[]string{"acme:one", "a"}, ExitOK},
|
||||
{[]string{"acme:min", "a", "b"}, ExitOK},
|
||||
{[]string{"acme:fail"}, ExitFailure},
|
||||
{[]string{"acme:usage"}, ExitUsage},
|
||||
{[]string{"acme:nope"}, ExitUsage},
|
||||
{[]string{"-v", "acme:nope"}, ExitUsage},
|
||||
{[]string{"acme:ok", "--bogus"}, ExitUsage},
|
||||
{[]string{"--bogus", "acme:ok"}, ExitUsage},
|
||||
{[]string{"acme:one"}, ExitUsage},
|
||||
{[]string{"acme:one", "a", "b"}, ExitUsage},
|
||||
{[]string{"acme:min"}, ExitUsage},
|
||||
{[]string{"help", "acme:nope"}, ExitUsage},
|
||||
{[]string{"list", "--format=yaml"}, ExitUsage},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
_, errOut, code := runGolden(t, commands, tc.args...)
|
||||
if code != tc.want {
|
||||
t.Errorf("%q: exit %d, want %d\n%s", tc.args, code, tc.want, errOut)
|
||||
}
|
||||
if tc.want != ExitOK && !strings.Contains(errOut, "ERROR") {
|
||||
t.Errorf("%q: no error block on stderr: %q", tc.args, errOut)
|
||||
}
|
||||
if tc.want == ExitUsage && !strings.Contains(errOut, "Try:") {
|
||||
t.Errorf("%q: usage error without a Try line:\n%s", tc.args, errOut)
|
||||
}
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if code := Fail(&buf, "acme", nil, Globals{}); code != ExitOK || buf.Len() != 0 {
|
||||
t.Errorf("Fail(nil) = %d, %q", code, buf.String())
|
||||
}
|
||||
if code := Fail(&buf, "acme", errors.New("config/app.yaml: bad"), Globals{}); code != ExitFailure || !strings.Contains(buf.String(), "config/app.yaml: bad") {
|
||||
t.Errorf("Fail(err) = %d, %q", code, buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestReportedSilent: a Reported error, also wrapped further, prints no
|
||||
// error block and exits 1; it matches ErrReported and the original error.
|
||||
func TestReportedSilent(t *testing.T) {
|
||||
plainEnv(t)
|
||||
base := errors.New("3 problems")
|
||||
rep := Reported(base)
|
||||
if !errors.Is(rep, ErrReported) || !errors.Is(rep, base) || rep.Error() != "3 problems" {
|
||||
t.Fatalf("Reported(base) = %v", rep)
|
||||
}
|
||||
if !errors.Is(Reported(nil), ErrReported) {
|
||||
t.Fatal("Reported(nil) is not ErrReported")
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
for _, err := range []error{rep, fmt.Errorf("docs:build: %w", rep), Reported(nil)} {
|
||||
RenderError(&buf, "acme", err, Globals{Verbosity: 3})
|
||||
}
|
||||
if buf.Len() != 0 {
|
||||
t.Fatalf("RenderError printed a reported error: %q", buf.String())
|
||||
}
|
||||
|
||||
cmd := Command{Name: "docs:check", Description: "Check", Run: func(_ context.Context, _ Input, out Output) error {
|
||||
out.Println("page.md: broken link")
|
||||
return fmt.Errorf("docs:check: %w", Reported(errors.New("1 problem")))
|
||||
}}
|
||||
out, errOut, code := runGolden(t, []Command{cmd}, "-v", "docs:check")
|
||||
if code != ExitFailure || errOut != "" || out != "page.md: broken link\n" {
|
||||
t.Fatalf("exit %d, stdout %q, stderr %q", code, out, errOut)
|
||||
}
|
||||
}
|
||||
|
||||
// TestErrorScrubbed: credential shapes in the message, the explanation,
|
||||
// the Try line and the -v chain are scrubbed by sunscreen.
|
||||
func TestErrorScrubbed(t *testing.T) {
|
||||
const bearer = "abcdefghijklmnop0123456789.payload.sig"
|
||||
const skKey = "sk-ABCDEFGHIJKLMNOPQRSTUVWXYZ012345"
|
||||
const apiKey = "live-key-0123456789"
|
||||
inner := fmt.Errorf("upstream answered 401 for x-api-key: %s", apiKey)
|
||||
err := WithHint(fmt.Errorf("calling the API with Bearer %s: %w", bearer, inner),
|
||||
"The configured key "+skKey+" was refused.",
|
||||
"{bin} acme:login --token Bearer "+bearer)
|
||||
for _, v := range []int{0, 1} {
|
||||
var buf bytes.Buffer
|
||||
RenderError(&buf, "acme", err, Globals{Verbosity: v, Color: ColorNever})
|
||||
out := buf.String()
|
||||
for _, secret := range []string{bearer, skKey, apiKey} {
|
||||
if strings.Contains(out, secret) {
|
||||
t.Errorf("verbosity %d leaks %q:\n%s", v, secret, out)
|
||||
}
|
||||
}
|
||||
for _, want := range []string{"Bearer [REDACTED]", "sk-[REDACTED]", "bin/acme acme:login"} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("verbosity %d lacks %q:\n%s", v, want, out)
|
||||
}
|
||||
}
|
||||
if v == 1 && !strings.Contains(out, "x-api-key: [REDACTED]") {
|
||||
t.Errorf("the -v chain is not scrubbed:\n%s", out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestWrongDirectoryHint: an application binary that fails in a directory
|
||||
// without config/ below an application root (summer.yaml) gets the
|
||||
// "cd <rel>; bin/<name> <args>" hint; summer, the root itself, usage
|
||||
// errors and directories outside an application get none.
|
||||
func TestWrongDirectoryHint(t *testing.T) {
|
||||
plainEnv(t)
|
||||
root := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(root, "summer.yaml"), []byte("binary: acme\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, dir := range []string{"config", "plugins/acme/blog"} {
|
||||
if err := os.MkdirAll(filepath.Join(root, dir), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
migrate := []Command{{Name: "migrate", Description: "Run the migrations", Run: func(context.Context, Input, Output) error {
|
||||
return errors.New("lagoon: database.dsn is empty")
|
||||
}}}
|
||||
run := func(t *testing.T, name, dir string, args ...string) string {
|
||||
t.Helper()
|
||||
t.Chdir(dir)
|
||||
var out, errW bytes.Buffer
|
||||
r, err := NewRootIO(name, migrate, strings.NewReader(""), &out, &errW, WithVersion(goldenVersion))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if code := Execute(context.Background(), r, args); code != ExitFailure && code != ExitUsage {
|
||||
t.Fatalf("exit %d", code)
|
||||
}
|
||||
return errW.String()
|
||||
}
|
||||
|
||||
got := run(t, "acme", filepath.Join(root, "plugins"), "migrate")
|
||||
if !strings.Contains(got, wrongDirectoryExplanation) || !strings.Contains(got, "Try:\n cd ..; bin/acme migrate\n") {
|
||||
t.Errorf("plugins/: no hint:\n%s", got)
|
||||
}
|
||||
got = run(t, "acme", filepath.Join(root, "plugins", "acme", "blog"), "-v", "migrate", "--env=staging")
|
||||
if !strings.Contains(got, "cd ../../..; bin/acme -v migrate --env=staging") {
|
||||
t.Errorf("plugins/acme/blog: hint lacks the path or the args:\n%s", got)
|
||||
}
|
||||
for name, tc := range map[string]struct {
|
||||
bin, dir string
|
||||
args []string
|
||||
}{
|
||||
"summer": {"summer", filepath.Join(root, "plugins"), []string{"migrate"}},
|
||||
"app root": {"acme", root, []string{"migrate"}},
|
||||
"usage error": {"acme", filepath.Join(root, "plugins"), []string{"migrate", "--bogus"}},
|
||||
"no application": {"acme", t.TempDir(), []string{"migrate"}},
|
||||
} {
|
||||
if got := run(t, tc.bin, tc.dir, tc.args...); strings.Contains(got, wrongDirectoryExplanation) {
|
||||
t.Errorf("%s: unexpected hint:\n%s", name, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
123
modules/bonfire/globals_test.go
Normal file
123
modules/bonfire/globals_test.go
Normal file
@@ -0,0 +1,123 @@
|
||||
package bonfire
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// peekCases are the global option rows shared by TestPeekGlobals and
|
||||
// TestGlobalFlagsAgree. cobra marks rows whose args cobra itself accepts
|
||||
// (it rejects unknown flags, so those rows are PeekGlobals-only).
|
||||
var peekCases = []struct {
|
||||
name string
|
||||
args []string
|
||||
want Globals
|
||||
cobra bool
|
||||
}{
|
||||
{"none", []string{"migrate"}, Globals{}, true},
|
||||
{"-v", []string{"-v", "migrate"}, Globals{Verbosity: 1}, true},
|
||||
{"-vv", []string{"migrate", "-vv"}, Globals{Verbosity: 2}, true},
|
||||
{"-vvv", []string{"-vvv", "migrate"}, Globals{Verbosity: 3}, true},
|
||||
{"-vvvv capped", []string{"-vvvv", "migrate"}, Globals{Verbosity: 3}, true},
|
||||
{"--verbose twice", []string{"--verbose", "migrate", "--verbose"}, Globals{Verbosity: 2}, true},
|
||||
{"-v -vv capped", []string{"-v", "migrate", "-vv", "--verbose"}, Globals{Verbosity: 3}, true},
|
||||
{"-q implies -n", []string{"migrate", "-q"}, Globals{Quiet: true, NoInteraction: true}, true},
|
||||
{"--quiet", []string{"--quiet", "migrate"}, Globals{Quiet: true, NoInteraction: true}, true},
|
||||
{"-n", []string{"migrate", "-n"}, Globals{NoInteraction: true}, true},
|
||||
{"--no-interaction", []string{"--no-interaction", "migrate"}, Globals{NoInteraction: true}, true},
|
||||
{"--ansi", []string{"migrate", "--ansi"}, Globals{Color: ColorAlways}, true},
|
||||
{"--no-ansi", []string{"--no-ansi", "migrate"}, Globals{Color: ColorNever}, true},
|
||||
{"--ansi then --no-ansi", []string{"--ansi", "migrate", "--no-ansi"}, Globals{Color: ColorNever}, true},
|
||||
{"--no-ansi then --ansi", []string{"--no-ansi", "migrate", "--ansi"}, Globals{Color: ColorAlways}, true},
|
||||
{"--env X", []string{"--env", "staging", "migrate"}, Globals{Env: "staging"}, true},
|
||||
{"--env=X", []string{"migrate", "--env=production"}, Globals{Env: "production"}, true},
|
||||
{"--env takes a dash value", []string{"migrate", "--env", "-v"}, Globals{Env: "-v"}, true},
|
||||
{"last --env wins", []string{"--env=a", "migrate", "--env", "b"}, Globals{Env: "b"}, true},
|
||||
{"stop at --", []string{"migrate", "--", "-v", "-q", "--env=x"}, Globals{}, true},
|
||||
{"combined -qn", []string{"migrate", "-qn"}, Globals{Quiet: true, NoInteraction: true}, true},
|
||||
{"combined -vq", []string{"-vq", "migrate"}, Globals{Verbosity: 1, Quiet: true, NoInteraction: true}, true},
|
||||
{"combined -nvv", []string{"migrate", "-nvv"}, Globals{Verbosity: 2, NoInteraction: true}, true},
|
||||
{"everything", []string{"-vv", "--env=dev", "migrate", "-n", "--no-ansi"}, Globals{Verbosity: 2, NoInteraction: true, Color: ColorNever, Env: "dev"}, true},
|
||||
{"unknown flags ignored", []string{"--bogus", "-x", "migrate", "-", "--env"}, Globals{}, false},
|
||||
{"--env without a value", []string{"migrate", "--env"}, Globals{}, false},
|
||||
{"empty", nil, Globals{}, false},
|
||||
}
|
||||
|
||||
// TestPeekGlobals: the tolerant pre-boot scan reads every global option
|
||||
// form, caps verbosity, lets the last color flag win, stops at -- and
|
||||
// never fails on unknown tokens.
|
||||
func TestPeekGlobals(t *testing.T) {
|
||||
for _, tc := range peekCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := PeekGlobals(tc.args); got != tc.want {
|
||||
t.Errorf("PeekGlobals(%q) = %+v, want %+v", tc.args, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestGlobalsArgs: Args re-serialises Globals so PeekGlobals reads them
|
||||
// back unchanged.
|
||||
func TestGlobalsArgs(t *testing.T) {
|
||||
cases := []struct {
|
||||
g Globals
|
||||
want string
|
||||
}{
|
||||
{Globals{}, ""},
|
||||
{Globals{Verbosity: 1}, "-v"},
|
||||
{Globals{Verbosity: 3}, "-vvv"},
|
||||
{Globals{Verbosity: 7}, "-vvv"},
|
||||
{Globals{Quiet: true, NoInteraction: true}, "-q"},
|
||||
{Globals{NoInteraction: true}, "-n"},
|
||||
{Globals{Color: ColorAlways}, "--ansi"},
|
||||
{Globals{Color: ColorNever}, "--no-ansi"},
|
||||
{Globals{Env: "staging"}, "--env=staging"},
|
||||
{Globals{Verbosity: 2, Quiet: true, NoInteraction: true, Color: ColorNever, Env: "dev"}, "-vv -q --no-ansi --env=dev"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
args := tc.g.Args()
|
||||
if got := strings.Join(args, " "); got != tc.want {
|
||||
t.Errorf("%+v.Args() = %q, want %q", tc.g, got, tc.want)
|
||||
}
|
||||
if back := PeekGlobals(args); back != tc.g.normalize() {
|
||||
t.Errorf("PeekGlobals(%q) = %+v, want %+v", args, back, tc.g.normalize())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestGlobalFlagsAgree: for every row cobra accepts, the OnGlobals hook
|
||||
// sees exactly the Globals PeekGlobals read before the root existed.
|
||||
func TestGlobalFlagsAgree(t *testing.T) {
|
||||
for _, tc := range peekCases {
|
||||
if !tc.cobra {
|
||||
continue
|
||||
}
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var hooked []Globals
|
||||
var seen Globals
|
||||
cmd := Command{Name: "migrate", Description: "Run the migrations", Run: func(_ context.Context, _ Input, out Output) error {
|
||||
seen = GlobalsOf(out)
|
||||
return nil
|
||||
}}
|
||||
var buf bytes.Buffer
|
||||
root, err := NewRootIO("acme", []Command{cmd}, strings.NewReader(""), &buf, &buf,
|
||||
WithVersion(goldenVersion), OnGlobals(func(g Globals) { hooked = append(hooked, g) }))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if code := Execute(context.Background(), root, slices.Clone(tc.args)); code != ExitOK {
|
||||
t.Fatalf("exit %d:\n%s", code, buf.String())
|
||||
}
|
||||
peek := PeekGlobals(tc.args)
|
||||
if len(hooked) != 1 || hooked[0] != peek {
|
||||
t.Errorf("hook saw %+v, PeekGlobals %+v", hooked, peek)
|
||||
}
|
||||
if seen != peek {
|
||||
t.Errorf("GlobalsOf(out) = %+v, PeekGlobals %+v", seen, peek)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
255
modules/bonfire/live_test.go
Normal file
255
modules/bonfire/live_test.go
Normal file
@@ -0,0 +1,255 @@
|
||||
package bonfire
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"github.com/charmbracelet/colorprofile"
|
||||
"github.com/charmbracelet/x/ansi"
|
||||
)
|
||||
|
||||
// TestLiveModels: the spinner, progress, process and selector models
|
||||
// render the right frames through Update and View, without a terminal; one
|
||||
// program runs end to end into a buffer with input off.
|
||||
func TestLiveModels(t *testing.T) {
|
||||
p := DefaultPalette()
|
||||
size := tea.WindowSizeMsg{Width: 80, Height: 24}
|
||||
|
||||
t.Run("spinner", func(t *testing.T) {
|
||||
var m tea.Model = newSpinnerModel(p, "Syncing")
|
||||
if m.Init() == nil {
|
||||
t.Fatal("spinner does not tick")
|
||||
}
|
||||
first := frame(m)
|
||||
m, cmd := m.Update(m.(spinnerModel).spin.Tick())
|
||||
if cmd == nil || frame(m) == first {
|
||||
t.Errorf("tick did not advance the spinner: %q -> %q", first, frame(m))
|
||||
}
|
||||
m, _ = m.Update(itemMsg{done: 1, total: 3, item: "post-1"})
|
||||
m, _ = m.Update(itemMsg{done: 2, total: 3, item: "post-2", err: errors.New("x")})
|
||||
v := frame(m)
|
||||
for _, want := range []string{"Syncing 2/3", "post-2…", "! 1 failed"} {
|
||||
if !strings.Contains(v, want) {
|
||||
t.Errorf("spinner frame lacks %q: %q", want, v)
|
||||
}
|
||||
}
|
||||
done, cmd := m.Update(finishMsg{})
|
||||
if cmd == nil || frame(done) != symCheck+" Syncing\n" {
|
||||
t.Errorf("success frame = %q", frame(done))
|
||||
}
|
||||
failed, _ := m.Update(finishMsg{err: errors.New("no")})
|
||||
if frame(failed) != symCross+" Syncing\n" {
|
||||
t.Errorf("failure frame = %q", frame(failed))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("progress", func(t *testing.T) {
|
||||
var m tea.Model = newProgressModel(p, 10)
|
||||
if m.Init() != nil {
|
||||
t.Error("progress has an init command")
|
||||
}
|
||||
m, _ = m.Update(tea.WindowSizeMsg{Width: 20, Height: 5})
|
||||
m, _ = m.Update(itemMsg{done: 0, total: 10})
|
||||
if v := frame(m); !strings.Contains(v, "0/10") {
|
||||
t.Errorf("empty bar = %q", v)
|
||||
}
|
||||
m, _ = m.Update(itemMsg{done: 4, total: 10, item: "row 4"})
|
||||
m, _ = m.Update(itemMsg{done: 5, total: 10})
|
||||
if v := frame(m); !strings.Contains(v, "5/10") || !strings.Contains(v, "row 4") {
|
||||
t.Errorf("Current kept across steps: %q", v)
|
||||
}
|
||||
m, _ = m.Update(itemMsg{done: 5, total: 10, item: "row 5", err: errors.New("bad")})
|
||||
if v := frame(m); !strings.Contains(v, "! 1 failed") || !strings.Contains(v, "row 5") {
|
||||
t.Errorf("Fail frame = %q", v)
|
||||
}
|
||||
m, cmd := m.Update(finishMsg{})
|
||||
v := frame(m)
|
||||
if cmd == nil || strings.Contains(v, "row 5") || !strings.HasSuffix(v, "\n") {
|
||||
t.Errorf("final frame = %q", v)
|
||||
}
|
||||
zero := newProgressModel(p, 0)
|
||||
if v := frame(zero); !strings.Contains(v, "0/0") {
|
||||
t.Errorf("zero total frame = %q", v)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("process", func(t *testing.T) {
|
||||
var m tea.Model = newProcessModel(p, "", []string{"blog", "shop", "forum"})
|
||||
m, _ = m.Update(size)
|
||||
v := frame(m)
|
||||
for _, name := range []string{"blog", "shop", "forum"} {
|
||||
if !strings.Contains(v, symWaiting+" "+name) {
|
||||
t.Errorf("pending row %s missing: %q", name, v)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(v, "Processing 0/3") || !strings.Contains(v, "0%") {
|
||||
t.Errorf("default operation label missing: %q", v)
|
||||
}
|
||||
m, _ = m.Update(stepMsg{item: "blog", state: itemRunning, done: 0})
|
||||
if v := frame(m); strings.Contains(v, symWaiting+" blog") {
|
||||
t.Errorf("running row still pending: %q", v)
|
||||
}
|
||||
m, _ = m.Update(stepMsg{item: "blog", state: itemDone, done: 1})
|
||||
m, _ = m.Update(stepMsg{item: "blog", state: itemRunning, done: 1})
|
||||
m, _ = m.Update(stepMsg{item: "shop", state: itemFailed, err: errors.New("rejected"), done: 2})
|
||||
v = frame(m)
|
||||
for _, want := range []string{symCheck + " blog", symCross + " shop rejected", symWaiting + " forum", "Processing 2/3", "66%"} {
|
||||
if !strings.Contains(v, want) {
|
||||
t.Errorf("process frame lacks %q:\n%s", want, v)
|
||||
}
|
||||
}
|
||||
m, cmd := m.Update(finishMsg{})
|
||||
if cmd == nil || !strings.HasSuffix(frame(m), "\n") {
|
||||
t.Errorf("final process frame = %q", frame(m))
|
||||
}
|
||||
|
||||
// Ten rows on a six-line terminal: three rows around the running one.
|
||||
var items []string
|
||||
for i := range 10 {
|
||||
items = append(items, fmt.Sprintf("item-%d", i))
|
||||
}
|
||||
var big tea.Model = newProcessModel(p, "Pushing", items)
|
||||
big, _ = big.Update(tea.WindowSizeMsg{Width: 80, Height: 6})
|
||||
big, _ = big.Update(stepMsg{item: "item-6", state: itemRunning, done: 6})
|
||||
v = frame(big)
|
||||
for _, want := range []string{"item-5", "item-6", "item-7"} {
|
||||
if !strings.Contains(v, want) {
|
||||
t.Errorf("window lacks %s:\n%s", want, v)
|
||||
}
|
||||
}
|
||||
for _, gone := range []string{"item-0", "item-4", "item-8"} {
|
||||
if strings.Contains(v, gone+"\n") || strings.Contains(v, gone+" ") {
|
||||
t.Errorf("window shows %s:\n%s", gone, v)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("selector", func(t *testing.T) {
|
||||
key := func(m tea.Model, msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { return m.Update(msg) }
|
||||
up, down := tea.KeyPressMsg{Code: tea.KeyUp}, tea.KeyPressMsg{Code: tea.KeyDown}
|
||||
enter, esc := tea.KeyPressMsg{Code: tea.KeyEnter}, tea.KeyPressMsg{Code: tea.KeyEscape}
|
||||
ctrlC := tea.KeyPressMsg{Code: 'c', Mod: tea.ModCtrl}
|
||||
|
||||
var m tea.Model = newSelectorModel(p, "Pick a plugin", []string{"blog", "shop", "forum"}, 1)
|
||||
m, _ = m.Update(size)
|
||||
if v := frame(m); !strings.Contains(v, "> shop") || !strings.Contains(v, "? Pick a plugin") {
|
||||
t.Errorf("default cursor frame = %q", v)
|
||||
}
|
||||
m, _ = key(m, up)
|
||||
m, _ = key(m, up)
|
||||
m, _ = key(m, tea.KeyPressMsg{Code: 'k', Text: "k"})
|
||||
if c := m.(selectorModel).cursor; c != 0 {
|
||||
t.Errorf("cursor moved above the top: %d", c)
|
||||
}
|
||||
for range 5 {
|
||||
m, _ = key(m, down)
|
||||
}
|
||||
if c := m.(selectorModel).cursor; c != 2 {
|
||||
t.Errorf("cursor moved below the bottom: %d", c)
|
||||
}
|
||||
m, _ = key(m, enter)
|
||||
if v := frame(m); !strings.Contains(v, "Selected:") || !strings.Contains(v, "forum") || !strings.Contains(v, "esc back") {
|
||||
t.Errorf("confirmation frame = %q", v)
|
||||
}
|
||||
m, _ = key(m, esc)
|
||||
if m.(selectorModel).confirming || m.(selectorModel).cancelled {
|
||||
t.Error("esc on the confirmation screen did not go back")
|
||||
}
|
||||
m, _ = key(m, tea.KeyPressMsg{Code: 'k', Text: "k"})
|
||||
m, _ = key(m, enter)
|
||||
m, cmd := key(m, enter)
|
||||
sel := m.(selectorModel)
|
||||
if cmd == nil || sel.chosen != 1 || sel.cancelled {
|
||||
t.Fatalf("selector chose %d (cancelled %v)", sel.chosen, sel.cancelled)
|
||||
}
|
||||
if v := frame(m); v != "? Pick a plugin shop\n" {
|
||||
t.Errorf("summary line = %q", v)
|
||||
}
|
||||
|
||||
for name, keys := range map[string][]tea.KeyPressMsg{
|
||||
"esc in the list": {esc},
|
||||
"ctrl+c in the list": {ctrlC},
|
||||
"ctrl+c on confirmation": {enter, ctrlC},
|
||||
} {
|
||||
var s tea.Model = newSelectorModel(p, "Pick", []string{"a", "b"}, 9)
|
||||
var cmd tea.Cmd
|
||||
for _, k := range keys {
|
||||
s, cmd = key(s, k)
|
||||
}
|
||||
if !s.(selectorModel).cancelled || cmd == nil || s.(selectorModel).chosen != -1 {
|
||||
t.Errorf("%s: not cancelled", name)
|
||||
}
|
||||
if v := frame(s); v != "? Pick\n" {
|
||||
t.Errorf("%s: summary line = %q", name, v)
|
||||
}
|
||||
}
|
||||
|
||||
// A short terminal shows a window that follows the cursor.
|
||||
var long []string
|
||||
for i := range 20 {
|
||||
long = append(long, fmt.Sprintf("choice-%02d", i))
|
||||
}
|
||||
var w tea.Model = newSelectorModel(p, "Pick", long, 0)
|
||||
w, _ = w.Update(tea.WindowSizeMsg{Width: 80, Height: 8})
|
||||
for range 10 {
|
||||
w, _ = key(w, down)
|
||||
}
|
||||
v := frame(w)
|
||||
if !strings.Contains(v, "> choice-10") || strings.Contains(v, "choice-00") || strings.Contains(v, "choice-11") {
|
||||
t.Errorf("selector window:\n%s", v)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("print above", func(t *testing.T) {
|
||||
var m tea.Model = liveModel{inner: newSpinnerModel(p, "x")}
|
||||
before := frame(m)
|
||||
m, cmd := m.Update(printAboveMsg("a log line"))
|
||||
if cmd == nil || frame(m) != before {
|
||||
t.Error("printAboveMsg did not become a Println command or reached the inner model")
|
||||
}
|
||||
m, _ = m.Update(itemMsg{done: 1, total: 2})
|
||||
if !strings.Contains(frame(m), "1/2") {
|
||||
t.Errorf("other messages do not reach the inner model: %q", frame(m))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("program end to end", func(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
prog := tea.NewProgram(liveModel{inner: newSpinnerModel(p, "Importing posts")},
|
||||
tea.WithInput(nil),
|
||||
tea.WithOutput(&buf),
|
||||
tea.WithoutSignalHandler(),
|
||||
tea.WithWindowSize(80, 24),
|
||||
tea.WithColorProfile(colorprofile.NoTTY),
|
||||
)
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := prog.Run()
|
||||
done <- err
|
||||
}()
|
||||
prog.Send(itemMsg{done: 1, total: 2, item: "first"})
|
||||
prog.Send(printAboveMsg("imported first"))
|
||||
prog.Send(itemMsg{done: 2, total: 2, item: "second"})
|
||||
prog.Send(finishMsg{})
|
||||
select {
|
||||
case err := <-done:
|
||||
if err != nil {
|
||||
t.Fatalf("program: %v", err)
|
||||
}
|
||||
case <-time.After(10 * time.Second):
|
||||
prog.Kill()
|
||||
t.Fatal("program did not finish")
|
||||
}
|
||||
out := ansi.Strip(buf.String())
|
||||
for _, want := range []string{"imported first", symCheck + " Importing posts"} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("program output lacks %q:\n%q", want, out)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
105
modules/bonfire/root_test.go
Normal file
105
modules/bonfire/root_test.go
Normal file
@@ -0,0 +1,105 @@
|
||||
package bonfire
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func newTestRoot(commands []Command, opts ...RootOption) error {
|
||||
var buf bytes.Buffer
|
||||
_, err := NewRootIO("acme", commands, strings.NewReader(""), &buf, &buf, opts...)
|
||||
return err
|
||||
}
|
||||
|
||||
// TestReservedFlags: a command flag that reuses a global option's name or
|
||||
// shorthand fails NewRoot with an error naming the command and the flag,
|
||||
// without a panic; Call refuses it the same way.
|
||||
func TestReservedFlags(t *testing.T) {
|
||||
for _, name := range []string{"verbose", "quiet", "ansi", "no-ansi", "no-interaction", "env", "help"} {
|
||||
cmd := Command{Name: "acme:loud", Flags: []Flag{{Name: name}}}
|
||||
err := newTestRoot([]Command{cmd})
|
||||
if !errors.Is(err, ErrReservedFlag) || !strings.Contains(err.Error(), `"acme:loud"`) || !strings.Contains(err.Error(), "--"+name) {
|
||||
t.Errorf("--%s: err = %v", name, err)
|
||||
}
|
||||
if err := Call(context.Background(), []Command{cmd}, "acme:loud", nil, nil); !errors.Is(err, ErrReservedFlag) {
|
||||
t.Errorf("Call with --%s: err = %v", name, err)
|
||||
}
|
||||
}
|
||||
for _, short := range []string{"v", "q", "n", "h"} {
|
||||
cmd := Command{Name: "acme:loud", Flags: []Flag{{Name: "volume", Shorthand: short}}}
|
||||
err := newTestRoot([]Command{cmd})
|
||||
if !errors.Is(err, ErrReservedFlag) || !strings.Contains(err.Error(), "-"+short+" for --volume") || !strings.Contains(err.Error(), `"acme:loud"`) {
|
||||
t.Errorf("-%s: err = %v", short, err)
|
||||
}
|
||||
}
|
||||
// Other names and shorthands are fine, repeatable and bare ones too.
|
||||
ok := Command{Name: "acme:loud", Flags: []Flag{
|
||||
{Name: "volume", Shorthand: "V"}, {Name: "environment", Shorthand: "e"},
|
||||
{Name: "tag", Shorthand: "t", Repeatable: true}, {Name: "force", Shorthand: "f", Bare: true},
|
||||
}}
|
||||
if err := newTestRoot([]Command{ok}); err != nil {
|
||||
t.Errorf("unreserved flags refused: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDuplicateNames: two commands with one name, or a command named list
|
||||
// or help, fail NewRoot with ErrDuplicateCommand naming it.
|
||||
func TestDuplicateNames(t *testing.T) {
|
||||
cases := map[string][]Command{
|
||||
"acme:sync": {{Name: "acme:sync"}, {Name: "acme:other"}, {Name: "acme:sync"}},
|
||||
"list": {{Name: "list"}},
|
||||
"help": {{Name: "acme:x"}, {Name: "help"}},
|
||||
}
|
||||
for name, commands := range cases {
|
||||
err := newTestRoot(commands)
|
||||
if !errors.Is(err, ErrDuplicateCommand) || !strings.Contains(err.Error(), `"`+name+`"`) {
|
||||
t.Errorf("%s: err = %v", name, err)
|
||||
}
|
||||
}
|
||||
if err := newTestRoot([]Command{{Name: "acme:list"}, {Name: "list:all"}, {Name: "acme:help"}}); err != nil {
|
||||
t.Errorf("names that only contain list or help refused: %v", err)
|
||||
}
|
||||
if err := newTestRoot([]Command{{Name: "nonamespace"}}); !errors.Is(err, ErrCommandName) {
|
||||
t.Errorf("a plugin command without a namespace: err = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCallNoGlobalsHook: a root built by Call runs no OnGlobals hook, even
|
||||
// when called from a command of a root that has one, and the called
|
||||
// command still sees the globals passed to it.
|
||||
func TestCallNoGlobalsHook(t *testing.T) {
|
||||
var hooked []Globals
|
||||
var inner Globals
|
||||
target := Command{Name: "acme:inner", Description: "Inner", Run: func(_ context.Context, _ Input, out Output) error {
|
||||
inner = GlobalsOf(out)
|
||||
return nil
|
||||
}}
|
||||
outer := Command{Name: "acme:outer", Description: "Outer", Run: func(ctx context.Context, _ Input, out Output) error {
|
||||
return NewCatalog([]Command{target}).Call(ctx, "acme:inner", []string{"-vvv", "--env=other"}, out)
|
||||
}}
|
||||
var buf bytes.Buffer
|
||||
root, err := NewRootIO("acme", []Command{outer, target}, strings.NewReader(""), &buf, &buf,
|
||||
OnGlobals(func(g Globals) { hooked = append(hooked, g) }))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if code := Execute(context.Background(), root, []string{"-v", "acme:outer"}); code != ExitOK {
|
||||
t.Fatalf("exit %d:\n%s", code, buf.String())
|
||||
}
|
||||
if len(hooked) != 1 || hooked[0].Verbosity != 1 || hooked[0].Env != "" {
|
||||
t.Errorf("hooks = %+v, want one call with the outer -v", hooked)
|
||||
}
|
||||
if inner.Verbosity != 3 || inner.Env != "other" {
|
||||
t.Errorf("called command saw %+v, want -vvv --env=other", inner)
|
||||
}
|
||||
if err := Call(context.Background(), []Command{target}, "acme:missing", nil, nil); !errors.Is(err, ErrUnknownCommand) {
|
||||
t.Errorf("Call of a missing command: %v", err)
|
||||
}
|
||||
var nilCatalog *Catalog
|
||||
if nilCatalog.Has("acme:inner") || !errors.Is(nilCatalog.Call(context.Background(), "acme:inner", nil, nil), ErrUnknownCommand) {
|
||||
t.Error("a nil Catalog has commands")
|
||||
}
|
||||
}
|
||||
297
modules/bonfire/widgets_test.go
Normal file
297
modules/bonfire/widgets_test.go
Normal file
@@ -0,0 +1,297 @@
|
||||
package bonfire
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/charmbracelet/x/ansi"
|
||||
)
|
||||
|
||||
// noEnv is a lookup with no environment variables set.
|
||||
func noEnv(string) string { return "" }
|
||||
|
||||
// exerciseWidgets runs every widget once on c: status lines, a spinner, a
|
||||
// progress bar of four steps, the process view over a, b (failing) and c,
|
||||
// a table, a data line and an error.
|
||||
func exerciseWidgets(t *testing.T, c *console) {
|
||||
t.Helper()
|
||||
c.Info("info line")
|
||||
c.Success("success line")
|
||||
c.Warning("warning line")
|
||||
if err := c.Spinner("spinning", func() error { return nil }); err != nil {
|
||||
t.Fatalf("Spinner: %v", err)
|
||||
}
|
||||
if err := c.Progress(4, func(p Progress) error {
|
||||
for range 4 {
|
||||
p.Advance(1)
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
t.Fatalf("Progress: %v", err)
|
||||
}
|
||||
err := Process(context.Background(), c, "Importing", []string{"a", "b", "c"}, func(_ context.Context, item string) error {
|
||||
if item == "b" {
|
||||
return errors.New("bad row")
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "b: bad row") {
|
||||
t.Fatalf("Process err = %v", err)
|
||||
}
|
||||
c.Table([]string{"key", "value"}, [][]string{{"k1", "v1"}, {"k2"}})
|
||||
c.Println("data line")
|
||||
c.Error("boom")
|
||||
}
|
||||
|
||||
// TestWidgetMatrix: every widget degrades by mode. Not a terminal gives
|
||||
// the pre-phase plain lines byte for byte; -q keeps data and errors only;
|
||||
// --no-ansi and -n on a terminal print plain lines (no live view), and
|
||||
// --no-ansi writes no escape codes; a terminal runs the live views.
|
||||
func TestWidgetMatrix(t *testing.T) {
|
||||
type streams struct{ out, err bytes.Buffer }
|
||||
newC := func(s *streams, tty bool, g Globals) *console {
|
||||
c := newConsole(strings.NewReader(""), &s.out, &s.err, noEnv, boolPtr(tty), boolPtr(tty))
|
||||
c.setGlobals(g)
|
||||
return c
|
||||
}
|
||||
|
||||
t.Run("not a terminal", func(t *testing.T) {
|
||||
var s streams
|
||||
exerciseWidgets(t, newC(&s, false, Globals{}))
|
||||
wantOut := "ℹ info line\n✓ success line\n⚠ warning line\n" +
|
||||
"[...] spinning\n" +
|
||||
"[2/4] 50%\n[4/4] 100%\n" +
|
||||
"✓ a\n✓ c\n" +
|
||||
"key\tvalue\nk1\tv1\nk2\t\n" +
|
||||
"data line\n"
|
||||
if got := s.out.String(); got != wantOut {
|
||||
t.Errorf("stdout:\n got %q\nwant %q", got, wantOut)
|
||||
}
|
||||
if got, want := s.err.String(), "✗ b: bad row\n✗ boom\n"; got != want {
|
||||
t.Errorf("stderr:\n got %q\nwant %q", got, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("quiet", func(t *testing.T) {
|
||||
for _, tty := range []bool{false, true} {
|
||||
var s streams
|
||||
exerciseWidgets(t, newC(&s, tty, Globals{Quiet: true, NoInteraction: true}))
|
||||
out := ansi.Strip(s.out.String())
|
||||
for _, gone := range []string{"info line", "success line", "warning line", "spinning", "[4/4]", "4/4", "✓ a", "Importing"} {
|
||||
if strings.Contains(out, gone) {
|
||||
t.Errorf("tty %v: -q printed %q:\n%s", tty, gone, out)
|
||||
}
|
||||
}
|
||||
for _, kept := range []string{"k1", "v1", "data line\n"} {
|
||||
if !strings.Contains(out, kept) {
|
||||
t.Errorf("tty %v: -q dropped data %q:\n%s", tty, kept, out)
|
||||
}
|
||||
}
|
||||
errOut := ansi.Strip(s.err.String())
|
||||
if !strings.Contains(errOut, "✗ b: bad row") || !strings.Contains(errOut, "✗ boom") {
|
||||
t.Errorf("tty %v: -q dropped errors: %q", tty, errOut)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("no-ansi on a terminal", func(t *testing.T) {
|
||||
var s streams
|
||||
exerciseWidgets(t, newC(&s, true, Globals{Color: ColorNever}))
|
||||
out := s.out.String()
|
||||
if strings.Contains(out, "\x1b") || strings.Contains(s.err.String(), "\x1b") {
|
||||
t.Errorf("--no-ansi wrote escape codes:\n%q\n%q", out, s.err.String())
|
||||
}
|
||||
for _, want := range []string{"ℹ info line\n", "[...] spinning\n", "[2/4] 50%\n", "✓ a\n", "data line\n", "┌"} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("--no-ansi lacks %q:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("no-interaction on a terminal", func(t *testing.T) {
|
||||
var s streams
|
||||
exerciseWidgets(t, newC(&s, true, Globals{NoInteraction: true}))
|
||||
out := s.out.String()
|
||||
if strings.Contains(out, "\x1b[?25l") {
|
||||
t.Errorf("-n ran a live view (cursor hidden):\n%q", out)
|
||||
}
|
||||
plain := ansi.Strip(out)
|
||||
for _, want := range []string{"ℹ info line\n", "[...] spinning\n", "[4/4] 100%\n", "✓ a\n", "data line\n"} {
|
||||
if !strings.Contains(plain, want) {
|
||||
t.Errorf("-n lacks %q:\n%s", want, plain)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(out, "\x1b[") {
|
||||
t.Errorf("-n on a color terminal lost its colors:\n%q", out)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("terminal", func(t *testing.T) {
|
||||
var s streams
|
||||
c := newC(&s, true, Globals{})
|
||||
if !c.live() {
|
||||
t.Fatal("a terminal console without flags does not animate")
|
||||
}
|
||||
exerciseWidgets(t, c)
|
||||
// The live views ran as bubbletea programs (the renderer hides the
|
||||
// cursor). A buffer has no window size, so the renderer draws no
|
||||
// frames here; TestLiveModels renders them with a fixed size.
|
||||
if n := strings.Count(s.out.String(), "\x1b[?25l"); n != 3 {
|
||||
t.Errorf("want 3 live views (spinner, progress, process), cursor hidden %d times:\n%q", n, s.out.String())
|
||||
}
|
||||
out := ansi.Strip(s.out.String())
|
||||
for _, want := range []string{"ℹ info line", "┌", "k1", "data line"} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("terminal output lacks %q:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
if strings.Contains(out, "[...] spinning") || strings.Contains(out, "[2/4] 50%") || strings.Contains(out, "✓ a\n") {
|
||||
t.Errorf("terminal printed the plain fallback lines:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(ansi.Strip(s.err.String()), "✗ boom") {
|
||||
t.Errorf("terminal error line missing: %q", s.err.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestPromptsNoInteraction: with -n or -q every prompt returns its default
|
||||
// without reading stdin or printing, even on an interactive terminal.
|
||||
func TestPromptsNoInteraction(t *testing.T) {
|
||||
for _, g := range []Globals{{NoInteraction: true}, {Quiet: true, NoInteraction: true}} {
|
||||
in := strings.NewReader("typed\n")
|
||||
var buf bytes.Buffer
|
||||
c := newConsole(in, &buf, &buf, noEnv, boolPtr(true), boolPtr(true))
|
||||
c.setGlobals(g)
|
||||
if got, err := c.Ask("name", "bob"); err != nil || got != "bob" {
|
||||
t.Errorf("%+v: Ask = %q, %v", g, got, err)
|
||||
}
|
||||
for _, def := range []bool{true, false} {
|
||||
if got, err := c.Confirm("sure?", def); err != nil || got != def {
|
||||
t.Errorf("%+v: Confirm(def %v) = %v, %v", g, def, got, err)
|
||||
}
|
||||
}
|
||||
choices := []string{"a", "b", "c"}
|
||||
for def, want := range map[int]string{0: "a", 2: "c", 5: "a", -1: "a"} {
|
||||
if got, err := c.Choice("pick", choices, def); err != nil || got != want {
|
||||
t.Errorf("%+v: Choice(def %d) = %q, %v; want %q", g, def, got, err, want)
|
||||
}
|
||||
}
|
||||
if _, err := c.Choice("pick", nil, 0); err == nil {
|
||||
t.Errorf("%+v: Choice without options did not fail", g)
|
||||
}
|
||||
if in.Len() != len("typed\n") || buf.Len() != 0 {
|
||||
t.Errorf("%+v: prompts read stdin or printed: left %d, out %q", g, in.Len(), buf.String())
|
||||
}
|
||||
if Interactive(c) {
|
||||
t.Errorf("%+v: Interactive is true", g)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSecretWithNoInteraction: under -n a secret is read from piped stdin
|
||||
// (empty on EOF); with a terminal stdin it fails with a hint naming the
|
||||
// piped form, because a secret has no default.
|
||||
func TestSecretWithNoInteraction(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
c := newConsole(strings.NewReader(" s3cret-value \nnext\n"), &buf, &buf, noEnv, boolPtr(false), boolPtr(false))
|
||||
c.setGlobals(Globals{NoInteraction: true})
|
||||
if got, err := c.Secret("password"); err != nil || got != "s3cret-value" {
|
||||
t.Fatalf("piped Secret = %q, %v", got, err)
|
||||
}
|
||||
if buf.Len() != 0 {
|
||||
t.Errorf("piped Secret printed a prompt: %q", buf.String())
|
||||
}
|
||||
empty := newConsole(strings.NewReader(""), &buf, &buf, noEnv, boolPtr(false), boolPtr(false))
|
||||
empty.setGlobals(Globals{NoInteraction: true})
|
||||
if got, err := empty.Secret("password"); err != nil || got != "" {
|
||||
t.Errorf("Secret on empty stdin = %q, %v", got, err)
|
||||
}
|
||||
|
||||
term := newConsole(strings.NewReader("never read\n"), &buf, &buf, noEnv, boolPtr(true), boolPtr(true))
|
||||
term.setGlobals(Globals{NoInteraction: true})
|
||||
term.inTTY = true
|
||||
_, err := term.Secret("password")
|
||||
var hint *HintError
|
||||
if !errors.As(err, &hint) || !errors.Is(err, errSecretNoInteraction) {
|
||||
t.Fatalf("terminal Secret under -n: %v", err)
|
||||
}
|
||||
var block bytes.Buffer
|
||||
RenderError(&block, "acme", err, Globals{Color: ColorNever})
|
||||
if !strings.Contains(block.String(), `printf '%s\n' "$VALUE" | bin/acme <command> -n`) {
|
||||
t.Errorf("hint does not show the piped form:\n%s", block.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestConfirmProceed: off a terminal, under -n and for an Output that is
|
||||
// not a bonfire console it proceeds without reading stdin; on an
|
||||
// interactive terminal it asks, defaulting to no.
|
||||
func TestConfirmProceed(t *testing.T) {
|
||||
in := strings.NewReader("n\n")
|
||||
var buf bytes.Buffer
|
||||
for name, c := range map[string]*console{
|
||||
"not a terminal": newConsole(in, &buf, &buf, noEnv, boolPtr(false), boolPtr(false)),
|
||||
"-n": newConsole(in, &buf, &buf, noEnv, boolPtr(true), boolPtr(true)),
|
||||
} {
|
||||
if name == "-n" {
|
||||
c.setGlobals(Globals{NoInteraction: true})
|
||||
}
|
||||
if ok, err := ConfirmProceed(c, "Drop every table?"); err != nil || !ok {
|
||||
t.Errorf("%s: ConfirmProceed = %v, %v", name, ok, err)
|
||||
}
|
||||
}
|
||||
if in.Len() != 2 || buf.Len() != 0 {
|
||||
t.Errorf("ConfirmProceed read stdin or printed: left %d, out %q", in.Len(), buf.String())
|
||||
}
|
||||
if ok, err := ConfirmProceed(struct{ Output }{}, "Drop?"); err != nil || !ok {
|
||||
t.Errorf("foreign Output: ConfirmProceed = %v, %v", ok, err)
|
||||
}
|
||||
|
||||
for answer, want := range map[string]bool{"n\n": false, "y\n": true, "yes\n": true, "\n": false, "": false} {
|
||||
var out bytes.Buffer
|
||||
c := newConsole(strings.NewReader(answer), &out, &out, noEnv, boolPtr(true), boolPtr(true))
|
||||
// --no-ansi keeps the line prompt on a terminal (huh needs a real
|
||||
// terminal).
|
||||
c.setGlobals(Globals{Color: ColorNever})
|
||||
ok, err := ConfirmProceed(c, "Drop every table?")
|
||||
if err != nil || ok != want {
|
||||
t.Errorf("answer %q: ConfirmProceed = %v, %v; want %v", answer, ok, err, want)
|
||||
}
|
||||
if !strings.Contains(out.String(), "Drop every table? [y/N]") {
|
||||
t.Errorf("answer %q: question not asked: %q", answer, out.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestPrintAbove: without a live view PrintAbove reports false; while one
|
||||
// runs, the line (and Println output) prints above it.
|
||||
func TestPrintAbove(t *testing.T) {
|
||||
if PrintAbove("nobody listens") {
|
||||
t.Fatal("PrintAbove reported a running view")
|
||||
}
|
||||
var out, errW bytes.Buffer
|
||||
c := newConsole(strings.NewReader(""), &out, &errW, noEnv, boolPtr(true), boolPtr(true))
|
||||
var during bool
|
||||
err := c.Spinner("working", func() error {
|
||||
during = PrintAbove("log line above the view")
|
||||
c.Println("println above the view")
|
||||
return nil
|
||||
})
|
||||
if err != nil || !during {
|
||||
t.Fatalf("Spinner err %v, PrintAbove during the view %v", err, during)
|
||||
}
|
||||
if PrintAbove("after") {
|
||||
t.Error("PrintAbove reported a view after it ended")
|
||||
}
|
||||
got := ansi.Strip(out.String())
|
||||
for _, want := range []string{"log line above the view", "println above the view"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("output lacks %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
if strings.Contains(got, "[...] working") {
|
||||
t.Errorf("the spinner did not run as a live view:\n%s", got)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user