feat/data-dnd e05cb15

capture_test.go RawHistoryFolder

1package headless
2
3import (
4	"context"
5	"errors"
6	"image"
7	"os"
8	"sync"
9	"sync/atomic"
10	"testing"
11	"time"
12
13	"github.com/bnema/neferwl/internal/adapters/outputkit/capture"
14	portsmocks "github.com/bnema/neferwl/internal/mocks/ports"
15	"github.com/bnema/neferwl/internal/ports"
16	"github.com/stretchr/testify/mock"
17	"github.com/stretchr/testify/require"
18)
19
20type events struct {
21	mu  sync.Mutex
22	log []string
23}
24
25func (e *events) add(s string) {
26	e.mu.Lock()
27	defer e.mu.Unlock()
28	e.log = append(e.log, s)
29}
30
31func (e *events) get() []string {
32	e.mu.Lock()
33	defer e.mu.Unlock()
34	return append([]string(nil), e.log...)
35}
36
37func sessionCaptureFile(t *testing.T) ports.SHMBuffer {
38	t.Helper()
39	f, err := os.CreateTemp(t.TempDir(), "capture")
40	require.NoError(t, err)
41	t.Cleanup(func() { _ = f.Close() })
42	require.NoError(t, f.Truncate(16))
43	return ports.SHMBuffer{File: f}
44}
45
46// runSession drives one frame with the given requests and returns the
47// renderer event order and the replies.
48func runSession(t *testing.T, scene ports.Scene, reqs []ports.CaptureRequest, wantReplies int) ([]string, []ports.CaptureDone) {
49	t.Helper()
50	ev := &events{}
51	r := portsmocks.NewMockRenderer(t)
52	r.EXPECT().Render(mock.Anything, mock.Anything).RunAndReturn(func(s ports.Scene, _ map[ports.WindowID]ports.SurfaceContent) (*os.File, error) {
53		// Excluded and plain scenes are renumbered 0; the tests' displayed scenes are not.
54		if s.Seq == 0 {
55			ev.add("render:excluded")
56		} else {
57			ev.add("render:full")
58		}
59		return nil, nil
60	}).Maybe()
61	frame := portsmocks.NewMockCaptureFrame(t)
62	frame.EXPECT().Done().Return(nil).Maybe()
63	frame.EXPECT().Read(mock.Anything, mock.Anything, 8).Return(nil).Maybe()
64	r.EXPECT().BeginCapture().RunAndReturn(func() (ports.CaptureFrame, error) {
65		ev.add("begin")
66		return frame, nil
67	}).Maybe()
68	r.EXPECT().EndCapture(frame).Return().Maybe()
69	r.EXPECT().Close().Return().Once()
70	ctx, cancel := context.WithCancel(context.Background())
71	defer cancel()
72	scenes := make(chan ports.Scene, 1)
73	incoming := make(chan ports.CaptureRequest)
74	replies := make(chan ports.CaptureDone, 8)
75	done := make(chan error, 1)
76	go func() {
77		done <- Run(ctx, Options{Width: 2, Height: 2, NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }, Captured: replies}, scenes, nil, nil, nil, incoming)
78	}()
79	// Requests wait for a scene: all of them belong to its first frame.
80	for _, q := range reqs {
81		q.Region, q.Width, q.Height, q.Stride, q.Format = image.Rect(0, 0, 2, 2), 2, 2, 8, 1
82		q.Dst = sessionCaptureFile(t)
83		incoming <- q
84	}
85	scenes <- scene
86	var got []ports.CaptureDone
87	for range wantReplies {
88		select {
89		case d := <-replies:
90			got = append(got, d)
91		case <-time.After(3 * time.Second):
92			t.Fatal("missing capture reply")
93		}
94	}
95	cancel()
96	require.NoError(t, <-done)
97	return ev.get(), got
98}
99
100func sessionScene() ports.Scene {
101	return ports.Scene{Seq: 2, Windows: []ports.SceneWindow{{ID: 1}}, Layers: []ports.SceneLayer{{ID: 10, Layer: ports.LayerTop}},
102		Capture: &ports.SceneCapture{Session: 7, Revision: 1, Excluded: []ports.WindowID{10}}}
103}
104
105func TestExcludeCaptureOrderedBeforeDisplayedFrame(t *testing.T) {
106	order, replies := runSession(t, sessionScene(), []ports.CaptureRequest{{ID: 1, Exclude: true, Session: 7}, {ID: 2}}, 2)
107	// The Exclude composition and its copy come first; the displayed frame and
108	// the standard capture (HUD and border included) follow.
109	require.Equal(t, []string{"render:excluded", "begin", "render:full", "begin"}, order)
110	for _, d := range replies {
111		require.NoError(t, d.Err)
112	}
113}
114
115func TestExcludeCaptureFailsClosedWithoutMatchingSession(t *testing.T) {
116	scene := sessionScene()
117	scene.Capture = nil
118	order, replies := runSession(t, scene, []ports.CaptureRequest{{ID: 1, Exclude: true, Session: 7}, {ID: 2}}, 2)
119	require.Equal(t, []string{"render:full", "begin"}, order) // one frame, the standard capture only
120	byID := map[uint64]error{}
121	for _, d := range replies {
122		byID[d.ID] = d.Err
123	}
124	require.ErrorIs(t, byID[1], capture.ErrSessionInactive)
125	require.NoError(t, byID[2])
126}
127
128func TestStandardCaptureDuringSessionUsesOneComposition(t *testing.T) {
129	order, replies := runSession(t, sessionScene(), []ports.CaptureRequest{{ID: 2}}, 1)
130	require.Equal(t, []string{"render:full", "begin"}, order)
131	require.NoError(t, replies[0].Err)
132}
133
134// A hidden workspace is drawn by the child renderer (made by the factory),
135// not by the display's; the display still renders the scene it shows.
136func TestHiddenWorkspaceCaptureUsesChildRenderer(t *testing.T) {
137	read, write, err := os.Pipe()
138	require.NoError(t, err)
139	defer write.Close()
140	display := portsmocks.NewMockRenderer(t)
141	display.EXPECT().Render(mock.Anything, mock.Anything).Return(nil, nil).Once()
142	display.EXPECT().Close().Return().Once()
143	child := portsmocks.NewMockRenderer(t)
144	frame := portsmocks.NewMockCaptureFrame(t)
145	frame.EXPECT().Done().Return(nil).Maybe()
146	frame.EXPECT().Read(mock.Anything, mock.Anything, 8).Return(nil).Maybe()
147	child.EXPECT().Render(mock.Anything, mock.Anything).RunAndReturn(func(s ports.Scene, _ map[ports.WindowID]ports.SurfaceContent) (*os.File, error) {
148		require.Nil(t, s.Capture)
149		require.Len(t, s.Layers, 0, "a workspace rendered off screen has no layers")
150		return read, nil
151	}).Once()
152	child.EXPECT().BeginCapture().Return(frame, nil).Once()
153	child.EXPECT().EndCapture(frame).Return().Maybe()
154	child.EXPECT().Close().Return().Once()
155	scene := sessionScene()
156	scene.OutputWidth, scene.OutputHeight, scene.Scale = 2, 2, 1
157	scene.Capture.Workspace = 9
158	scene.CaptureScene = &ports.Scene{OutputWidth: 2, OutputHeight: 2, Scale: 1, Windows: []ports.SceneWindow{{ID: 5}}}
159	ctx, cancel := context.WithCancel(context.Background())
160	defer cancel()
161	scenes := make(chan ports.Scene, 1)
162	incoming := make(chan ports.CaptureRequest)
163	replies := make(chan ports.CaptureDone, 2)
164	done := make(chan error, 1)
165	var made [2]int
166	sink, presented := presentedSink(t, 4)
167	go func() {
168		done <- Run(ctx, Options{Width: 2, Height: 2, Captured: replies, Presented: sink,
169			NewRenderer:        func(int, int) (ports.Renderer, error) { return display, nil },
170			NewCaptureRenderer: func(w, h int) (ports.Renderer, error) { made = [2]int{w, h}; return child, nil },
171		}, scenes, nil, nil, nil, incoming)
172	}()
173	q := ports.CaptureRequest{ID: 1, Workspace: 9, OffScreen: true, Region: image.Rect(0, 0, 2, 2), Width: 2, Height: 2, Stride: 8, Format: 1, Dst: sessionCaptureFile(t)}
174	incoming <- q
175	scenes <- scene
176	// The owner may report an idle frame before receiving the first scene.
177	deadline := time.After(3 * time.Second)
178waitHold:
179	for {
180		select {
181		case report := <-presented:
182			if len(report.ChildReads) > 0 {
183				require.Equal(t, map[ports.WindowID]uint64{5: 0}, report.ChildReads, "hold published before waiting")
184				break waitHold
185			}
186		case <-deadline:
187			t.Fatal("no child hold report")
188		}
189	}
190	select {
191	case <-presented:
192		t.Fatal("child hold lifted before its fence signalled")
193	case <-time.After(120 * time.Millisecond): // beyond Wayland's 100ms timeout
194	}
195	_, err = write.Write([]byte{1})
196	require.NoError(t, err)
197	select {
198	case report := <-presented:
199		require.Empty(t, report.ChildReads, "completed render lifts child hold")
200	case <-time.After(3 * time.Second):
201		t.Fatal("no completed render report")
202	}
203	select {
204	case d := <-replies:
205		require.NoError(t, d.Err)
206	case <-time.After(3 * time.Second):
207		t.Fatal("no reply")
208	}
209	cancel()
210	require.NoError(t, <-done)
211	require.Equal(t, [2]int{2, 2}, made)
212}
213
214// A hidden request whose scene carries no CaptureScene fails closed instead of
215// being dropped or served from the displayed frame.
216func TestHiddenWorkspaceCaptureWithoutCaptureSceneFails(t *testing.T) {
217	scene := sessionScene()
218	scene.Capture.Workspace = 9
219	_, replies := runSession(t, scene, []ports.CaptureRequest{{ID: 1, Workspace: 9, OffScreen: true}}, 1)
220	require.ErrorIs(t, replies[0].Err, capture.ErrOffscreenUnavailable)
221}
222
223// GO007: a render error while an Exclude request was already handed to a worker
224// must not answer it a second time as request 0.
225func TestRenderErrorDoesNotAnswerHandedRequestsAgain(t *testing.T) {
226	display := portsmocks.NewMockRenderer(t)
227	boom := errors.New("boom")
228	display.EXPECT().Render(mock.MatchedBy(func(s ports.Scene) bool { return s.Seq == 0 }), mock.Anything).Return(nil, nil).Once() // excluded frame
229	display.EXPECT().Render(mock.MatchedBy(func(s ports.Scene) bool { return s.Seq != 0 }), mock.Anything).Return(nil, boom).Once()
230	frame := portsmocks.NewMockCaptureFrame(t)
231	frame.EXPECT().Done().Return(nil).Maybe()
232	frame.EXPECT().Read(mock.Anything, mock.Anything, 8).Return(nil).Maybe()
233	display.EXPECT().BeginCapture().Return(frame, nil).Once()
234	display.EXPECT().EndCapture(frame).Return().Maybe()
235	display.EXPECT().Close().Return().Once()
236	ctx, cancel := context.WithCancel(context.Background())
237	defer cancel()
238	scenes := make(chan ports.Scene, 1)
239	incoming := make(chan ports.CaptureRequest)
240	replies := make(chan ports.CaptureDone, 8)
241	done := make(chan error, 1)
242	go func() {
243		done <- Run(ctx, Options{Width: 2, Height: 2, Captured: replies,
244			NewRenderer: func(int, int) (ports.Renderer, error) { return display, nil }}, scenes, nil, nil, nil, incoming)
245	}()
246	incoming <- ports.CaptureRequest{ID: 1, Exclude: true, Session: 7, Region: image.Rect(0, 0, 2, 2), Width: 2, Height: 2, Stride: 8, Format: 1, Dst: sessionCaptureFile(t)}
247	scenes <- sessionScene()
248	select {
249	case err := <-done:
250		require.ErrorIs(t, err, boom)
251	case <-time.After(3 * time.Second):
252		t.Fatal("output did not stop on the render error")
253	}
254	var ids []uint64
255	for len(replies) > 0 {
256		ids = append(ids, (<-replies).ID)
257	}
258	require.Equal(t, []uint64{1}, ids, "request 1 answered once by its worker, no pseudo request 0")
259}
260
261// A scene that shows the capture indicator is never the source of a capture:
262// the standard capture is copied from a frame drawn without it, before the
263// displayed frame.
264func TestStandardCaptureNeverHoldsTheIndicator(t *testing.T) {
265	scene := sessionScene()
266	scene.Capture = nil
267	scene.CaptureIndicators = []ports.CaptureIndicator{{Rect: ports.Rect{W: 2, H: 2}}}
268	order, replies := runSession(t, scene, []ports.CaptureRequest{{ID: 2}}, 1)
269	require.Equal(t, []string{"render:excluded", "begin", "render:full"}, order)
270	require.NoError(t, replies[0].Err)
271	// An Exclude request of the session is served from its own excluded frame.
272	scene = sessionScene()
273	scene.CaptureIndicators = []ports.CaptureIndicator{{Rect: ports.Rect{W: 2, H: 2}}}
274	order, replies = runSession(t, scene, []ports.CaptureRequest{{ID: 1, Exclude: true, Session: 7}, {ID: 2}}, 2)
275	require.Equal(t, []string{"render:excluded", "begin", "render:excluded", "begin", "render:full"}, order)
276	for _, d := range replies {
277		require.NoError(t, d.Err)
278	}
279}
280
281// A capture is requested before core published the scene that shows its
282// indicator: it is held, not served from the scene the output already has, and
283// served from the first scene that shows the border, with the indicator on
284// screen in that same frame.
285func TestCaptureWaitsForItsIndicatorScene(t *testing.T) {
286	ev := &events{}
287	r := portsmocks.NewMockRenderer(t)
288	r.EXPECT().Render(mock.Anything, mock.Anything).RunAndReturn(func(s ports.Scene, _ map[ports.WindowID]ports.SurfaceContent) (*os.File, error) {
289		switch {
290		case s.Seq == 0:
291			ev.add("render:plain")
292		case len(s.CaptureIndicators) > 0:
293			ev.add("render:indicated")
294		default:
295			ev.add("render:bare")
296		}
297		return nil, nil
298	}).Maybe()
299	frame := portsmocks.NewMockCaptureFrame(t)
300	frame.EXPECT().Done().Return(nil).Maybe()
301	frame.EXPECT().Read(mock.Anything, mock.Anything, 8).Return(nil).Maybe()
302	r.EXPECT().BeginCapture().RunAndReturn(func() (ports.CaptureFrame, error) {
303		ev.add("begin")
304		return frame, nil
305	}).Maybe()
306	r.EXPECT().EndCapture(frame).Return().Maybe()
307	r.EXPECT().Close().Return().Once()
308	ctx, cancel := context.WithCancel(context.Background())
309	defer cancel()
310	scenes := make(chan ports.Scene, 2)
311	incoming := make(chan ports.CaptureRequest)
312	replies := make(chan ports.CaptureDone, 4)
313	done := make(chan error, 1)
314	go func() {
315		done <- Run(ctx, Options{Width: 2, Height: 2, NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }, Captured: replies}, scenes, nil, nil, nil, incoming)
316	}()
317	bare := ports.Scene{Seq: 2, Scale: 1, OutputWidth: 2, OutputHeight: 2, Windows: []ports.SceneWindow{{ID: 1}}}
318	scenes <- bare
319	require.Eventually(t, func() bool { return len(ev.get()) == 1 }, 2*time.Second, time.Millisecond)
320	require.Equal(t, []string{"render:bare"}, ev.get())
321
322	incoming <- ports.CaptureRequest{ID: 1, Indicate: true, Region: image.Rect(0, 0, 2, 2), Width: 2, Height: 2, Stride: 8, Format: 1, Dst: sessionCaptureFile(t)}
323	select {
324	case d := <-replies:
325		t.Fatalf("served from a scene without the indicator: %+v", d)
326	case <-time.After(50 * time.Millisecond):
327	}
328	require.Equal(t, []string{"render:bare"}, ev.get(), "nothing is copied, nothing is redrawn for a held request")
329
330	indicated := bare
331	indicated.Seq = 3
332	indicated.CaptureIndicators = []ports.CaptureIndicator{{Rect: ports.Rect{W: 2, H: 2}}}
333	scenes <- indicated
334	select {
335	case d := <-replies:
336		require.NoError(t, d.Err)
337		require.Equal(t, uint64(1), d.ID)
338	case <-time.After(2 * time.Second):
339		t.Fatal("not served once the indicator scene exists")
340	}
341	require.Equal(t, []string{"render:bare", "render:plain", "begin", "render:indicated"}, ev.get(), "copied from the frame without the indicator, which is on screen in the same frame")
342	cancel()
343	require.NoError(t, <-done)
344}
345
346// A request whose indicator never comes (core marks nothing) fails after the
347// bounded wait, and is never served.
348func TestCaptureWithoutIndicatorFailsAfterTheBoundedWait(t *testing.T) {
349	r := portsmocks.NewMockRenderer(t)
350	r.EXPECT().Render(mock.Anything, mock.Anything).Return(nil, nil).Once()
351	r.EXPECT().Close().Return().Once()
352	ctx, cancel := context.WithCancel(context.Background())
353	defer cancel()
354	scenes := make(chan ports.Scene, 1)
355	incoming := make(chan ports.CaptureRequest)
356	replies := make(chan ports.CaptureDone, 4)
357	done := make(chan error, 1)
358	go func() {
359		done <- Run(ctx, Options{Width: 2, Height: 2, NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }, Captured: replies}, scenes, nil, nil, nil, incoming)
360	}()
361	scenes <- ports.Scene{Seq: 2, Scale: 1, OutputWidth: 2, OutputHeight: 2}
362	start := time.Now()
363	incoming <- ports.CaptureRequest{ID: 1, Indicate: true, Region: image.Rect(0, 0, 2, 2), Width: 2, Height: 2, Stride: 8, Format: 1, Dst: sessionCaptureFile(t)}
364	select {
365	case d := <-replies:
366		require.ErrorIs(t, d.Err, capture.ErrIndicatorMissing)
367		require.GreaterOrEqual(t, time.Since(start), capture.HoldFor-20*time.Millisecond)
368	case <-time.After(3 * time.Second):
369		t.Fatal("a held request was never answered")
370	}
371	cancel()
372	require.NoError(t, <-done)
373}
374
375// The capture of a frame is delivered once that frame, with its indicator, is
376// presented. When its render fails, the capture fails and no pixel is written
377// (the mock frame counts reads); when it succeeds it is delivered.
378func TestCaptureDeliveredOnlyWhenTheIndicatedFramePresents(t *testing.T) {
379	boom := errors.New("boom")
380	for _, tc := range []struct {
381		name   string
382		render error
383	}{{"presented", nil}, {"render error", boom}} {
384		t.Run(tc.name, func(t *testing.T) {
385			var reads atomic.Int32
386			r := portsmocks.NewMockRenderer(t)
387			r.EXPECT().Render(mock.Anything, mock.Anything).RunAndReturn(func(s ports.Scene, _ map[ports.WindowID]ports.SurfaceContent) (*os.File, error) {
388				if s.Seq != 0 {
389					return nil, tc.render // the indicated frame
390				}
391				return nil, nil // the plain frame
392			}).Maybe()
393			frame := portsmocks.NewMockCaptureFrame(t)
394			frame.EXPECT().Done().Return(nil).Maybe()
395			frame.EXPECT().Read(mock.Anything, mock.Anything, 8).RunAndReturn(func(image.Rectangle, []byte, int) error {
396				reads.Add(1)
397				return nil
398			}).Maybe()
399			r.EXPECT().BeginCapture().Return(frame, nil).Maybe()
400			r.EXPECT().EndCapture(frame).Return().Maybe()
401			r.EXPECT().Close().Return().Once()
402			ctx, cancel := context.WithCancel(context.Background())
403			defer cancel()
404			scenes := make(chan ports.Scene, 1)
405			incoming := make(chan ports.CaptureRequest)
406			replies := make(chan ports.CaptureDone, 4)
407			done := make(chan error, 1)
408			go func() {
409				done <- Run(ctx, Options{Width: 2, Height: 2, NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }, Captured: replies}, scenes, nil, nil, nil, incoming)
410			}()
411			incoming <- ports.CaptureRequest{ID: 1, Indicate: true, Region: image.Rect(0, 0, 2, 2), Width: 2, Height: 2, Stride: 8, Format: 1, Dst: sessionCaptureFile(t)}
412			scenes <- ports.Scene{Seq: 2, Scale: 1, OutputWidth: 2, OutputHeight: 2, Windows: []ports.SceneWindow{{ID: 1}},
413				CaptureIndicators: []ports.CaptureIndicator{{Rect: ports.Rect{W: 2, H: 2}}}}
414			var d ports.CaptureDone
415			select {
416			case d = <-replies:
417			case <-time.After(3 * time.Second):
418				t.Fatal("no reply")
419			}
420			if tc.render == nil {
421				require.NoError(t, d.Err)
422				require.Equal(t, int32(1), reads.Load())
423				cancel()
424				require.NoError(t, <-done)
425				return
426			}
427			require.ErrorIs(t, d.Err, capture.ErrIndicatorMissing)
428			require.Zero(t, reads.Load(), "no pixel delivered")
429			require.ErrorIs(t, <-done, boom)
430		})
431	}
432}

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 25

Tags 10

↑↓ moveenter openesc close