1package core_test
2
3import (
4 "context"
5 "errors"
6 "fmt"
7 "sync/atomic"
8 "testing"
9 "time"
10
11 "github.com/stretchr/testify/assert"
12 "github.com/stretchr/testify/mock"
13 "github.com/stretchr/testify/require"
14
15 "github.com/bnema/nefercap/internal/core"
16 portsmocks "github.com/bnema/nefercap/internal/mocks/ports"
17 "github.com/bnema/nefercap/internal/ports"
18)
19
20var (
21 frame = ports.Frame{Pixels: make([]byte, 16), Width: 2, Height: 2, Stride: 8, Format: XRGB}
22 target = ports.Target{OutputID: 7, Region: ports.Region{X: 1, Y: 2, Width: 3, Height: 4}}
23)
24
25const XRGB = ports.XRGB8888
26
27type harness struct {
28 src *portsmocks.MockSource
29 png *portsmocks.MockScreenshotWriter
30 vid *portsmocks.MockVideoWriter
31 svc *core.Service
32}
33
34func newHarness(t *testing.T) harness {
35 t.Helper()
36 h := harness{
37 src: portsmocks.NewMockSource(t),
38 png: portsmocks.NewMockScreenshotWriter(t),
39 vid: portsmocks.NewMockVideoWriter(t),
40 }
41 h.svc = core.New(h.src, h.png, h.vid)
42 return h
43}
44
45func shot() ports.Selection {
46 return ports.Selection{Mode: ports.Screenshot, Target: target, Path: "a.png"}
47}
48
49func rec(d time.Duration) ports.Selection {
50 return ports.Selection{
51 Mode: ports.Record, Target: target, Path: "a.mkv", Duration: d,
52 Video: ports.VideoSettings{FPS: 120},
53 }
54}
55
56func TestInvalidSelectionsMakeNoCalls(t *testing.T) {
57 const maxI32 = 1<<31 - 1
58 cases := map[string]func(*ports.Selection){
59 "empty mode": func(s *ports.Selection) { s.Mode = "" },
60 "unknown mode": func(s *ports.Selection) { s.Mode = "gif" },
61 "zero output": func(s *ports.Selection) { s.Target.OutputID = 0 },
62 "negative x": func(s *ports.Selection) { s.Target.Region = ports.Region{X: -1, Width: 1, Height: 1} },
63 "negative y": func(s *ports.Selection) { s.Target.Region = ports.Region{Y: -1, Width: 1, Height: 1} },
64 "zero width": func(s *ports.Selection) { s.Target.Region = ports.Region{X: 1, Height: 1} },
65 "zero height": func(s *ports.Selection) { s.Target.Region = ports.Region{Width: 1} },
66 "negative width": func(s *ports.Selection) { s.Target.Region = ports.Region{Width: -1, Height: 1} },
67 "x overflow": func(s *ports.Selection) { s.Target.Region = ports.Region{X: maxI32, Width: 1, Height: 1} },
68 "width overflow": func(s *ports.Selection) { s.Target.Region = ports.Region{Width: maxI32 + 1, Height: 1} },
69 "empty path": func(s *ports.Selection) { s.Path = "" },
70 "negative duration": func(s *ports.Selection) { s.Duration = -time.Second },
71 }
72 for name, mutate := range cases {
73 for _, base := range []ports.Selection{shot(), rec(time.Second)} {
74 t.Run(string(base.Mode)+"/"+name, func(t *testing.T) {
75 h := newHarness(t)
76 sel := base
77 mutate(&sel)
78 err := h.svc.Run(context.Background(), sel)
79 require.ErrorIs(t, err, core.ErrInvalidSelection)
80 })
81 }
82 }
83
84 videos := map[string]ports.VideoSettings{
85 "fps zero": {},
86 "fps negative": {FPS: -1},
87 "fps too high": {FPS: 121},
88 "width only": {FPS: 30, Width: 640},
89 "height only": {FPS: 30, Height: 480},
90 "odd width": {FPS: 30, Width: 641, Height: 480},
91 "odd height": {FPS: 30, Width: 640, Height: 481},
92 "negative size": {FPS: 30, Width: -2, Height: -2},
93 "oversized": {FPS: 30, Width: 16386, Height: 2},
94 "frame too big": {FPS: 30, Width: 16384, Height: 16384},
95 "oversized high": {FPS: 30, Width: 2, Height: 16386},
96 }
97 for name, v := range videos {
98 t.Run("record/"+name, func(t *testing.T) {
99 h := newHarness(t)
100 sel := rec(0)
101 sel.Video = v
102 require.ErrorIs(t, h.svc.Run(context.Background(), sel), core.ErrInvalidSelection)
103 })
104 }
105}
106
107func TestRegionErrorIsInvalidRegion(t *testing.T) {
108 h := newHarness(t)
109 sel := shot()
110 sel.Target.Region = ports.Region{X: -1, Width: 1, Height: 1}
111 require.ErrorIs(t, h.svc.Run(context.Background(), sel), ports.ErrInvalidRegion)
112}
113
114func TestValidSelectionsAreAccepted(t *testing.T) {
115 sizes := []ports.VideoSettings{{FPS: 1}, {FPS: 120, Width: 2, Height: 2}, {FPS: 30, Width: 16384, Height: 4096}}
116 for _, v := range sizes {
117 h := newHarness(t)
118 h.src.EXPECT().Capture(mock.Anything, target).Return(frame, nil).Once()
119 h.vid.EXPECT().Start(mock.Anything, frame, "a.mkv", v).Return(errors.New("stop")).Once()
120 h.vid.EXPECT().Abort().Return(nil).Once()
121 sel := rec(0)
122 sel.Video = v
123 require.ErrorContains(t, h.svc.Run(context.Background(), sel), "stop")
124 }
125 // Zero region means full output.
126 h := newHarness(t)
127 full := ports.Target{OutputID: 1}
128 h.src.EXPECT().Capture(mock.Anything, full).Return(frame, nil).Once()
129 h.png.EXPECT().Save(mock.Anything, frame, "a.png").Return(nil).Once()
130 sel := shot()
131 sel.Target = full
132 require.NoError(t, h.svc.Run(context.Background(), sel))
133}
134
135func TestSelectionAllocations(t *testing.T) {
136 sels := []ports.Selection{shot(), rec(time.Second)}
137 h := newHarness(t)
138 ctx, cancel := context.WithCancel(context.Background())
139 cancel() // validation passes, then Run stops before any port call
140 for _, sel := range sels {
141 allocs := testing.AllocsPerRun(100, func() {
142 if err := h.svc.Run(ctx, sel); !errors.Is(err, context.Canceled) {
143 t.Fatalf("unexpected result %v", err)
144 }
145 })
146 assert.Zero(t, allocs, sel.Mode)
147 }
148}
149
150func TestScreenshot(t *testing.T) {
151 h := newHarness(t)
152 ctx := context.Background()
153 c := h.src.EXPECT().Capture(ctx, target).Return(frame, nil).Once()
154 h.png.EXPECT().Save(ctx, frame, "a.png").Return(nil).Once().NotBefore(c)
155 require.NoError(t, h.svc.Run(ctx, shot()))
156}
157
158func TestScreenshotFailures(t *testing.T) {
159 boom := errors.New("boom")
160 t.Run("capture", func(t *testing.T) {
161 h := newHarness(t)
162 h.src.EXPECT().Capture(mock.Anything, target).Return(ports.Frame{}, boom).Once()
163 require.ErrorIs(t, h.svc.Run(context.Background(), shot()), boom)
164 })
165 t.Run("invalid frame", func(t *testing.T) {
166 h := newHarness(t)
167 bad := frame
168 bad.Stride = 4
169 h.src.EXPECT().Capture(mock.Anything, target).Return(bad, nil).Once()
170 require.Error(t, h.svc.Run(context.Background(), shot()))
171 })
172 t.Run("save", func(t *testing.T) {
173 h := newHarness(t)
174 h.src.EXPECT().Capture(mock.Anything, target).Return(frame, nil).Once()
175 h.png.EXPECT().Save(mock.Anything, frame, "a.png").Return(errors.Join(boom, ports.ErrPathExists)).Once()
176 err := h.svc.Run(context.Background(), shot())
177 require.ErrorIs(t, err, boom)
178 require.ErrorIs(t, err, ports.ErrPathExists)
179 })
180 t.Run("already cancelled", func(t *testing.T) {
181 h := newHarness(t)
182 ctx, cancel := context.WithCancel(context.Background())
183 cancel()
184 require.ErrorIs(t, h.svc.Run(ctx, shot()), context.Canceled)
185 })
186 t.Run("cancelled during capture", func(t *testing.T) {
187 h := newHarness(t)
188 ctx, cancel := context.WithCancel(context.Background())
189 h.src.EXPECT().Capture(ctx, target).RunAndReturn(func(ctx context.Context, _ ports.Target) (ports.Frame, error) {
190 cancel()
191 return ports.Frame{}, ctx.Err()
192 }).Once()
193 require.ErrorIs(t, h.svc.Run(ctx, shot()), context.Canceled)
194 })
195 t.Run("cancelled during save", func(t *testing.T) {
196 h := newHarness(t)
197 ctx, cancel := context.WithCancel(context.Background())
198 h.src.EXPECT().Capture(ctx, target).Return(frame, nil).Once()
199 h.png.EXPECT().Save(ctx, frame, "a.png").RunAndReturn(func(ctx context.Context, _ ports.Frame, _ string) error {
200 cancel()
201 return ctx.Err()
202 }).Once()
203 require.ErrorIs(t, h.svc.Run(ctx, shot()), context.Canceled)
204 })
205}
206
207// recordOrder sets the mandatory prefix: Capture, Start, first Write.
208func recordOrder(h harness, sel ports.Selection) (first *mock.Call) {
209 c := h.src.EXPECT().Capture(mock.Anything, target).Return(frame, nil).Once()
210 s := h.vid.EXPECT().Start(mock.Anything, frame, sel.Path, sel.Video).Return(nil).Once().NotBefore(c)
211 return s
212}
213
214func TestRecordFirstWriteCompletesBeforeTick(t *testing.T) {
215 // 1 fps: only the first frame is written in a short run.
216 h := newHarness(t)
217 sel := rec(30 * time.Millisecond)
218 sel.Video.FPS = 1
219 start := recordOrder(h, sel)
220 w := h.vid.EXPECT().Write(mock.Anything, frame).Return(nil).Once().NotBefore(start)
221 h.vid.EXPECT().Close().Return(nil).Once().NotBefore(w)
222 require.NoError(t, h.svc.Run(context.Background(), sel))
223}
224
225func TestRecordCancelFinalizesGracefully(t *testing.T) {
226 h := newHarness(t)
227 sel := rec(0)
228 ctx, cancel := context.WithCancel(context.Background())
229 start := recordOrder(h, sel)
230 h.src.EXPECT().Capture(mock.Anything, target).Return(frame, nil).Maybe()
231 var n atomic.Int32
232 w := h.vid.EXPECT().Write(mock.Anything, frame).RunAndReturn(func(context.Context, ports.Frame) error {
233 if n.Add(1) == 3 {
234 cancel()
235 }
236 return nil
237 }).NotBefore(start)
238 w.Maybe()
239 h.vid.EXPECT().Close().Return(nil).Once()
240 require.NoError(t, h.svc.Run(ctx, sel))
241 assert.GreaterOrEqual(t, n.Load(), int32(3))
242}
243
244func TestRecordCancelInterruptsBlockedCapture(t *testing.T) {
245 h := newHarness(t)
246 sel := rec(0)
247 ctx, cancel := context.WithCancel(context.Background())
248 start := recordOrder(h, sel)
249 w := h.vid.EXPECT().Write(mock.Anything, frame).Return(nil).Once().NotBefore(start)
250 h.src.EXPECT().Capture(mock.Anything, target).RunAndReturn(func(ctx context.Context, _ ports.Target) (ports.Frame, error) {
251 cancel()
252 <-ctx.Done()
253 return ports.Frame{}, ctx.Err()
254 }).Once()
255 h.vid.EXPECT().Close().Return(nil).Once().NotBefore(w)
256 require.NoError(t, h.svc.Run(ctx, sel))
257}
258
259func TestRecordCancelDuringFirstWriteAborts(t *testing.T) {
260 h := newHarness(t)
261 sel := rec(0)
262 ctx, cancel := context.WithCancel(context.Background())
263 start := recordOrder(h, sel)
264 w := h.vid.EXPECT().Write(mock.Anything, frame).RunAndReturn(func(ctx context.Context, _ ports.Frame) error {
265 cancel()
266 return ctx.Err()
267 }).Once().NotBefore(start)
268 h.vid.EXPECT().Abort().Return(nil).Once().NotBefore(w)
269 err := h.svc.Run(ctx, sel)
270 require.ErrorIs(t, err, context.Canceled)
271}
272
273func TestRecordCancelDuringLaterWrite(t *testing.T) {
274 h := newHarness(t)
275 sel := rec(0)
276 ctx, cancel := context.WithCancel(context.Background())
277 start := recordOrder(h, sel)
278 first := h.vid.EXPECT().Write(mock.Anything, frame).Return(nil).Once().NotBefore(start)
279 h.src.EXPECT().Capture(mock.Anything, target).Return(frame, nil).Once()
280 h.vid.EXPECT().Write(mock.Anything, frame).RunAndReturn(func(ctx context.Context, _ ports.Frame) error {
281 cancel()
282 return ctx.Err()
283 }).Once().NotBefore(first)
284 h.vid.EXPECT().Close().Return(nil).Once()
285 require.NoError(t, h.svc.Run(ctx, sel))
286}
287
288func TestRecordJoinedFailureIsNotSuppressedByCancellation(t *testing.T) {
289 boom := errors.New("boom")
290 joins := map[string]error{
291 "join": errors.Join(context.Canceled, boom),
292 "wrapped": fmt.Errorf("x: %w", errors.Join(boom, fmt.Errorf("y: %w", context.Canceled))),
293 "multi": fmt.Errorf("%w; %w", context.Canceled, boom),
294 }
295 for name, werr := range joins {
296 t.Run("write/"+name, func(t *testing.T) {
297 h := newHarness(t)
298 sel := rec(0)
299 ctx, cancel := context.WithCancel(context.Background())
300 start := recordOrder(h, sel)
301 first := h.vid.EXPECT().Write(mock.Anything, frame).Return(nil).Once().NotBefore(start)
302 h.src.EXPECT().Capture(mock.Anything, target).Return(frame, nil).Once()
303 h.vid.EXPECT().Write(mock.Anything, frame).RunAndReturn(func(context.Context, ports.Frame) error {
304 cancel()
305 return werr
306 }).Once().NotBefore(first)
307 h.vid.EXPECT().Close().Return(nil).Once()
308 require.ErrorIs(t, h.svc.Run(ctx, sel), boom)
309 })
310 t.Run("capture/"+name, func(t *testing.T) {
311 h := newHarness(t)
312 sel := rec(0)
313 ctx, cancel := context.WithCancel(context.Background())
314 start := recordOrder(h, sel)
315 h.vid.EXPECT().Write(mock.Anything, frame).Return(nil).Once().NotBefore(start)
316 h.src.EXPECT().Capture(mock.Anything, target).RunAndReturn(func(context.Context, ports.Target) (ports.Frame, error) {
317 cancel()
318 return ports.Frame{}, werr
319 }).Once()
320 h.vid.EXPECT().Close().Return(nil).Once()
321 require.ErrorIs(t, h.svc.Run(ctx, sel), boom)
322 })
323 }
324 t.Run("wrapped cancellation alone is a clean stop", func(t *testing.T) {
325 h := newHarness(t)
326 sel := rec(0)
327 ctx, cancel := context.WithCancel(context.Background())
328 start := recordOrder(h, sel)
329 h.vid.EXPECT().Write(mock.Anything, frame).Return(nil).Once().NotBefore(start)
330 h.src.EXPECT().Capture(mock.Anything, target).RunAndReturn(func(context.Context, ports.Target) (ports.Frame, error) {
331 cancel()
332 return ports.Frame{}, fmt.Errorf("wayland: %w", errors.Join(context.Canceled, context.Canceled))
333 }).Once()
334 h.vid.EXPECT().Close().Return(nil).Once()
335 require.NoError(t, h.svc.Run(ctx, sel))
336 })
337}
338
339// slotRecorder tracks the most recently captured frame: only it may be written.
340type slotRecorder struct {
341 current *byte
342 captures atomic.Int32
343 writes atomic.Int32
344}
345
346func newFrame() ports.Frame {
347 f := frame
348 f.Pixels = make([]byte, len(frame.Pixels))
349 return f
350}
351
352func TestRecordExactFrameCountWithSlowStartAndCapture(t *testing.T) {
353 cases := []struct {
354 name string
355 duration time.Duration
356 fps int
357 want int32
358 }{
359 {"slow capture 60fps", 300 * time.Millisecond, 60, 18},
360 {"fractional slot rounds up", 25 * time.Millisecond, 60, 2},
361 {"single slot", 10 * time.Millisecond, 30, 1},
362 {"fast", 100 * time.Millisecond, 30, 3},
363 }
364 for _, tc := range cases {
365 t.Run(tc.name, func(t *testing.T) {
366 h := newHarness(t)
367 sel := rec(tc.duration)
368 sel.Video.FPS = tc.fps
369 var r slotRecorder
370 first := newFrame()
371 r.current = &first.Pixels[0]
372 var closed atomic.Bool
373 h.src.EXPECT().Capture(mock.Anything, target).RunAndReturn(func(context.Context, ports.Target) (ports.Frame, error) {
374 r.captures.Add(1)
375 f := first
376 if r.captures.Load() > 1 {
377 time.Sleep(25 * time.Millisecond)
378 f = newFrame()
379 }
380 r.current = &f.Pixels[0]
381 return f, nil
382 }).Maybe()
383 s := h.vid.EXPECT().Start(mock.Anything, mock.Anything, sel.Path, sel.Video).RunAndReturn(func(context.Context, ports.Frame, string, ports.VideoSettings) error {
384 time.Sleep(100 * time.Millisecond) // must not count against the duration
385 return nil
386 }).Once()
387 h.vid.EXPECT().Write(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, f ports.Frame) error {
388 require.False(t, closed.Load(), "write after close")
389 require.Same(t, r.current, &f.Pixels[0], "stale or unknown frame written")
390 r.writes.Add(1)
391 return nil
392 }).NotBefore(s).Maybe()
393 h.vid.EXPECT().Close().RunAndReturn(func() error {
394 closed.Store(true)
395 return nil
396 }).Once()
397
398 began := time.Now()
399 require.NoError(t, h.svc.Run(context.Background(), sel))
400 assert.Equal(t, tc.want, r.writes.Load())
401 assert.LessOrEqual(t, r.captures.Load(), tc.want)
402 assert.GreaterOrEqual(t, time.Since(began), 100*time.Millisecond+tc.duration-time.Second/time.Duration(tc.fps))
403 })
404 }
405}
406
407// backpressure wires a slow Write and distinct capture storage, and reports
408// fresh captures, total writes and the longest run of repeated frames.
409type backpressure struct {
410 captures atomic.Int32
411 writes atomic.Int32
412 maxRun int
413 run int
414 last *byte
415}
416
417func (bp *backpressure) wire(h harness, start *mock.Call, slow time.Duration) {
418 h.src.EXPECT().Capture(mock.Anything, target).RunAndReturn(func(context.Context, ports.Target) (ports.Frame, error) {
419 bp.captures.Add(1)
420 return newFrame(), nil
421 }).Maybe()
422 h.vid.EXPECT().Write(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, f ports.Frame) error {
423 time.Sleep(slow)
424 bp.writes.Add(1)
425 if &f.Pixels[0] == bp.last {
426 bp.run++
427 } else {
428 bp.run = 0
429 }
430 bp.maxRun = max(bp.maxRun, bp.run)
431 bp.last = &f.Pixels[0]
432 return nil
433 }).NotBefore(start).Maybe()
434}
435
436func TestRecordEncoderBackpressureKeepsCapturesFresh(t *testing.T) {
437 h := newHarness(t)
438 sel := rec(2 * time.Second)
439 sel.Video.FPS = 60
440 start := recordOrder(h, sel)
441 var bp backpressure
442 bp.wire(h, start, 20*time.Millisecond)
443 h.vid.EXPECT().Close().Return(nil).Once()
444
445 require.NoError(t, h.svc.Run(context.Background(), sel))
446 assert.Equal(t, int32(120), bp.writes.Load(), "exact encoded length")
447 assert.GreaterOrEqual(t, bp.captures.Load(), int32(40), "captures must not starve")
448 assert.LessOrEqual(t, bp.maxRun, 2, "repeat backlog is bounded")
449}
450
451func TestRecordSlightWriteOverrunStaysFresh(t *testing.T) {
452 h := newHarness(t)
453 sel := rec(time.Second)
454 sel.Video.FPS = 60
455 start := recordOrder(h, sel)
456 var bp backpressure
457 bp.wire(h, start, 17*time.Millisecond) // just over the 16.7ms slot
458 h.vid.EXPECT().Close().Return(nil).Once()
459
460 require.NoError(t, h.svc.Run(context.Background(), sel))
461 assert.Equal(t, int32(60), bp.writes.Load(), "exact encoded length")
462 assert.GreaterOrEqual(t, bp.captures.Load(), int32(54), "over 90 percent of frames are fresh")
463}
464
465func TestRecordSlowCaptureStillRepeats(t *testing.T) {
466 h := newHarness(t)
467 sel := rec(time.Second)
468 sel.Video.FPS = 60
469 start := recordOrder(h, sel)
470 var captures atomic.Int32
471 h.src.EXPECT().Capture(mock.Anything, target).RunAndReturn(func(context.Context, ports.Target) (ports.Frame, error) {
472 captures.Add(1)
473 time.Sleep(25 * time.Millisecond)
474 return newFrame(), nil
475 }).Maybe()
476 var writes atomic.Int32
477 h.vid.EXPECT().Write(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, _ ports.Frame) error {
478 time.Sleep(time.Millisecond)
479 writes.Add(1)
480 return nil
481 }).NotBefore(start).Maybe()
482 h.vid.EXPECT().Close().Return(nil).Once()
483
484 require.NoError(t, h.svc.Run(context.Background(), sel))
485 assert.Equal(t, int32(60), writes.Load(), "exact encoded length")
486 assert.Less(t, captures.Load(), int32(45), "slow captures are covered by repeated frames")
487 assert.Greater(t, captures.Load(), int32(10))
488}
489
490func TestRecordUntimedBackpressureBoundsRepeats(t *testing.T) {
491 h := newHarness(t)
492 sel := rec(0)
493 sel.Video.FPS = 60
494 ctx, cancel := context.WithCancel(context.Background())
495 start := recordOrder(h, sel)
496 var bp backpressure
497 bp.wire(h, start, 40*time.Millisecond) // slower than two slots per frame
498 h.vid.EXPECT().Close().Return(nil).Once()
499
500 time.AfterFunc(time.Second, cancel)
501 require.NoError(t, h.svc.Run(ctx, sel))
502 assert.Greater(t, bp.captures.Load(), int32(5))
503 assert.LessOrEqual(t, bp.maxRun, 3, "repeated writes between fresh captures")
504}
505
506func TestRecordCancelWritesNothingAfterStop(t *testing.T) {
507 h := newHarness(t)
508 sel := rec(0)
509 sel.Video.FPS = 50
510 ctx, cancel := context.WithCancel(context.Background())
511 start := recordOrder(h, sel)
512 h.src.EXPECT().Capture(mock.Anything, target).Return(frame, nil).Maybe()
513 var writes atomic.Int32
514 var closed atomic.Bool
515 h.vid.EXPECT().Write(mock.Anything, frame).RunAndReturn(func(ctx context.Context, _ ports.Frame) error {
516 require.NoError(t, ctx.Err(), "write after stop")
517 if writes.Add(1) == 4 {
518 cancel() // stop arrives while this frame is being written
519 }
520 return nil
521 }).NotBefore(start).Maybe()
522 h.vid.EXPECT().Close().RunAndReturn(func() error {
523 closed.Store(true)
524 return nil
525 }).Once()
526
527 require.NoError(t, h.svc.Run(ctx, sel))
528 assert.Equal(t, int32(4), writes.Load(), "recording ends at the last frame written before the stop")
529 assert.True(t, closed.Load())
530}
531
532func TestRecordHugeDurationDoesNotOverflow(t *testing.T) {
533 h := newHarness(t)
534 sel := rec(time.Duration(1<<63 - 1))
535 ctx, cancel := context.WithCancel(context.Background())
536 start := recordOrder(h, sel)
537 h.src.EXPECT().Capture(mock.Anything, target).Return(frame, nil).Maybe()
538 h.vid.EXPECT().Write(mock.Anything, frame).Return(nil).NotBefore(start).Maybe()
539 h.vid.EXPECT().Close().Return(nil).Once()
540 time.AfterFunc(40*time.Millisecond, cancel)
541 require.NoError(t, h.svc.Run(ctx, sel))
542}
543
544func TestRecordAlreadyCancelled(t *testing.T) {
545 h := newHarness(t)
546 ctx, cancel := context.WithCancel(context.Background())
547 cancel()
548 require.ErrorIs(t, h.svc.Run(ctx, rec(0)), context.Canceled)
549}
550
551func TestRecordStartFailures(t *testing.T) {
552 boom := errors.New("boom")
553 abortErr := errors.New("abort failed")
554 t.Run("first capture", func(t *testing.T) {
555 h := newHarness(t)
556 h.src.EXPECT().Capture(mock.Anything, target).Return(ports.Frame{}, boom).Once()
557 require.ErrorIs(t, h.svc.Run(context.Background(), rec(0)), boom)
558 })
559 t.Run("invalid first frame", func(t *testing.T) {
560 h := newHarness(t)
561 h.src.EXPECT().Capture(mock.Anything, target).Return(ports.Frame{Width: 2}, nil).Once()
562 require.Error(t, h.svc.Run(context.Background(), rec(0)))
563 })
564 t.Run("start with abort failure", func(t *testing.T) {
565 h := newHarness(t)
566 sel := rec(0)
567 c := h.src.EXPECT().Capture(mock.Anything, target).Return(frame, nil).Once()
568 s := h.vid.EXPECT().Start(mock.Anything, frame, sel.Path, sel.Video).Return(boom).Once().NotBefore(c)
569 h.vid.EXPECT().Abort().Return(abortErr).Once().NotBefore(s)
570 err := h.svc.Run(context.Background(), sel)
571 require.ErrorIs(t, err, boom)
572 require.ErrorIs(t, err, abortErr)
573 })
574 t.Run("path exists", func(t *testing.T) {
575 h := newHarness(t)
576 sel := rec(0)
577 h.src.EXPECT().Capture(mock.Anything, target).Return(frame, nil).Once()
578 h.vid.EXPECT().Start(mock.Anything, frame, sel.Path, sel.Video).Return(ports.ErrPathExists).Once()
579 h.vid.EXPECT().Abort().Return(nil).Once()
580 require.ErrorIs(t, h.svc.Run(context.Background(), sel), ports.ErrPathExists)
581 })
582}
583
584func TestRecordRuntimeFailures(t *testing.T) {
585 boom := errors.New("boom")
586 closeErr := errors.New("close failed")
587
588 t.Run("first write", func(t *testing.T) {
589 h := newHarness(t)
590 sel := rec(0)
591 start := recordOrder(h, sel)
592 w := h.vid.EXPECT().Write(mock.Anything, frame).Return(boom).Once().NotBefore(start)
593 h.vid.EXPECT().Abort().Return(closeErr).Once().NotBefore(w)
594 err := h.svc.Run(context.Background(), sel)
595 require.ErrorIs(t, err, boom)
596 require.ErrorIs(t, err, closeErr)
597 })
598 t.Run("later capture", func(t *testing.T) {
599 h := newHarness(t)
600 sel := rec(0)
601 start := recordOrder(h, sel)
602 w := h.vid.EXPECT().Write(mock.Anything, frame).Return(nil).Once().NotBefore(start)
603 h.src.EXPECT().Capture(mock.Anything, target).Return(ports.Frame{}, boom).Once()
604 h.vid.EXPECT().Close().Return(nil).Once().NotBefore(w)
605 err := h.svc.Run(context.Background(), sel)
606 require.ErrorIs(t, err, boom)
607 assert.NotErrorIs(t, err, closeErr)
608 })
609 t.Run("later invalid frame", func(t *testing.T) {
610 h := newHarness(t)
611 sel := rec(0)
612 start := recordOrder(h, sel)
613 w := h.vid.EXPECT().Write(mock.Anything, frame).Return(nil).Once().NotBefore(start)
614 h.src.EXPECT().Capture(mock.Anything, target).Return(ports.Frame{}, nil).Once()
615 h.vid.EXPECT().Close().Return(nil).Once().NotBefore(w)
616 require.Error(t, h.svc.Run(context.Background(), sel))
617 })
618 t.Run("later capture and close", func(t *testing.T) {
619 h := newHarness(t)
620 sel := rec(0)
621 start := recordOrder(h, sel)
622 w := h.vid.EXPECT().Write(mock.Anything, frame).Return(nil).Once().NotBefore(start)
623 h.src.EXPECT().Capture(mock.Anything, target).Return(ports.Frame{}, boom).Once()
624 h.vid.EXPECT().Close().Return(closeErr).Once().NotBefore(w)
625 err := h.svc.Run(context.Background(), sel)
626 require.ErrorIs(t, err, boom)
627 require.ErrorIs(t, err, closeErr)
628 })
629 t.Run("geometry change", func(t *testing.T) {
630 h := newHarness(t)
631 sel := rec(0)
632 start := recordOrder(h, sel)
633 first := h.vid.EXPECT().Write(mock.Anything, frame).Return(nil).Once().NotBefore(start)
634 h.src.EXPECT().Capture(mock.Anything, target).Return(frame, nil).Once()
635 h.vid.EXPECT().Write(mock.Anything, frame).Return(ports.ErrGeometryChanged).Once().NotBefore(first)
636 h.vid.EXPECT().Close().Return(nil).Once()
637 require.ErrorIs(t, h.svc.Run(context.Background(), sel), ports.ErrGeometryChanged)
638 })
639 t.Run("close only", func(t *testing.T) {
640 h := newHarness(t)
641 sel := rec(20 * time.Millisecond)
642 start := recordOrder(h, sel)
643 h.src.EXPECT().Capture(mock.Anything, target).Return(frame, nil).Maybe()
644 h.vid.EXPECT().Write(mock.Anything, frame).Return(nil).NotBefore(start).Maybe()
645 h.vid.EXPECT().Close().Return(closeErr).Once()
646 require.ErrorIs(t, h.svc.Run(context.Background(), sel), closeErr)
647 })
648}