diff --git a/exp/teatest/v2/app_test.go b/exp/teatest/v2/app_test.go index 93fbe2c4..4d4cfda9 100644 --- a/exp/teatest/v2/app_test.go +++ b/exp/teatest/v2/app_test.go @@ -1,10 +1,10 @@ package teatest_test import ( - "bytes" "fmt" "io" "regexp" + "strings" "testing" "time" @@ -35,9 +35,44 @@ func TestApp(t *testing.T) { t.Fatal(err) } - out := readBts(t, tm.FinalOutput(t, teatest.WithFinalTimeout(time.Second))) - if !regexp.MustCompile(`This program will exit in \d+ seconds`).Match(out) { - t.Fatalf("output does not match the given regular expression: %s", string(out)) + out := teatest.TrimEmptyLines(tm.FinalOutput(t, teatest.WithFinalTimeout(time.Second))) + if !regexp.MustCompile(`This program will exit in \d+ seconds`).MatchString(out) { + t.Fatalf("output does not match the given regular expression: %q", out) + } + teatest.RequireEqualOutput(t, out) + + if tm.FinalModel(t).(model) != 9 { + t.Errorf("expected model to be 10, was %d", m) + } +} + +func TestAppAltScreen(t *testing.T) { + m := model(10) + tm := teatest.NewTestModel( + t, m, + teatest.WithInitialTermSize(70, 30), + teatest.WithProgramOptions(tea.WithAltScreen()), + ) + t.Cleanup(func() { + if err := tm.Quit(); err != nil { + t.Fatal(err) + } + }) + + time.Sleep(time.Second + time.Millisecond*200) + tm.Type("I'm typing things, but it'll be ignored by my program") + tm.Send("ignored msg") + tm.Send(tea.KeyPressMsg{ + Code: tea.KeyEnter, + }) + + if err := tm.Quit(); err != nil { + t.Fatal(err) + } + + out := teatest.TrimEmptyLines(tm.FinalOutput(t, teatest.WithFinalTimeout(time.Second))) + if !regexp.MustCompile(`This program will exit in \d+ seconds`).MatchString(out) { + t.Fatalf("output does not match the given regular expression: %q", out) } teatest.RequireEqualOutput(t, out) @@ -56,12 +91,12 @@ func TestAppInteractive(t *testing.T) { time.Sleep(time.Second + time.Millisecond*200) tm.Send("ignored msg") - if bts := readBts(t, tm.Output()); !bytes.Contains(bts, []byte("This program will exit in 9 seconds")) { - t.Fatalf("output does not match: expected %q", string(bts)) + if s := tm.Output(); !strings.Contains(s, "This program will exit in 9 seconds") { + t.Fatalf("output does not match: expected %q", string(s)) } - teatest.WaitFor(t, tm.Output(), func(out []byte) bool { - return bytes.Contains(out, []byte("This program will exit in 7 seconds")) + teatest.WaitForOutput(t, tm, func(s string) bool { + return strings.Contains(s, "This program will exit in 7 seconds") }, teatest.WithDuration(5*time.Second), teatest.WithCheckInterval(time.Millisecond*10)) tm.Send(tea.KeyPressMsg{ diff --git a/exp/teatest/v2/go.mod b/exp/teatest/v2/go.mod index d978c6d2..1d580468 100644 --- a/exp/teatest/v2/go.mod +++ b/exp/teatest/v2/go.mod @@ -3,25 +3,24 @@ module github.com/charmbracelet/x/exp/teatest/v2 go 1.19 require ( - github.com/charmbracelet/bubbletea/v2 v2.0.0-alpha.1 + github.com/charmbracelet/bubbletea/v2 v2.0.0-alpha.2.0.20241114171136-a07eb04402c5 + github.com/charmbracelet/x/cellbuf v0.0.6-0.20241114164159-aea15a2cc929 github.com/charmbracelet/x/exp/golden v0.0.0-20240806155701-69247e0abc2a + github.com/charmbracelet/x/vt v0.0.0-20241114164159-aea15a2cc929 ) require ( - github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/aymanbagabas/go-udiff v0.2.0 // indirect - github.com/charmbracelet/lipgloss v0.13.0 // indirect - github.com/charmbracelet/x/ansi v0.3.2 // indirect - github.com/charmbracelet/x/term v0.2.0 // indirect + github.com/charmbracelet/colorprofile v0.1.8-0.20241114170416-4ca4b7121c58 // indirect + github.com/charmbracelet/x/ansi v0.4.6-0.20241114164159-aea15a2cc929 // indirect + github.com/charmbracelet/x/term v0.2.1 // indirect + github.com/charmbracelet/x/wcwidth v0.0.0-20241113152101-0af7d04e9f32 // indirect github.com/charmbracelet/x/windows v0.2.0 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect - github.com/mattn/go-isatty v0.0.20 // indirect - github.com/mattn/go-runewidth v0.0.16 // indirect - github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/cancelreader v0.2.2 // indirect - github.com/muesli/termenv v0.15.2 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect - golang.org/x/sync v0.8.0 // indirect - golang.org/x/sys v0.25.0 // indirect + golang.org/x/sync v0.9.0 // indirect + golang.org/x/sys v0.27.0 // indirect + golang.org/x/text v0.20.0 // indirect ) diff --git a/exp/teatest/v2/go.sum b/exp/teatest/v2/go.sum index 41c5d247..28af6bae 100644 --- a/exp/teatest/v2/go.sum +++ b/exp/teatest/v2/go.sum @@ -1,39 +1,35 @@ -github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= -github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8= github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= -github.com/charmbracelet/bubbletea/v2 v2.0.0-alpha.1 h1:OZtpLCsuuPplC+1oyUo+/eAN7e9MC2UyZWKlKrVlUnw= -github.com/charmbracelet/bubbletea/v2 v2.0.0-alpha.1/go.mod h1:j0gn4ft5CE7NDYNZjAA3hBM8t2OPjI8urxuAD0oR4w8= -github.com/charmbracelet/lipgloss v0.13.0 h1:4X3PPeoWEDCMvzDvGmTajSyYPcZM4+y8sCA/SsA3cjw= -github.com/charmbracelet/lipgloss v0.13.0/go.mod h1:nw4zy0SBX/F/eAO1cWdcvy6qnkDUxr8Lw7dvFrAIbbY= -github.com/charmbracelet/x/ansi v0.3.2 h1:wsEwgAN+C9U06l9dCVMX0/L3x7ptvY1qmjMwyfE6USY= -github.com/charmbracelet/x/ansi v0.3.2/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw= +github.com/charmbracelet/bubbletea/v2 v2.0.0-alpha.2.0.20241114171136-a07eb04402c5 h1:A7B4qOc3Hu9fb9IKAlY0eIF7fJU67yfIQ4zNCZf4yJ8= +github.com/charmbracelet/bubbletea/v2 v2.0.0-alpha.2.0.20241114171136-a07eb04402c5/go.mod h1:1yWOfzgRWgUU+aIvVCG5vvRnxOynslfUz80S36V1lrw= +github.com/charmbracelet/colorprofile v0.1.8-0.20241114170416-4ca4b7121c58 h1:O7ZVm7uxDSU06e5+Ps5c3bC5zWkqfRUKsH4M3/taYq8= +github.com/charmbracelet/colorprofile v0.1.8-0.20241114170416-4ca4b7121c58/go.mod h1:Fenu2dAsg1m0qWUHcsOwjr12BuDyMaUP6UCQjAUFVrg= +github.com/charmbracelet/x/ansi v0.4.6-0.20241114164159-aea15a2cc929 h1:CxDzlAZwEEcq5DNjRlx+RQ0acLKBg/J5ZmvW+db9kAc= +github.com/charmbracelet/x/ansi v0.4.6-0.20241114164159-aea15a2cc929/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw= +github.com/charmbracelet/x/cellbuf v0.0.6-0.20241114164159-aea15a2cc929 h1:3WICEOCsGzB3TAj9nYj64ojaWl8HpUGxagmVKiiBDXA= +github.com/charmbracelet/x/cellbuf v0.0.6-0.20241114164159-aea15a2cc929/go.mod h1:OJj3QVur0XOJQgNCsE1Q4xdFLgXeQhWkchE8zYzlbMs= github.com/charmbracelet/x/exp/golden v0.0.0-20240806155701-69247e0abc2a h1:G99klV19u0QnhiizODirwVksQB91TJKV/UaTnACcG30= github.com/charmbracelet/x/exp/golden v0.0.0-20240806155701-69247e0abc2a/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= -github.com/charmbracelet/x/term v0.2.0 h1:cNB9Ot9q8I711MyZ7myUR5HFWL/lc3OpU8jZ4hwm0x0= -github.com/charmbracelet/x/term v0.2.0/go.mod h1:GVxgxAbjUrmpvIINHIQnJJKpMlHiZ4cktEQCN6GWyF0= +github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= +github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= +github.com/charmbracelet/x/vt v0.0.0-20241114164159-aea15a2cc929 h1:/xi/eowrQ14ihrKyr9mBdOuKK02QfgPAo0vrKK9OIJc= +github.com/charmbracelet/x/vt v0.0.0-20241114164159-aea15a2cc929/go.mod h1:mMsiDODOSTc241mLfeVdqqHYhuzjSP5uEPcJDGScsRg= +github.com/charmbracelet/x/wcwidth v0.0.0-20241113152101-0af7d04e9f32 h1:14czE6R5CgOlvONsJYa2B1uTyLvXzGXpBqw2AyZeTh4= +github.com/charmbracelet/x/wcwidth v0.0.0-20241113152101-0af7d04e9f32/go.mod h1:hyua5CY63kyl7IfyIxv1SjVEqoKze/XmDkEglItuVjA= github.com/charmbracelet/x/windows v0.2.0 h1:ilXA1GJjTNkgOm94CLPeSz7rar54jtFatdmoiONPuEw= github.com/charmbracelet/x/windows v0.2.0/go.mod h1:ZibNFR49ZFqCXgP76sYanisxRyC+EYrBE7TTknD8s1s= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= -github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= -github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= -github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= -github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= -github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= -github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo= -github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8= -github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= -golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ= -golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34= -golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sync v0.9.0 h1:fEo0HyrW1GIgZdpbhCRO0PkJajUS5H9IFUztCgEo2jQ= +golang.org/x/sync v0.9.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.27.0 h1:wBqf8DvsY9Y/2P8gAfPDEYNuS30J4lPHJxXSb/nJZ+s= +golang.org/x/sys v0.27.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug= +golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4= diff --git a/exp/teatest/v2/send_test.go b/exp/teatest/v2/send_test.go index 8879f5e8..4667f465 100644 --- a/exp/teatest/v2/send_test.go +++ b/exp/teatest/v2/send_test.go @@ -39,8 +39,8 @@ func TestAppSendToOtherProgram(t *testing.T) { tm1.Type("q") tm2.Type("q") - out1 := readBts(t, tm1.FinalOutput(t, teatest.WithFinalTimeout(time.Second))) - out2 := readBts(t, tm2.FinalOutput(t, teatest.WithFinalTimeout(time.Second))) + out1 := teatest.TrimEmptyLines(tm1.FinalOutput(t, teatest.WithFinalTimeout(time.Second))) + out2 := teatest.TrimEmptyLines(tm2.FinalOutput(t, teatest.WithFinalTimeout(time.Second))) if string(out1) != string(out2) { t.Errorf("output of both models should be the same, got:\n%v\nand:\n%v\n", string(out1), string(out2)) diff --git a/exp/teatest/v2/teatest.go b/exp/teatest/v2/teatest.go index f277f067..d44e88da 100644 --- a/exp/teatest/v2/teatest.go +++ b/exp/teatest/v2/teatest.go @@ -2,18 +2,19 @@ package teatest import ( - "bytes" "fmt" - "io" "os" "os/signal" + "strings" "sync" "syscall" "testing" "time" tea "github.com/charmbracelet/bubbletea/v2" + "github.com/charmbracelet/x/cellbuf" "github.com/charmbracelet/x/exp/golden" + "github.com/charmbracelet/x/vt" ) // Program defines the subset of the tea.Program API we need for testing. @@ -23,12 +24,21 @@ type Program interface { // TestModelOptions defines all options available to the test function. type TestModelOptions struct { - size tea.WindowSizeMsg + size tea.WindowSizeMsg + topts []tea.ProgramOption } // TestOption is a functional option. type TestOption func(opts *TestModelOptions) +// WithProgramOptions allows to give the program additional +// [tea.ProgramOption]s. +func WithProgramOptions(topts ...tea.ProgramOption) TestOption { + return func(opts *TestModelOptions) { + opts.topts = append(opts.topts, topts...) + } +} + // WithInitialTermSize ... func WithInitialTermSize(x, y int) TestOption { return func(opts *TestModelOptions) { @@ -63,22 +73,22 @@ func WithDuration(d time.Duration) WaitForOption { } } -// WaitFor keeps reading from r until the condition matches. +// WaitForOutput keeps reading from r until the condition matches. // Default duration is 1s, default check interval is 50ms. // These defaults can be changed with WithDuration and WithCheckInterval. -func WaitFor( +func WaitForOutput( tb testing.TB, - r io.Reader, - condition func(bts []byte) bool, + tm *TestModel, + condition func(string) bool, options ...WaitForOption, ) { tb.Helper() - if err := doWaitFor(r, condition, options...); err != nil { + if err := doWaitFor(tm, condition, options...); err != nil { tb.Fatal(err) } } -func doWaitFor(r io.Reader, condition func(bts []byte) bool, options ...WaitForOption) error { +func doWaitFor(tm *TestModel, condition func(string) bool, options ...WaitForOption) error { wf := WaitingForContext{ Duration: time.Second, CheckInterval: 50 * time.Millisecond, //nolint: gomnd @@ -88,26 +98,21 @@ func doWaitFor(r io.Reader, condition func(bts []byte) bool, options ...WaitForO opt(&wf) } - var b bytes.Buffer start := time.Now() for time.Since(start) <= wf.Duration { - if _, err := io.ReadAll(io.TeeReader(r, &b)); err != nil { - return fmt.Errorf("WaitFor: %w", err) - } - if condition(b.Bytes()) { + if condition(tm.Output()) { return nil } time.Sleep(wf.CheckInterval) } - return fmt.Errorf("WaitFor: condition not met after %s. Last output:\n%s", wf.Duration, b.String()) + return fmt.Errorf("WaitFor: condition not met after %s. Last output:\n%q", wf.Duration, tm.Output()) } // TestModel is a model that is being tested. type TestModel struct { program *tea.Program - in *bytes.Buffer - out io.ReadWriter + term *vt.Terminal modelCh chan tea.Model model tea.Model @@ -118,18 +123,28 @@ type TestModel struct { // NewTestModel makes a new TestModel which can be used for tests. func NewTestModel(tb testing.TB, m tea.Model, options ...TestOption) *TestModel { + var opts TestModelOptions + for _, opt := range options { + opt(&opts) + } + if opts.size.Width == 0 { + opts.size.Width, opts.size.Height = 70, 40 + } + tm := &TestModel{ - in: bytes.NewBuffer(nil), - out: safe(bytes.NewBuffer(nil)), + term: vt.NewTerminal(opts.size.Width, opts.size.Height), modelCh: make(chan tea.Model, 1), doneCh: make(chan bool, 1), } + topts := []tea.ProgramOption{ + tea.WithInput(tm.term), + tea.WithOutput(tm.term), + tea.WithoutSignals(), + } tm.program = tea.NewProgram( m, - tea.WithInput(tm.in), - tea.WithOutput(tm.out), - tea.WithoutSignals(), + append(topts, opts.topts...)..., ) interruptions := make(chan os.Signal, 1) @@ -149,30 +164,27 @@ func NewTestModel(tb testing.TB, m tea.Model, options ...TestOption) *TestModel tm.program.Kill() }() - var opts TestModelOptions - for _, opt := range options { - opt(&opts) - } + tm.program.Send(opts.size) + return tm +} - if opts.size.Width != 0 { - tm.program.Send(opts.size) +func mergeOpts(opts []FinalOpt) FinalOpts { + r := FinalOpts{} + for _, opt := range opts { + opt(&r) } - return tm + return r } -func (tm *TestModel) waitDone(tb testing.TB, opts []FinalOpt) { +func (tm *TestModel) waitDone(tb testing.TB, opts FinalOpts) { tm.done.Do(func() { - fopts := FinalOpts{} - for _, opt := range opts { - opt(&fopts) - } - if fopts.timeout > 0 { + if opts.timeout > 0 { select { - case <-time.After(fopts.timeout): - if fopts.onTimeout == nil { - tb.Fatalf("timeout after %s", fopts.timeout) + case <-time.After(opts.timeout): + if opts.onTimeout == nil { + tb.Fatalf("timeout after %s", opts.timeout) } - fopts.onTimeout(tb) + opts.onTimeout(tb) case <-tm.doneCh: } } else { @@ -185,6 +197,7 @@ func (tm *TestModel) waitDone(tb testing.TB, opts []FinalOpt) { type FinalOpts struct { timeout time.Duration onTimeout func(tb testing.TB) + trim bool } // FinalOpt changes FinalOpts. @@ -209,14 +222,14 @@ func WithFinalTimeout(d time.Duration) FinalOpt { // This method only returns once the program has finished running or when it // times out. func (tm *TestModel) WaitFinished(tb testing.TB, opts ...FinalOpt) { - tm.waitDone(tb, opts) + tm.waitDone(tb, mergeOpts(opts)) } // FinalModel returns the resulting model, resulting from program.Run(). // This method only returns once the program has finished running or when it // times out. func (tm *TestModel) FinalModel(tb testing.TB, opts ...FinalOpt) tea.Model { - tm.waitDone(tb, opts) + tm.WaitFinished(tb, opts...) select { case m := <-tm.modelCh: tm.model = m @@ -226,17 +239,25 @@ func (tm *TestModel) FinalModel(tb testing.TB, opts ...FinalOpt) tea.Model { } } -// FinalOutput returns the program's final output io.Reader. +// FinalOutput returns the program's final output. // This method only returns once the program has finished running or when it // times out. -func (tm *TestModel) FinalOutput(tb testing.TB, opts ...FinalOpt) io.Reader { - tm.waitDone(tb, opts) - return tm.Output() +// It's the equivalent of calling both `tm.WaitFinished` and `tm.Output()`. +// If the app is running in altscreen, this will return the final output of the +// altscreen. +// If you need the primary screen output, use [WaitFinished] and [Output]. +func (tm *TestModel) FinalOutput(tb testing.TB, opts ...FinalOpt) string { + d := tm.term.Screen() + if tm.term.IsAltScreen() { + d = tm.term.AltScreen() + } + tm.WaitFinished(tb, opts...) + return cellbuf.Render(d) } -// Output returns the program's current output io.Reader. -func (tm *TestModel) Output() io.Reader { - return tm.out +// Output returns the program's current output. +func (tm *TestModel) Output() string { + return cellbuf.Render(tm.term.Screen()) } // Send sends messages to the underlying program. @@ -271,31 +292,19 @@ func (tm *TestModel) GetProgram() *tea.Program { // Important: this uses the system `diff` tool. // // You can update the golden files by running your tests with the -update flag. -func RequireEqualOutput(tb testing.TB, out []byte) { +func RequireEqualOutput(tb testing.TB, out string) { tb.Helper() - golden.RequireEqualEscape(tb, out, true) + golden.RequireEqualEscape(tb, []byte(out), true) } -func safe(rw io.ReadWriter) io.ReadWriter { - return &safeReadWriter{rw: rw} -} - -// safeReadWriter implements io.ReadWriter, but locks reads and writes. -type safeReadWriter struct { - rw io.ReadWriter - m sync.RWMutex -} - -// Read implements io.ReadWriter. -func (s *safeReadWriter) Read(p []byte) (n int, err error) { - s.m.RLock() - defer s.m.RUnlock() - return s.rw.Read(p) //nolint: wrapcheck -} - -// Write implements io.ReadWriter. -func (s *safeReadWriter) Write(p []byte) (int, error) { - s.m.Lock() - defer s.m.Unlock() - return s.rw.Write(p) //nolint: wrapcheck +// TrimEmptyLines removes trailing empty lines from the given output. +func TrimEmptyLines(out string) string { + // trim empty trailing lines from the output + lines := strings.Split(out, "\n") + for i := len(lines) - 1; i >= 0; i-- { + if strings.TrimSpace(lines[i]) != "" { + return strings.Join(lines[:i], "\n") + } + } + return out } diff --git a/exp/teatest/v2/teatest_test.go b/exp/teatest/v2/teatest_test.go index 1ccb7fb9..f359188a 100644 --- a/exp/teatest/v2/teatest_test.go +++ b/exp/teatest/v2/teatest_test.go @@ -1,39 +1,12 @@ package teatest import ( - "fmt" - "strings" "testing" - "testing/iotest" "time" tea "github.com/charmbracelet/bubbletea/v2" ) -func TestWaitForErrorReader(t *testing.T) { - err := doWaitFor(iotest.ErrReader(fmt.Errorf("fake")), func(bts []byte) bool { - return true - }, WithDuration(time.Millisecond), WithCheckInterval(10*time.Microsecond)) - if err == nil { - t.Fatal("expected an error, got nil") - } - if err.Error() != "WaitFor: fake" { - t.Fatalf("unexpected error: %s", err.Error()) - } -} - -func TestWaitForTimeout(t *testing.T) { - err := doWaitFor(strings.NewReader("nope"), func(bts []byte) bool { - return false - }, WithDuration(time.Millisecond), WithCheckInterval(10*time.Microsecond)) - if err == nil { - t.Fatal("expected an error, got nil") - } - if err.Error() != "WaitFor: condition not met after 1ms. Last output:\nnope" { - t.Fatalf("unexpected error: %s", err.Error()) - } -} - type m string func (m m) Init() (tea.Model, tea.Cmd) { return m, nil } diff --git a/exp/teatest/v2/testdata/TestApp.golden b/exp/teatest/v2/testdata/TestApp.golden index 12b60376..a728107f 100644 --- a/exp/teatest/v2/testdata/TestApp.golden +++ b/exp/teatest/v2/testdata/TestApp.golden @@ -1,3 +1 @@ -[?25l[?2004h[?2027h[?2027$p Hi. This program will exit in 10 seconds. To quit sooner press any key -Hi. This program will exit in 9 seconds. To quit sooner press any key. - [?2004l[?25h \ No newline at end of file +Hi. This program will exit in 10 seconds. To quit sooner press any key \ No newline at end of file diff --git a/exp/teatest/v2/testdata/TestAppSendToOtherProgram.golden b/exp/teatest/v2/testdata/TestAppSendToOtherProgram.golden index 0e196c51..6ba5f64c 100644 --- a/exp/teatest/v2/testdata/TestAppSendToOtherProgram.golden +++ b/exp/teatest/v2/testdata/TestAppSendToOtherProgram.golden @@ -1,7 +1,6 @@ -[?25l[?2004h[?2027h[?2027$p All pings: +All pings: from m1 from m1 from m2 from m2 -from m2 -from m2 [?2004l[?25h \ No newline at end of file +from m2 \ No newline at end of file diff --git a/go.work.sum b/go.work.sum index 0e99ff18..9f561845 100644 --- a/go.work.sum +++ b/go.work.sum @@ -1,3 +1,5 @@ +github.com/charmbracelet/bubbletea/v2 v2.0.0-alpha.2.0.20241113134142-c71ad13e23d6 h1:kRj022q2jfr69oRZNnhev/Em44M7/TjV7jvWpyQ9PMo= +github.com/charmbracelet/bubbletea/v2 v2.0.0-alpha.2.0.20241113134142-c71ad13e23d6/go.mod h1:Az92EQe8w9w+TgIPiTjbZtVohnlxwHiVDNJMPUTSg2o= github.com/charmbracelet/colorprofile v0.1.6/go.mod h1:3EMXDxwRDJl0c17eJ1jX99MhtlP9OxE/9Qw0C5lvyUg= github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= diff --git a/vt/terminal.go b/vt/terminal.go index 92d05221..8ad05994 100644 --- a/vt/terminal.go +++ b/vt/terminal.go @@ -84,6 +84,11 @@ func NewTerminal(w, h int, opts ...Option) *Terminal { return t } +// IsAltScreen returns true if the terminal is using the altscreen. +func (t *Terminal) IsAltScreen() bool { + return t.pmodes[ansi.AltScreenBufferMode] == ModeSet +} + // Screen returns the main terminal screen. func (t *Terminal) Screen() *Screen { return &t.scrs[0]