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
package cliapp
import (
"context"
"errors"
"testing"
"time"
"github.com/stretchr/testify/require"
"github.com/urfave/cli/v2"
"github.com/ethereum-optimism/optimism/op-service/opio"
)
type fakeLifecycle struct {
startCh, stopCh chan error
stopped bool
selfClose context.CancelCauseFunc
}
func (f *fakeLifecycle) Start(ctx context.Context) error {
select {
case err := <-f.startCh:
f.stopped = true
return err
case <-ctx.Done():
f.stopped = true
return ctx.Err()
}
}
func (f *fakeLifecycle) Stop(ctx context.Context) error {
select {
case err := <-f.stopCh:
f.stopped = true
return err
case <-ctx.Done():
f.stopped = true
return ctx.Err()
}
}
func (f *fakeLifecycle) Stopped() bool {
return f.stopped
}
var _ Lifecycle = (*fakeLifecycle)(nil)
func TestLifecycleCmd(t *testing.T) {
appSetup := func(t *testing.T) (signalCh chan struct{}, initCh, startCh, stopCh, resultCh chan error, appCh chan *fakeLifecycle) {
signalCh = make(chan struct{})
initCh = make(chan error)
startCh = make(chan error)
stopCh = make(chan error)
resultCh = make(chan error)
// optional channel to retrieve the fakeLifecycle from, available some time after init, before start.
appCh = make(chan *fakeLifecycle, 1)
// mock an application that may fail at different stages of its lifecycle
mockAppFn := func(ctx *cli.Context, close context.CancelCauseFunc) (Lifecycle, error) {
select {
case <-ctx.Context.Done():
return nil, ctx.Context.Err()
case err := <-initCh:
if err != nil {
return nil, err
}
}
app := &fakeLifecycle{
startCh: startCh,
stopCh: stopCh,
stopped: false,
selfClose: close,
}
appCh <- app
return app, nil
}
// turn our mock app and system signal into a lifecycle-managed command
actionFn := LifecycleCmd(mockAppFn)
// try to shut the test down after being locked more than a minute
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
// puppeteer system signal interrupts by hooking up the test signal channel as "blocker" for the app to use.
ctx = opio.WithBlocker(ctx, func(ctx context.Context) {
select {
case <-ctx.Done():
case <-signalCh:
}
})
t.Cleanup(cancel)
// create a fake CLI context to run our command with
cliCtx := &cli.Context{
Context: ctx,
App: &cli.App{
Name: "test-app",
Action: actionFn,
},
Command: nil,
}
// run the command async, it may block etc. The result will be sent back to the tester.
go func() {
result := actionFn(cliCtx)
require.NoError(t, ctx.Err(), "expecting test context to be alive after end still")
// collect the result
resultCh <- result
}()
t.Cleanup(func() {
close(signalCh)
close(initCh)
close(startCh)
close(stopCh)
close(resultCh)
close(appCh)
})
return
}
t.Run("interrupt int", func(t *testing.T) {
signalCh, _, _, _, resultCh, _ := appSetup(t)
signalCh <- struct{}{}
res := <-resultCh
require.ErrorIs(t, res, interruptErr)
require.ErrorContains(t, res, "failed to setup")
})
t.Run("failed init", func(t *testing.T) {
_, initCh, _, _, resultCh, _ := appSetup(t)
v := errors.New("TEST INIT ERROR")
initCh <- v
res := <-resultCh
require.ErrorIs(t, res, v)
require.ErrorContains(t, res, "failed to setup")
})
t.Run("interrupt start", func(t *testing.T) {
signalCh, initCh, _, _, resultCh, appCh := appSetup(t)
initCh <- nil
app := <-appCh
require.False(t, app.Stopped())
signalCh <- struct{}{}
res := <-resultCh
require.ErrorIs(t, res, interruptErr)
require.ErrorContains(t, res, "failed to start")
require.True(t, app.Stopped())
})
t.Run("failed start", func(t *testing.T) {
_, initCh, startCh, _, resultCh, appCh := appSetup(t)
initCh <- nil
app := <-appCh
require.False(t, app.Stopped())
v := errors.New("TEST START ERROR")
startCh <- v
res := <-resultCh
require.ErrorIs(t, res, v)
require.ErrorContains(t, res, "failed to start")
require.True(t, app.Stopped())
})
t.Run("graceful shutdown", func(t *testing.T) {
signalCh, initCh, startCh, stopCh, resultCh, appCh := appSetup(t)
initCh <- nil
app := <-appCh
require.False(t, app.Stopped())
startCh <- nil
signalCh <- struct{}{} // interrupt, but at an expected time
stopCh <- nil // graceful shutdown after interrupt
require.NoError(t, <-resultCh, nil)
require.True(t, app.Stopped())
})
t.Run("interrupted shutdown", func(t *testing.T) {
signalCh, initCh, startCh, _, resultCh, appCh := appSetup(t)
initCh <- nil
app := <-appCh
require.False(t, app.Stopped())
startCh <- nil
signalCh <- struct{}{} // start graceful shutdown
signalCh <- struct{}{} // interrupt before the shutdown process is allowed to complete
res := <-resultCh
require.ErrorIs(t, res, interruptErr)
require.ErrorContains(t, res, "failed to stop")
require.True(t, app.Stopped()) // still fully closes, interrupts only accelerate shutdown where possible.
})
t.Run("failed shutdown", func(t *testing.T) {
signalCh, initCh, startCh, stopCh, resultCh, appCh := appSetup(t)
initCh <- nil
app := <-appCh
require.False(t, app.Stopped())
startCh <- nil
signalCh <- struct{}{} // start graceful shutdown
v := errors.New("TEST STOP ERROR")
stopCh <- v
res := <-resultCh
require.ErrorIs(t, res, v)
require.ErrorContains(t, res, "failed to stop")
require.True(t, app.Stopped())
})
t.Run("app self-close", func(t *testing.T) {
_, initCh, startCh, stopCh, resultCh, appCh := appSetup(t)
initCh <- nil
app := <-appCh
require.False(t, app.Stopped())
startCh <- nil
v := errors.New("TEST SELF CLOSE ERROR")
app.selfClose(v)
stopCh <- nil
require.NoError(t, <-resultCh, "self-close is not considered an error")
require.True(t, app.Stopped())
})
}