main 3d41bc5

record_lifecycle_test.go RawHistoryFolder

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}

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