1package app
2
3import (
4 "bytes"
5 "context"
6 "errors"
7 "fmt"
8 "os"
9 "path/filepath"
10 "testing"
11 "time"
12
13 "github.com/stretchr/testify/mock"
14
15 "github.com/bnema/nefercap/internal/adapters/wayland"
16 "github.com/bnema/nefercap/internal/core"
17 portsmocks "github.com/bnema/nefercap/internal/mocks/ports"
18 "github.com/bnema/nefercap/internal/ports"
19)
20
21// newTestLifecycle builds the coordinator over real contexts, as production does.
22func newTestLifecycle(t *testing.T) (*lifecycle, context.Context, context.Context, context.CancelFunc) {
23 t.Helper()
24 parent, cancelParent := context.WithCancel(context.Background())
25 t.Cleanup(cancelParent)
26 recordCtx, cancelRecord := context.WithCancelCause(parent)
27 t.Cleanup(func() { cancelRecord(nil) })
28 hudCtx, cancelHUD := context.WithCancel(parent)
29 t.Cleanup(cancelHUD)
30 return newLifecycle(parent, cancelRecord, cancelHUD), recordCtx, hudCtx, cancelParent
31}
32
33func TestUserStopIsNotAFailure(t *testing.T) {
34 life, recordCtx, hudCtx, _ := newTestLifecycle(t)
35 life.userStop()
36 if context.Cause(recordCtx) != errUserStop || hudCtx.Err() == nil {
37 t.Fatalf("stop did not end recording and HUD: %v", context.Cause(recordCtx))
38 }
39 // The HUD teardown provokes a detach; it is an echo, not a failure.
40 life.detached(errors.New("layer detached"))
41 if err := life.failed(); err != nil {
42 t.Fatalf("echo recorded as failure: %v", err)
43 }
44 // Stopped before the HUD was ready: the HUD only reports its cancellation.
45 err := classify(ending{hudErr: context.Canceled, failure: life.failed(), parentErr: nil, userStop: context.Cause(recordCtx) == errUserStop})
46 if err != nil {
47 t.Fatalf("stop before ready: %v", err)
48 }
49}
50
51func TestDetachAfterReadyKeepsRealError(t *testing.T) {
52 life, recordCtx, hudCtx, _ := newTestLifecycle(t)
53 detach := fmt.Errorf("compositor: %w", wayland.ErrExclusionUnavailable)
54 life.detached(detach)
55 if recordCtx.Err() == nil || hudCtx.Err() == nil {
56 t.Fatal("detach did not stop recording and HUD")
57 }
58 // The core observes only the cancellation the detach caused.
59 err := classify(ending{runErr: fmt.Errorf("capture first frame: %w", context.Canceled), failure: life.failed(), userStop: context.Cause(recordCtx) == errUserStop})
60 if !errors.Is(err, wayland.ErrExclusionUnavailable) {
61 t.Fatalf("detach error lost: %v", err)
62 }
63 // A second detach does not replace the first cause.
64 life.detached(errors.New("later"))
65 if cause := context.Cause(recordCtx); !errors.Is(cause, wayland.ErrExclusionUnavailable) {
66 t.Fatalf("first cause replaced: %v", cause)
67 }
68}
69
70func TestDetachDuringOwnShutdownIsIgnored(t *testing.T) {
71 life, recordCtx, _, _ := newTestLifecycle(t)
72 life.beginShutdown()
73 life.detached(errors.New("late detach"))
74 if err := life.failed(); err != nil || recordCtx.Err() != nil {
75 t.Fatalf("late detach acted on: %v %v", err, recordCtx.Err())
76 }
77}
78
79func TestDetachAfterParentCancelIsIgnored(t *testing.T) {
80 life, _, _, cancelParent := newTestLifecycle(t)
81 cancelParent()
82 life.detached(errors.New("echo"))
83 if err := life.failed(); err != nil {
84 t.Fatalf("echo of parent cancel recorded: %v", err)
85 }
86}
87
88func TestHUDEndedBeforeReady(t *testing.T) {
89 // Closed with nothing real: a failure naming the missing authorization.
90 life, _, _, _ := newTestLifecycle(t)
91 life.hudEnded(nil, true)
92 if err := classify(ending{failure: life.failed()}); !errors.Is(err, errHUDClosedEarly) {
93 t.Fatalf("silent early close: %v", err)
94 }
95 // A real startup error is returned as itself, not masked by a generic one.
96 life, _, _, _ = newTestLifecycle(t)
97 real := errors.New("authorize refused")
98 rest := life.hudEnded(real, true)
99 err := classify(ending{hudErr: rest, failure: life.failed()})
100 if !errors.Is(err, real) || errors.Is(err, errHUDClosedEarly) {
101 t.Fatalf("startup error masked: %v", err)
102 }
103}
104
105func TestHUDEndedIgnoredOnceStopping(t *testing.T) {
106 life, _, _, _ := newTestLifecycle(t)
107 life.userStop()
108 life.hudEnded(context.Canceled, false)
109 if err := life.failed(); err != nil {
110 t.Fatalf("stopped HUD counted as failure: %v", err)
111 }
112}
113
114func TestParentCancelBeforeReadyWithNilHUD(t *testing.T) {
115 life, _, _, cancelParent := newTestLifecycle(t)
116 cancelParent()
117 // The HUD ended cleanly (nil) because the parent cancelled it.
118 life.hudEnded(nil, true)
119 err := classify(ending{failure: life.failed(), parentErr: context.Canceled})
120 if err != context.Canceled {
121 t.Fatalf("want bare context.Canceled, got %v", err)
122 }
123}
124
125func TestStopOnSignalStopsOnce(t *testing.T) {
126 life, recordCtx, _, _ := newTestLifecycle(t)
127 signal := make(chan struct{}, 1)
128 watchCtx, stopWatch := context.WithCancel(context.Background())
129 defer stopWatch()
130 done := stopOnSignal(watchCtx, signal, life.userStop)
131 signal <- struct{}{}
132 select {
133 case <-done:
134 case <-time.After(time.Second):
135 t.Fatal("signal watcher did not finish")
136 }
137 if context.Cause(recordCtx) != errUserStop {
138 t.Fatalf("cause: %v", context.Cause(recordCtx))
139 }
140}
141
142func TestClassify(t *testing.T) {
143 boom := errors.New("boom")
144 cancelled := fmt.Errorf("capture first frame: %w", context.Canceled)
145 tests := []struct {
146 name string
147 in ending
148 want error
149 is error
150 }{
151 {name: "user stop before first frame", in: ending{runErr: cancelled, userStop: true}},
152 {name: "user stop after frames", in: ending{userStop: true}},
153 {name: "parent cancel returns bare error", in: ending{runErr: cancelled, parentErr: context.Canceled}, want: context.Canceled},
154 {name: "cancel plus encoder failure", in: ending{runErr: errors.Join(cancelled, fmt.Errorf("finalize video: %w", boom)), userStop: true}, is: boom},
155 {name: "HUD failure with user stop", in: ending{runErr: cancelled, hudErr: boom, userStop: true}, is: boom},
156 {name: "recorded failure beats parent cancel", in: ending{runErr: cancelled, failure: boom, parentErr: context.Canceled}, is: boom},
157 {name: "both failures kept", in: ending{runErr: boom, failure: errHUDClosed}, is: errHUDClosed},
158 }
159 for _, tt := range tests {
160 t.Run(tt.name, func(t *testing.T) {
161 err := classify(tt.in)
162 switch {
163 case tt.is != nil:
164 if !errors.Is(err, tt.is) {
165 t.Fatalf("got %v, want it to wrap %v", err, tt.is)
166 }
167 case err != tt.want:
168 t.Fatalf("got %v, want %v", err, tt.want)
169 }
170 })
171 }
172}
173
174func TestExclusionError(t *testing.T) {
175 live := context.Background()
176 done, cancel := context.WithCancel(context.Background())
177 cancel()
178 if err := exclusionError(done, fmt.Errorf("begin: %w", context.Canceled)); err != context.Canceled {
179 t.Fatalf("cancellation not bare: %v", err)
180 }
181 real := errors.New("protocol failure")
182 if err := exclusionError(done, errors.Join(context.Canceled, real)); !errors.Is(err, real) || err == context.Canceled {
183 t.Fatalf("real failure masked by cancel: %v", err)
184 }
185 // A cancel error while the caller is live is not the caller's cancel.
186 if err := exclusionError(live, context.Canceled); err == context.Canceled {
187 t.Fatal("foreign cancel treated as caller's")
188 }
189 if err := exclusionError(live, real); !errors.Is(err, real) || err == real {
190 t.Fatalf("phase missing: %v", err)
191 }
192}
193
194func TestAuthorized(t *testing.T) {
195 ready := make(chan struct{}, 1)
196 if authorized(ready) {
197 t.Fatal("authorized before ready")
198 }
199 ready <- struct{}{}
200 if !authorized(ready) {
201 t.Fatal("ready not seen")
202 }
203}
204
205// A HUD that was authorized and then ended quietly is a plain close, not an
206// early close.
207func TestHUDEndedAfterAuthorization(t *testing.T) {
208 life, _, _, _ := newTestLifecycle(t)
209 ready := make(chan struct{}, 1)
210 ready <- struct{}{}
211 life.hudEnded(nil, !authorized(ready))
212 if err := classify(ending{failure: life.failed()}); !errors.Is(err, errHUDClosed) || errors.Is(err, errHUDClosedEarly) {
213 t.Fatalf("got %v", err)
214 }
215}
216
217func TestFinishCapture(t *testing.T) {
218 detach := errors.New("layer detached")
219 var out bytes.Buffer
220 if err := finishCapture(&out, "/v/a.mp4", true, detach); err != detach || out.String() != "/v/a.mp4\n" {
221 t.Fatalf("saved with failure: %v %q", err, out.String())
222 }
223 out.Reset()
224 if err := finishCapture(&out, "/v/a.mp4", true, nil); err != nil || out.String() != "/v/a.mp4\n" {
225 t.Fatalf("saved: %v %q", err, out.String())
226 }
227 out.Reset()
228 if err := finishCapture(&out, "/v/a.mp4", false, context.Canceled); err != context.Canceled || out.Len() != 0 {
229 t.Fatalf("unsaved: %v %q", err, out.String())
230 }
231 if err := finishCapture(&out, "/v/a.mp4", false, nil); err != nil || out.Len() != 0 {
232 t.Fatalf("stopped early: %v %q", err, out.String())
233 }
234 // A read-only file is a real writer that always fails.
235 readOnly, openErr := os.Open(os.DevNull)
236 if openErr != nil {
237 t.Fatal(openErr)
238 }
239 defer readOnly.Close()
240 err := finishCapture(readOnly, "/v/a.mp4", true, detach)
241 if !errors.Is(err, detach) || len(err.(interface{ Unwrap() []error }).Unwrap()) != 2 {
242 t.Fatalf("joined error lost a part: %v", err)
243 }
244}
245
246// The production core with generated ports: cancelling during the first
247// capture aborts nothing and writes nothing, and the lifecycle calls it success.
248func TestCancelDuringFirstFrameIsNotAFailure(t *testing.T) {
249 ctx, cancel := context.WithCancelCause(context.Background())
250 defer cancel(nil)
251 target := ports.Target{OutputID: 1}
252 source := portsmocks.NewMockSource(t)
253 source.EXPECT().Capture(ctx, target).RunAndReturn(func(c context.Context, _ ports.Target) (ports.Frame, error) {
254 cancel(errUserStop)
255 return ports.Frame{}, c.Err()
256 })
257 video := portsmocks.NewMockVideoWriter(t)
258 path := filepath.Join(t.TempDir(), "never.mp4")
259 runErr := core.New(source, nil, video).Run(ctx, ports.Selection{Mode: ports.Record, Target: target, Path: path, Video: ports.VideoSettings{FPS: 30}})
260 if runErr == nil {
261 t.Fatal("core reported success without a frame")
262 }
263 if err := classify(ending{runErr: runErr, userStop: context.Cause(ctx) == errUserStop}); err != nil {
264 t.Fatalf("first frame cancel is a failure: %v", err)
265 }
266 if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) {
267 t.Fatalf("expected no recording file: %v", err)
268 }
269}
270
271// A finalize failure that surfaces beside the cancellation must reach the user.
272func TestEncoderFailureAfterCancelIsKept(t *testing.T) {
273 ctx, cancel := context.WithCancelCause(context.Background())
274 defer cancel(nil)
275 frame := ports.Frame{Pixels: make([]byte, 4*2*2), Width: 2, Height: 2, Stride: 8, Format: ports.XRGB8888}
276 target := ports.Target{OutputID: 1}
277 source := portsmocks.NewMockSource(t)
278 source.EXPECT().Capture(ctx, target).Return(frame, nil)
279 closeErr := errors.New("encoder exited 1")
280 video := portsmocks.NewMockVideoWriter(t)
281 video.EXPECT().Start(ctx, frame, mock.Anything, mock.Anything).Return(nil)
282 video.EXPECT().Write(ctx, frame).RunAndReturn(func(context.Context, ports.Frame) error {
283 cancel(errUserStop)
284 return nil
285 })
286 video.EXPECT().Close().Return(closeErr)
287 runErr := core.New(source, nil, video).Run(ctx, ports.Selection{Mode: ports.Record, Target: target, Path: filepath.Join(t.TempDir(), "x.mp4"), Video: ports.VideoSettings{FPS: 30}})
288 err := classify(ending{runErr: runErr, userStop: context.Cause(ctx) == errUserStop})
289 if !errors.Is(err, closeErr) {
290 t.Fatalf("encoder failure masked: %v", err)
291 }
292}