- Spinner, gradient progress bar and the multi-row process view ported from SSU to bubbletea v2 (input off, no signal handler, command context); boxed tables through lipgloss/table - Prompts use huh fields and a single-select port of SSU's selector with its confirmation screen on an interactive terminal; line prompts off a terminal and under --no-ansi are unchanged (piped passwords still work) - -q hides decoration but keeps data and errors; -n returns defaults and Secret fails with a hint on a terminal stdin; --no-ansi and non-TTY runs print today's plain lines - Helpers: GlobalsOf, VerbosityOf, IsQuiet, Interactive, ConfirmProceed, Process, Current, Failed, PrintAbove, ItemProgress, ErrAborted - diary's styled console lines print above a running live view
185 lines
6.5 KiB
Go
185 lines
6.5 KiB
Go
package bonfire
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"strings"
|
|
"testing"
|
|
|
|
tea "charm.land/bubbletea/v2"
|
|
"github.com/charmbracelet/x/ansi"
|
|
)
|
|
|
|
// frame is a model's view without escape codes.
|
|
func frame(m tea.Model) string { return ansi.Strip(m.View().Content) }
|
|
|
|
// TestWidgetMatrixSmoke: -q keeps data and errors but drops decoration, -n
|
|
// makes prompts return their defaults without reading stdin (Secret with a
|
|
// terminal stdin fails with a hint), ConfirmProceed never asks off a
|
|
// terminal, and the ported live models render frames from Update and View.
|
|
func TestWidgetMatrixSmoke(t *testing.T) {
|
|
t.Run("quiet", func(t *testing.T) {
|
|
var stdout, stderr bytes.Buffer
|
|
c := newConsole(strings.NewReader(""), &stdout, &stderr, func(string) string { return "" }, boolPtr(true), boolPtr(false))
|
|
c.setGlobals(Globals{Quiet: true, NoInteraction: true})
|
|
c.Info("info")
|
|
c.Success("success")
|
|
c.Warning("warning")
|
|
if err := c.Spinner("spinning", func() error { return nil }); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := c.Progress(4, func(p Progress) error {
|
|
for range 4 {
|
|
p.Advance(1)
|
|
}
|
|
Failed(p, "item-2", errors.New("broken"))
|
|
return nil
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
c.Println("data line")
|
|
c.Table([]string{"key"}, [][]string{{"value"}})
|
|
c.Error("boom")
|
|
out := stdout.String()
|
|
for _, gone := range []string{"info", "success", "warning", "spinning", "[4/4]"} {
|
|
if strings.Contains(out, gone) {
|
|
t.Fatalf("-q printed %q:\n%s", gone, out)
|
|
}
|
|
}
|
|
if !strings.Contains(out, "data line\n") || !strings.Contains(out, "value") {
|
|
t.Fatalf("-q dropped data:\n%s", out)
|
|
}
|
|
if !strings.Contains(stderr.String(), "boom") || !strings.Contains(stderr.String(), "item-2: broken") {
|
|
t.Fatalf("-q dropped errors: %q", stderr.String())
|
|
}
|
|
})
|
|
|
|
t.Run("no-interaction", func(t *testing.T) {
|
|
in := strings.NewReader("typed\n")
|
|
var buf bytes.Buffer
|
|
c := newConsole(in, &buf, &buf, func(string) string { return "" }, boolPtr(true), boolPtr(true))
|
|
c.setGlobals(Globals{NoInteraction: true})
|
|
if got, err := c.Ask("name", "bob"); err != nil || got != "bob" {
|
|
t.Fatalf("Ask = %q, %v", got, err)
|
|
}
|
|
if got, err := c.Confirm("sure?", true); err != nil || !got {
|
|
t.Fatalf("Confirm = %v, %v", got, err)
|
|
}
|
|
if got, err := c.Choice("pick", []string{"a", "b", "c"}, 2); err != nil || got != "c" {
|
|
t.Fatalf("Choice = %q, %v", got, err)
|
|
}
|
|
if in.Len() != len("typed\n") || buf.Len() != 0 {
|
|
t.Fatalf("-n prompts read stdin or printed: left %d, out %q", in.Len(), buf.String())
|
|
}
|
|
if ok, err := ConfirmProceed(c, "Drop everything?"); err != nil || !ok {
|
|
t.Fatalf("ConfirmProceed = %v, %v", ok, err)
|
|
}
|
|
if Interactive(c) || !GlobalsOf(c).NoInteraction {
|
|
t.Fatal("console with -n reports interactive")
|
|
}
|
|
|
|
c.inTTY = true
|
|
_, err := c.Secret("password")
|
|
var hint *HintError
|
|
if !errors.As(err, &hint) || !strings.Contains(hint.Try, "-n") {
|
|
t.Fatalf("Secret with a terminal stdin under -n: %v", err)
|
|
}
|
|
c.inTTY = false
|
|
if got, err := c.Secret("password"); err != nil || got != "typed" {
|
|
t.Fatalf("Secret with piped stdin under -n = %q, %v", got, err)
|
|
}
|
|
})
|
|
|
|
t.Run("not interactive", func(t *testing.T) {
|
|
in := strings.NewReader("y\n")
|
|
var buf bytes.Buffer
|
|
out := NewOutput(in, &buf, &buf)
|
|
if ok, err := ConfirmProceed(out, "Drop everything?"); err != nil || !ok || in.Len() != 2 {
|
|
t.Fatalf("ConfirmProceed off a terminal = %v, %v (stdin left %d)", ok, err, in.Len())
|
|
}
|
|
if VerbosityOf(out) != 0 || IsQuiet(out) {
|
|
t.Fatal("zero globals expected")
|
|
}
|
|
var lines bytes.Buffer
|
|
errOut := NewOutput(strings.NewReader(""), &lines, &lines)
|
|
err := Process(context.Background(), errOut, "Importing", []string{"a", "b"}, 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)
|
|
}
|
|
if got := lines.String(); got != "✓ a\n✗ b: bad row\n" {
|
|
t.Fatalf("Process lines = %q", got)
|
|
}
|
|
})
|
|
|
|
size := tea.WindowSizeMsg{Width: 80, Height: 24}
|
|
p := DefaultPalette()
|
|
|
|
t.Run("spinner model", func(t *testing.T) {
|
|
var m tea.Model = newSpinnerModel(p, "Importing posts")
|
|
m, _ = m.Update(size)
|
|
m, _ = m.Update(itemMsg{done: 2, total: 5, item: "post-2"})
|
|
if v := frame(m); !strings.Contains(v, "Importing posts 2/5") || !strings.Contains(v, "post-2") {
|
|
t.Fatalf("spinner frame = %q", v)
|
|
}
|
|
m, cmd := m.Update(finishMsg{})
|
|
if cmd == nil || !strings.Contains(frame(m), symCheck+" Importing posts") {
|
|
t.Fatalf("spinner final frame = %q", frame(m))
|
|
}
|
|
})
|
|
|
|
t.Run("progress model", func(t *testing.T) {
|
|
var m tea.Model = newProgressModel(p, 10)
|
|
m, _ = m.Update(size)
|
|
m, _ = m.Update(itemMsg{done: 4, total: 10, item: "row 4"})
|
|
if v := frame(m); !strings.Contains(v, "4/10") || !strings.Contains(v, "row 4") {
|
|
t.Fatalf("progress frame = %q", v)
|
|
}
|
|
})
|
|
|
|
t.Run("process model", func(t *testing.T) {
|
|
var m tea.Model = newProcessModel(p, "Pushing", []string{"blog", "shop", "forum"})
|
|
m, _ = m.Update(size)
|
|
m, _ = m.Update(stepMsg{item: "blog", state: itemDone, done: 1})
|
|
m, _ = m.Update(stepMsg{item: "shop", state: itemFailed, err: errors.New("rejected"), done: 2})
|
|
m, _ = m.Update(stepMsg{item: "forum", state: itemRunning, done: 2})
|
|
v := frame(m)
|
|
for _, want := range []string{symCheck + " blog", symCross + " shop", "rejected", "forum", "Pushing 2/3"} {
|
|
if !strings.Contains(v, want) {
|
|
t.Fatalf("process frame missing %q:\n%s", want, v)
|
|
}
|
|
}
|
|
})
|
|
|
|
t.Run("selector model", func(t *testing.T) {
|
|
var m tea.Model = newSelectorModel(p, "Pick a plugin", []string{"blog", "shop", "forum"}, 0)
|
|
m, _ = m.Update(size)
|
|
if v := frame(m); !strings.Contains(v, "> blog") {
|
|
t.Fatalf("selector frame = %q", v)
|
|
}
|
|
m, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyDown})
|
|
m, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
|
|
if v := frame(m); !strings.Contains(v, "Selected:") || !strings.Contains(v, "shop") {
|
|
t.Fatalf("confirmation frame = %q", v)
|
|
}
|
|
m, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyEscape})
|
|
m, _ = m.Update(tea.KeyPressMsg{Code: 'j', Text: "j"})
|
|
m, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
|
|
m, cmd := m.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
|
|
sel := m.(selectorModel)
|
|
if cmd == nil || sel.chosen != 2 || sel.items[sel.chosen] != "forum" {
|
|
t.Fatalf("selector chose %d (cmd %v)", sel.chosen, cmd != nil)
|
|
}
|
|
var c tea.Model = newSelectorModel(p, "Pick", []string{"a"}, 0)
|
|
c, _ = c.Update(tea.KeyPressMsg{Code: tea.KeyEscape})
|
|
if !c.(selectorModel).cancelled {
|
|
t.Fatal("esc did not cancel")
|
|
}
|
|
})
|
|
}
|