-
Notifications
You must be signed in to change notification settings - Fork 22
/
Copy pathteatest.go
303 lines (262 loc) · 7.03 KB
/
teatest.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
// Package teatest provides helper functions to test tea.Model's.
package teatest
import (
"bytes"
"fmt"
"io"
"os"
"os/signal"
"sync"
"syscall"
"testing"
"time"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/x/exp/golden"
)
// Program defines the subset of the tea.Program API we need for testing.
type Program interface {
Send(tea.Msg)
}
// TestModelOptions defines all options available to the test function.
type TestModelOptions struct {
size tea.WindowSizeMsg
}
// TestOption is a functional option.
type TestOption func(opts *TestModelOptions)
// WithInitialTermSize ...
func WithInitialTermSize(x, y int) TestOption {
return func(opts *TestModelOptions) {
opts.size = tea.WindowSizeMsg{
Width: x,
Height: y,
}
}
}
// WaitingForContext is the context for a WaitFor.
type WaitingForContext struct {
Duration time.Duration
CheckInterval time.Duration
}
// WaitForOption changes how a WaitFor will behave.
type WaitForOption func(*WaitingForContext)
// WithCheckInterval sets how much time a WaitFor should sleep between every
// check.
func WithCheckInterval(d time.Duration) WaitForOption {
return func(wf *WaitingForContext) {
wf.CheckInterval = d
}
}
// WithDuration sets how much time a WaitFor will wait for the condition.
func WithDuration(d time.Duration) WaitForOption {
return func(wf *WaitingForContext) {
wf.Duration = d
}
}
// WaitFor 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(
tb testing.TB,
r io.Reader,
condition func(bts []byte) bool,
options ...WaitForOption,
) {
tb.Helper()
if err := doWaitFor(r, condition, options...); err != nil {
tb.Fatal(err)
}
}
func doWaitFor(r io.Reader, condition func(bts []byte) bool, options ...WaitForOption) error {
wf := WaitingForContext{
Duration: time.Second,
CheckInterval: 50 * time.Millisecond, //nolint: mnd
}
for _, opt := range options {
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()) {
return nil
}
time.Sleep(wf.CheckInterval)
}
return fmt.Errorf("WaitFor: condition not met after %s. Last output:\n%s", wf.Duration, b.String())
}
// TestModel is a model that is being tested.
type TestModel struct {
program *tea.Program
in *bytes.Buffer
out io.ReadWriter
modelCh chan tea.Model
model tea.Model
done sync.Once
doneCh chan bool
}
// NewTestModel makes a new TestModel which can be used for tests.
func NewTestModel(tb testing.TB, m tea.Model, options ...TestOption) *TestModel {
tm := &TestModel{
in: bytes.NewBuffer(nil),
out: safe(bytes.NewBuffer(nil)),
modelCh: make(chan tea.Model, 1),
doneCh: make(chan bool, 1),
}
//nolint: staticcheck
tm.program = tea.NewProgram(
m,
tea.WithInput(tm.in),
tea.WithOutput(tm.out),
tea.WithoutSignals(),
tea.WithANSICompressor(), // this helps a bit to reduce drift between runs
)
interruptions := make(chan os.Signal, 1)
signal.Notify(interruptions, syscall.SIGINT)
go func() {
m, err := tm.program.Run()
if err != nil {
tb.Fatalf("app failed: %s", err)
}
tm.doneCh <- true
tm.modelCh <- m
}()
go func() {
<-interruptions
signal.Stop(interruptions)
tb.Log("interrupted")
tm.program.Kill()
}()
var opts TestModelOptions
for _, opt := range options {
opt(&opts)
}
if opts.size.Width != 0 {
tm.program.Send(opts.size)
}
return tm
}
func (tm *TestModel) waitDone(tb testing.TB, opts []FinalOpt) {
tm.done.Do(func() {
fopts := FinalOpts{}
for _, opt := range opts {
opt(&fopts)
}
if fopts.timeout > 0 {
select {
case <-time.After(fopts.timeout):
if fopts.onTimeout == nil {
tb.Fatalf("timeout after %s", fopts.timeout)
}
fopts.onTimeout(tb)
case <-tm.doneCh:
}
} else {
<-tm.doneCh
}
})
}
// FinalOpts represents the options for FinalModel and FinalOutput.
type FinalOpts struct {
timeout time.Duration
onTimeout func(tb testing.TB)
}
// FinalOpt changes FinalOpts.
type FinalOpt func(opts *FinalOpts)
// WithTimeoutFn allows to define what happens when WaitFinished times out.
func WithTimeoutFn(fn func(tb testing.TB)) FinalOpt {
return func(opts *FinalOpts) {
opts.onTimeout = fn
}
}
// WithFinalTimeout allows to set a timeout for how long FinalModel and
// FinalOuput should wait for the program to complete.
func WithFinalTimeout(d time.Duration) FinalOpt {
return func(opts *FinalOpts) {
opts.timeout = d
}
}
// WaitFinished waits for the app to finish.
// 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)
}
// 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)
select {
case m := <-tm.modelCh:
tm.model = m
return tm.model
default:
return tm.model
}
}
// FinalOutput returns the program's final output io.Reader.
// 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()
}
// Output returns the program's current output io.Reader.
func (tm *TestModel) Output() io.Reader {
return tm.out
}
// Send sends messages to the underlying program.
func (tm *TestModel) Send(m tea.Msg) {
tm.program.Send(m)
}
// Quit quits the program and releases the terminal.
func (tm *TestModel) Quit() error {
tm.program.Quit()
return nil
}
// Type types the given text into the given program.
func (tm *TestModel) Type(s string) {
for _, c := range []byte(s) {
tm.Send(tea.KeyMsg{
Runes: []rune{rune(c)},
Type: tea.KeyRunes,
})
}
}
// GetProgram gets the TestModel's program.
func (tm *TestModel) GetProgram() *tea.Program {
return tm.program
}
// RequireEqualOutput is a helper function to assert the given output is
// the expected from the golden files, printing its diff in case it is not.
//
// 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) {
tb.Helper()
golden.RequireEqual(tb, out)
}
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
}