main 3d41bc5

service_test.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 9

↑↓ moveenter openesc close