1package core_test
2
3import (
4 "context"
5 "slices"
6 "sync/atomic"
7 "testing"
8 "time"
9
10 "github.com/bnema/neferwl/internal/core"
11 portsmocks "github.com/bnema/neferwl/internal/mocks/ports"
12 "github.com/bnema/neferwl/internal/ports"
13)
14
15type captureRig struct {
16 client chan ports.ClientEvent
17 output chan ports.OutputEvent
18 commands chan ports.ClientCommand
19 scenes chan []ports.Scene
20 workspaces chan ports.Workspaces
21}
22
23// captureCore runs a core with one 100x80 output OUT-1.
24func captureCore(t *testing.T) *captureRig {
25 t.Helper()
26 cfg := scrollDefaults()
27 cfg.Border.Width = 0
28 r := &captureRig{
29 client: make(chan ports.ClientEvent, 16),
30 output: make(chan ports.OutputEvent, 8),
31 commands: make(chan ports.ClientCommand, 64),
32 scenes: make(chan []ports.Scene, 1),
33 workspaces: make(chan ports.Workspaces, 1),
34 }
35 c, err := core.New(cfg, core.Channels{Client: r.client, Output: r.output, Commands: r.commands, Scenes: r.scenes, Workspaces: r.workspaces}, core.Options{})
36 if err != nil {
37 t.Fatal(err)
38 }
39 ctx, cancel := context.WithCancel(context.Background())
40 t.Cleanup(cancel)
41 go c.Run(ctx)
42 r.output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "OUT-1", Width: 100, Height: 80}}
43 scene(t, r.scenes)
44 return r
45}
46
47// state returns the next CaptureSessionState, skipping other commands.
48func (r *captureRig) state(t *testing.T) ports.CaptureSessionState {
49 t.Helper()
50 for {
51 if v, ok := receive(t, r.commands).(ports.CaptureSessionState); ok {
52 return v
53 }
54 }
55}
56
57// sceneWith returns the next scene whose capture presence equals want.
58func (r *captureRig) sceneWith(t *testing.T, want bool) ports.Scene {
59 t.Helper()
60 for {
61 if s := scene(t, r.scenes); (s.Capture != nil) == want {
62 return s
63 }
64 }
65}
66
67func (r *captureRig) layer(id ports.WindowID, layer ports.Layer, keyboard uint32) ports.LayerSurface {
68 return ports.LayerSurface{ID: id, Layer: layer, Anchor: ports.AnchorTop | ports.AnchorLeft, Width: 10, Height: 10, Output: "OUT-1", Keyboard: keyboard}
69}
70
71// drainCommands drops the commands already queued. Call it only once the
72// core is quiet (after receiving a scene, which is published after its
73// commands) and before sending the event under test: draining after the
74// send races with the core emitting the commands the test waits for.
75func (r *captureRig) drainCommands() {
76 for len(r.commands) > 0 {
77 <-r.commands
78 }
79}
80
81// excludedScene waits for a scene whose exclusion list has n entries.
82func (r *captureRig) excludedScene(t *testing.T, n int) ports.Scene {
83 t.Helper()
84 for {
85 if s := r.sceneWith(t, true); len(s.Capture.Excluded) == n {
86 return s
87 }
88 }
89}
90
91func TestCaptureSessionOutputTarget(t *testing.T) {
92 r := captureCore(t)
93 r.client <- ports.CaptureSessionOpen{ID: 1, Output: "OUT-1", Region: ports.Rect{X: -10, Y: 10, W: 50, H: 200}}
94 st := r.state(t)
95 if !st.Active || st.Hidden || st.Workspace != 0 || st.Output != "OUT-1" || st.Rect != (ports.Rect{X: 0, Y: 10, W: 40, H: 70}) {
96 t.Fatalf("state %+v", st)
97 }
98 // Without an exclusion the scene carries no exclusion and no hidden workspace.
99 if s := r.sceneWith(t, true); s.Capture.Session != 0 || len(s.Capture.Excluded) != 0 || s.Capture.Workspace != 0 || s.CaptureScene != nil {
100 t.Fatalf("capture %+v", s.Capture)
101 }
102 r.client <- ports.CaptureSessionClose{ID: 1}
103 r.sceneWith(t, false)
104 // A zero region is the whole output.
105 r.client <- ports.CaptureSessionOpen{ID: 2, Output: "OUT-1"}
106 if st := r.state(t); st.Rect != (ports.Rect{W: 100, H: 80}) {
107 t.Fatalf("state %+v", st)
108 }
109}
110
111func TestCaptureSessionWorkspaceTarget(t *testing.T) {
112 r := captureCore(t)
113 r.client <- ports.WindowMapped{ID: 1}
114 scene(t, r.scenes)
115 ws := receive(t, r.workspaces).Outputs[0].Workspaces
116 if ws[0].Frame != (ports.Rect{W: 100, H: 80}) {
117 t.Fatalf("frame %+v", ws[0])
118 }
119 r.client <- ports.CaptureSessionOpen{ID: 1, Workspace: ws[0].ID}
120 if st := r.state(t); !st.Active || st.Hidden || st.Output != "OUT-1" || st.Workspace != ws[0].ID || st.Rect != ws[0].Frame {
121 t.Fatalf("state %+v", st)
122 }
123 if s := r.sceneWith(t, true); s.Capture.Shown != ws[0].ID || s.Capture.Workspace != 0 {
124 t.Fatalf("capture %+v", s.Capture)
125 }
126 r.client <- ports.CaptureSessionClose{ID: 1}
127 r.sceneWith(t, false)
128 // A workspace that does not exist ends the session.
129 r.client <- ports.CaptureSessionOpen{ID: 2, Workspace: 9999}
130 if st := r.state(t); st.Active || st.Reason != ports.CaptureReasonWorkspaceGone || !st.Reason.Terminal() {
131 t.Fatalf("state %+v", st)
132 }
133}
134
135// Only one hidden workspace is rendered at a time: a second session on
136// another hidden workspace is not Active until the first one ends.
137func TestCaptureSessionOneHiddenWorkspaceAtATime(t *testing.T) {
138 r := captureCore(t)
139 r.client <- ports.WindowMapped{ID: 1}
140 scene(t, r.scenes)
141 var ws []ports.WorkspaceInfo
142 for len(ws) < 2 {
143 ws = receive(t, r.workspaces).Outputs[0].Workspaces
144 }
145 // Workspace 2 is on screen after this; 1 and a third are hidden.
146 r.client <- ports.WorkspaceActivate{IDs: []uint64{ws[1].ID}}
147 r.client <- ports.CaptureSessionOpen{ID: 1, Workspace: ws[0].ID}
148 if st := r.state(t); !st.Active || !st.Hidden {
149 t.Fatalf("first hidden session %+v", st)
150 }
151 r.client <- ports.CaptureSessionOpen{ID: 2, Workspace: ws[0].ID}
152 r.client <- ports.CaptureSessionOpen{ID: 3, Workspace: ws[1].ID}
153 for {
154 if st := r.state(t); st.ID == 3 {
155 if !st.Active || st.Hidden {
156 t.Fatalf("session on the workspace on screen %+v", st)
157 }
158 break
159 }
160 }
161}
162
163func TestCaptureSessionTerminalReasons(t *testing.T) {
164 r := captureCore(t)
165 r.client <- ports.CaptureSessionOpen{ID: 1, Output: "NOPE"}
166 if st := r.state(t); st.Reason != ports.CaptureReasonOutputGone || st.Active {
167 t.Fatalf("state %+v", st)
168 }
169 r.client <- ports.CaptureSessionOpen{ID: 2, Output: "OUT-1", Region: ports.Rect{X: 500, Y: 500, W: 5, H: 5}}
170 if st := r.state(t); st.Reason != ports.CaptureReasonInvalidRegion {
171 t.Fatalf("state %+v", st)
172 }
173 // An unplugged output ends the session.
174 r.client <- ports.CaptureSessionOpen{ID: 3, Output: "OUT-1"}
175 if st := r.state(t); !st.Active {
176 t.Fatalf("state %+v", st)
177 }
178 r.output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "OUT-2", Width: 100, Height: 80}}
179 r.output <- ports.OutputRemoved{Name: "OUT-1"}
180 if st := r.state(t); st.ID != 3 || st.Reason != ports.CaptureReasonOutputGone {
181 t.Fatalf("state %+v", st)
182 }
183 r.sceneWith(t, false)
184}
185
186// Several sessions live at once; each is decided on its own.
187func TestCaptureSessionsAreIndependent(t *testing.T) {
188 r := captureCore(t)
189 r.client <- ports.CaptureSessionOpen{ID: 1, Output: "OUT-1"}
190 r.client <- ports.CaptureSessionOpen{ID: 2, Output: "OUT-1", Region: ports.Rect{W: 10, H: 10}}
191 got := map[uint64]ports.CaptureSessionState{}
192 for len(got) < 2 {
193 st := r.state(t)
194 got[st.ID] = st
195 }
196 if !got[1].Active || !got[2].Active || got[2].Rect != (ports.Rect{W: 10, H: 10}) {
197 t.Fatalf("states %+v", got)
198 }
199 r.client <- ports.CaptureSessionClose{ID: 1}
200 r.client <- ports.CaptureSessionClose{ID: 7} // unknown: ignored
201 r.client <- ports.WindowMapped{ID: 1}
202 r.sceneWith(t, true) // session 2 lives on
203}
204
205// An exclusion needs a live session; attached layers stay over a fullscreen
206// window, are excluded with their popups, and other layers keep hiding.
207func TestCaptureExclusionLayersOverFullscreen(t *testing.T) {
208 r := captureCore(t)
209 r.client <- ports.WindowMapped{ID: 1}
210 scene(t, r.scenes)
211 r.client <- ports.WindowFullscreenRequest{ID: 1, Fullscreen: true, External: true}
212 scene(t, r.scenes)
213 r.client <- ports.LayerChanged{Layers: []ports.LayerSurface{r.layer(10, ports.LayerOverlay, 0), r.layer(11, ports.LayerTop, 0)}}
214 if s := scene(t, r.scenes); len(s.Layers) != 0 {
215 t.Fatalf("fullscreen should hide layers: %+v", s.Layers)
216 }
217 r.client <- ports.CaptureSessionOpen{ID: 1, Output: "OUT-1"}
218 r.client <- ports.CaptureExclusionBegin{Session: 1}
219 r.client <- ports.CaptureExclusionLayer{Session: 1, Layer: 10, Attached: true}
220 r.client <- ports.CaptureExclusionLayer{Session: 1, Layer: 10, Attached: true} // duplicate
221 r.client <- ports.PopupRequest{ID: 20, Parent: 10, Positioner: ports.Positioner{Width: 5, Height: 5, AnchorRect: ports.Rect{W: 1, H: 1}}}
222 r.client <- ports.PopupMapped{ID: 20}
223 s := r.excludedScene(t, 2)
224 if s.Capture.Session != 1 || s.Capture.Excluded[0] != 10 || s.Capture.Excluded[1] != 20 {
225 t.Fatalf("capture %+v", s.Capture)
226 }
227 if len(s.Layers) != 1 || s.Layers[0].ID != 10 {
228 t.Fatalf("only the attached layer shows over fullscreen: %+v", s.Layers)
229 }
230 // The exclusion ends with its session.
231 r.client <- ports.CaptureSessionClose{ID: 1}
232 if s = r.sceneWith(t, false); len(s.Layers) != 0 {
233 t.Fatalf("layers after the session: %+v", s.Layers)
234 }
235}
236
237// The exclusion of a session that is not registered, or of another session
238// than the live one, does nothing.
239func TestCaptureExclusionNeedsItsSession(t *testing.T) {
240 r := captureCore(t)
241 r.client <- ports.CaptureExclusionBegin{Session: 5} // unknown session
242 r.client <- ports.CaptureExclusionLayer{Session: 5, Layer: 10, Attached: true}
243 r.client <- ports.WindowMapped{ID: 1}
244 if s := scene(t, r.scenes); s.Capture != nil {
245 t.Fatalf("capture without session: %+v", s.Capture)
246 }
247 r.client <- ports.CaptureSessionOpen{ID: 1, Output: "OUT-1"}
248 r.client <- ports.CaptureSessionOpen{ID: 2, Output: "OUT-1"}
249 r.client <- ports.CaptureExclusionBegin{Session: 1}
250 r.client <- ports.CaptureExclusionBegin{Session: 2} // only one is live
251 r.client <- ports.CaptureExclusionLayer{Session: 2, Layer: 10, Attached: true} // not the owner
252 r.client <- ports.CaptureExclusionLayer{Session: 1, Layer: 11, Attached: true}
253 if s := r.excludedScene(t, 1); s.Capture.Session != 1 || s.Capture.Excluded[0] != 11 {
254 t.Fatalf("capture %+v", s.Capture)
255 }
256}
257
258// Wayland ends an exclusion whose object was destroyed; the session goes on.
259func TestCaptureExclusionEndKeepsSession(t *testing.T) {
260 r := captureCore(t)
261 r.client <- ports.CaptureSessionOpen{ID: 1, Output: "OUT-1"}
262 r.client <- ports.CaptureExclusionBegin{Session: 1}
263 r.client <- ports.CaptureExclusionLayer{Session: 1, Layer: 10, Attached: true}
264 r.excludedScene(t, 1)
265 r.client <- ports.CaptureExclusionEnd{Session: 1}
266 for {
267 s := r.sceneWith(t, true)
268 if s.Capture.Session == 0 && len(s.Capture.Excluded) == 0 {
269 break
270 }
271 }
272 // A new exclusion can begin on the live session.
273 r.client <- ports.CaptureExclusionBegin{Session: 1}
274 r.client <- ports.CaptureExclusionLayer{Session: 1, Layer: 12, Attached: true}
275 if s := r.excludedScene(t, 1); s.Capture.Excluded[0] != 12 {
276 t.Fatalf("capture %+v", s.Capture)
277 }
278}
279
280// When the exclusion object is destroyed and the session goes on, its HUD
281// layers that a screen still lists stay excluded (and are no longer kept over
282// a fullscreen window) until they are not listed: the recorder's frames never
283// show the HUD after the exclusion ended.
284func TestCaptureExclusionEndKeepsListedLayersExcluded(t *testing.T) {
285 r := captureCore(t)
286 r.client <- ports.WindowMapped{ID: 1}
287 scene(t, r.scenes)
288 r.client <- ports.WindowFullscreenRequest{ID: 1, Fullscreen: true, External: true}
289 scene(t, r.scenes)
290 r.client <- ports.LayerChanged{Layers: []ports.LayerSurface{r.layer(10, ports.LayerOverlay, 0)}}
291 scene(t, r.scenes)
292 r.client <- ports.CaptureSessionOpen{ID: 1, Output: "OUT-1"}
293 r.client <- ports.CaptureExclusionBegin{Session: 1}
294 r.client <- ports.CaptureExclusionLayer{Session: 1, Layer: 10, Attached: true}
295 s := r.excludedScene(t, 1)
296 if len(s.Layers) != 1 {
297 t.Fatalf("attached layer shows over fullscreen: %+v", s.Layers)
298 }
299 before := s.Capture.Revision
300 r.client <- ports.CaptureExclusionEnd{Session: 1}
301 s = r.excludedScene(t, 1)
302 if s.Capture.Session != 1 || s.Capture.Excluded[0] != 10 || s.Capture.Revision <= before {
303 t.Fatalf("ended exclusion's layer must stay excluded and fenced: %+v", s.Capture)
304 }
305 if len(s.Layers) != 0 {
306 t.Fatalf("an ended exclusion keeps nothing over fullscreen: %+v", s.Layers)
307 }
308 st := r.state(t)
309 for st.Revision != s.Capture.Revision {
310 st = r.state(t)
311 }
312 if !st.Exclusion || len(st.Layers) != 0 {
313 t.Fatalf("state %+v", st)
314 }
315 // The layer goes: nothing is excluded any more and the fence is lifted.
316 r.client <- ports.LayerChanged{}
317 for {
318 s = r.sceneWith(t, true)
319 if s.Capture.Session == 0 && len(s.Capture.Excluded) == 0 {
320 break
321 }
322 }
323 for st := r.state(t); st.Exclusion; st = r.state(t) {
324 }
325}
326
327func TestCaptureExclusionLayerBounds(t *testing.T) {
328 r := captureCore(t)
329 r.client <- ports.CaptureSessionOpen{ID: 1, Output: "OUT-1"}
330 r.client <- ports.CaptureExclusionBegin{Session: 1}
331 for id := ports.WindowID(1); id <= ports.MaxExclusionLayers+3; id++ {
332 r.client <- ports.CaptureExclusionLayer{Session: 1, Layer: id, Attached: true}
333 }
334 s := r.excludedScene(t, ports.MaxExclusionLayers)
335 for _, id := range s.Capture.Excluded {
336 if id > ports.MaxExclusionLayers {
337 t.Fatalf("excluded %v", s.Capture.Excluded)
338 }
339 }
340 // Detaching removes one.
341 r.client <- ports.CaptureExclusionLayer{Session: 1, Layer: 1}
342 r.excludedScene(t, ports.MaxExclusionLayers-1)
343}
344
345// More excluded surfaces than a capture can leave out (layers and popups)
346// stop the session: fail closed.
347func TestCaptureExclusionTooManyExcluded(t *testing.T) {
348 r := captureCore(t)
349 r.client <- ports.LayerChanged{Layers: []ports.LayerSurface{r.layer(10, ports.LayerOverlay, 0)}}
350 scene(t, r.scenes)
351 r.client <- ports.CaptureSessionOpen{ID: 1, Output: "OUT-1"}
352 r.client <- ports.CaptureExclusionBegin{Session: 1}
353 r.client <- ports.CaptureExclusionLayer{Session: 1, Layer: 10, Attached: true}
354 stopped := make(chan struct{})
355 go func() {
356 // Drain the commands while the popups are sent.
357 for cmd := range r.commands {
358 if st, ok := cmd.(ports.CaptureSessionState); ok && st.Reason == ports.CaptureReasonTooManyExcluded {
359 close(stopped)
360 return
361 }
362 }
363 }()
364 for i := range ports.MaxCaptureExcluded {
365 r.client <- ports.PopupRequest{ID: ports.WindowID(100 + i), Parent: 10, Positioner: ports.Positioner{Width: 5, Height: 5, AnchorRect: ports.Rect{W: 1, H: 1}}}
366 r.client <- ports.PopupMapped{ID: ports.WindowID(100 + i)}
367 }
368 select {
369 case <-stopped:
370 case <-time.After(5 * time.Second):
371 t.Fatal("session not stopped")
372 }
373}
374
375// An attached layer asking for exclusive keyboard never gets it; an
376// unrelated layer may.
377func TestCaptureExclusionKeyboardClamp(t *testing.T) {
378 r := captureCore(t)
379 r.client <- ports.LayerChanged{Layers: []ports.LayerSurface{r.layer(10, ports.LayerOverlay, 0), r.layer(11, ports.LayerOverlay, 0)}}
380 scene(t, r.scenes)
381 r.client <- ports.CaptureSessionOpen{ID: 1, Output: "OUT-1"}
382 r.client <- ports.CaptureExclusionBegin{Session: 1}
383 r.client <- ports.CaptureExclusionLayer{Session: 1, Layer: 10, Attached: true}
384 r.excludedScene(t, 1)
385 r.drainCommands()
386 r.client <- ports.LayerChanged{Layers: []ports.LayerSurface{r.layer(10, ports.LayerOverlay, 1), r.layer(11, ports.LayerOverlay, 1)}}
387 scene(t, r.scenes)
388 for {
389 if f, ok := receive(t, r.commands).(ports.FocusWindow); ok {
390 if f.ID != 11 {
391 t.Fatalf("keyboard went to %d, want the unattached layer 11", f.ID)
392 }
393 return
394 }
395 }
396}
397
398// An attached layer never gets keyboard interactivity, exclusive or on demand.
399func TestCaptureExclusionKeyboardNone(t *testing.T) {
400 r := captureCore(t)
401 r.client <- ports.LayerChanged{Layers: []ports.LayerSurface{r.layer(10, ports.LayerOverlay, 2), r.layer(11, ports.LayerOverlay, 2)}}
402 scene(t, r.scenes)
403 r.client <- ports.CaptureSessionOpen{ID: 1, Output: "OUT-1"}
404 r.client <- ports.CaptureExclusionBegin{Session: 1}
405 r.client <- ports.CaptureExclusionLayer{Session: 1, Layer: 10, Attached: true}
406 r.excludedScene(t, 1)
407 r.drainCommands()
408 r.client <- ports.WindowMapped{ID: 1}
409 scene(t, r.scenes)
410 for len(r.commands) > 0 {
411 if f, ok := (<-r.commands).(ports.FocusWindow); ok && f.ID == 10 {
412 t.Fatalf("keyboard went to the attached layer")
413 }
414 }
415}
416
417// Every change an excluded frame depends on bumps the revision, in the state
418// and in the scene that carries it; an unchanged republish does not.
419func TestCaptureExclusionRevisionFence(t *testing.T) {
420 r := captureCore(t)
421 r.client <- ports.CaptureSessionOpen{ID: 1, Output: "OUT-1"}
422 if st := r.state(t); st.Exclusion || st.Revision != 0 {
423 t.Fatalf("state without exclusion %+v", st)
424 }
425 r.client <- ports.CaptureExclusionBegin{Session: 1}
426 st := r.state(t)
427 if !st.Exclusion || st.Revision != 1 || len(st.Layers) != 0 {
428 t.Fatalf("state %+v", st)
429 }
430 for {
431 if s := r.sceneWith(t, true); s.Capture.Revision == 1 {
432 break
433 }
434 }
435 r.client <- ports.CaptureExclusionLayer{Session: 1, Layer: 10, Attached: true}
436 st = r.state(t)
437 if st.Revision != 2 || len(st.Layers) != 1 || st.Layers[0] != 10 {
438 t.Fatalf("state after attach %+v", st)
439 }
440 var s ports.Scene
441 for {
442 if s = r.sceneWith(t, true); s.Capture.Revision == 2 {
443 break
444 }
445 }
446 if len(s.Capture.Excluded) != 1 || s.Capture.Excluded[0] != 10 {
447 t.Fatalf("scene %+v", s.Capture)
448 }
449 // An unrelated publish keeps the revision and sends no state.
450 r.client <- ports.WindowMapped{ID: 1}
451 if s = r.sceneWith(t, true); s.Capture.Revision != 2 {
452 t.Fatalf("revision moved without a change: %d", s.Capture.Revision)
453 }
454 for len(r.commands) > 0 {
455 if v, ok := (<-r.commands).(ports.CaptureSessionState); ok {
456 t.Fatalf("state without a change: %+v", v)
457 }
458 }
459 r.client <- ports.CaptureExclusionLayer{Session: 1, Layer: 10}
460 if st = r.state(t); st.Revision != 3 || len(st.Layers) != 0 {
461 t.Fatalf("state after detach %+v", st)
462 }
463}
464
465// A detached layer stays excluded until a LayerChanged stops listing it, so
466// the last frame of a destroyed HUD never leaks into a capture.
467func TestCaptureExclusionDetachRetains(t *testing.T) {
468 r := captureCore(t)
469 r.client <- ports.LayerChanged{Layers: []ports.LayerSurface{r.layer(10, ports.LayerOverlay, 0)}}
470 scene(t, r.scenes)
471 r.client <- ports.CaptureSessionOpen{ID: 1, Output: "OUT-1"}
472 r.client <- ports.CaptureExclusionBegin{Session: 1}
473 r.client <- ports.CaptureExclusionLayer{Session: 1, Layer: 10, Attached: true}
474 r.excludedScene(t, 1)
475 r.client <- ports.CaptureExclusionLayer{Session: 1, Layer: 10}
476 if s := r.sceneWith(t, true); len(s.Capture.Excluded) != 1 || s.Capture.Excluded[0] != 10 {
477 t.Fatalf("detached layer no longer excluded while mapped: %+v", s.Capture)
478 }
479 r.client <- ports.LayerChanged{Layers: nil}
480 r.excludedScene(t, 0)
481}
482
483// An unmap queued before the detach (the wayland order) never leaves a
484// window where the layer is listed but not excluded.
485func TestCaptureExclusionUnmapThenDetach(t *testing.T) {
486 r := captureCore(t)
487 r.client <- ports.LayerChanged{Layers: []ports.LayerSurface{r.layer(10, ports.LayerOverlay, 0)}}
488 scene(t, r.scenes)
489 r.client <- ports.CaptureSessionOpen{ID: 1, Output: "OUT-1"}
490 r.client <- ports.CaptureExclusionBegin{Session: 1}
491 r.client <- ports.CaptureExclusionLayer{Session: 1, Layer: 10, Attached: true}
492 r.excludedScene(t, 1)
493 r.client <- ports.LayerChanged{Layers: nil}
494 r.client <- ports.CaptureExclusionLayer{Session: 1, Layer: 10}
495 for {
496 s := r.sceneWith(t, true)
497 if len(s.Layers) != 0 && len(s.Capture.Excluded) == 0 {
498 t.Fatalf("layer shown but not excluded: %+v %+v", s.Layers, s.Capture)
499 }
500 if len(s.Layers) == 0 {
501 return
502 }
503 }
504}
505
506// A captured frame shows the indicator for one second on the clock core
507// reads, then the scene goes back without it, whoever asked for the frame.
508func TestCaptureFrameFlashRunsOnTheCoreClock(t *testing.T) {
509 cfg := scrollDefaults()
510 cfg.Border.Width = 0
511 fire := make(chan time.Time)
512 timer := portsmocks.NewMockTimer(t)
513 timer.EXPECT().C().Return((<-chan time.Time)(fire))
514 timer.EXPECT().Stop().Return(true).Maybe()
515 var now atomic.Int64
516 clock := portsmocks.NewMockClock(t)
517 clock.EXPECT().Now().RunAndReturn(func() time.Time { return time.Unix(now.Load(), 0) }).Maybe()
518 clock.EXPECT().NewTimer(ports.CaptureFlash).Return(timer).Once()
519 client := make(chan ports.ClientEvent, 4)
520 output := make(chan ports.OutputEvent, 4)
521 scenes := make(chan []ports.Scene, 1)
522 c, err := core.New(cfg, core.Channels{Client: client, Output: output, Commands: make(chan ports.ClientCommand, 64), Scenes: scenes}, core.Options{Clock: clock})
523 if err != nil {
524 t.Fatal(err)
525 }
526 ctx, cancel := context.WithCancel(context.Background())
527 t.Cleanup(cancel)
528 go c.Run(ctx)
529 output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "OUT-1", Width: 100, Height: 80}}
530 scene(t, scenes)
531 // A wlr-screencopy frame: no session.
532 client <- ports.CaptureFrameTaken{Output: "OUT-1", Region: ports.Rect{X: 10, Y: 10, W: 20, H: 20}}
533 if s := scene(t, scenes); len(s.CaptureIndicators) != 1 || s.CaptureIndicators[0].Rect != (ports.Rect{X: 10, Y: 10, W: 20, H: 20}) || s.CaptureIndicators[0].Pill {
534 t.Fatalf("indicators %+v", s.CaptureIndicators)
535 }
536 now.Store(1)
537 fire <- time.Time{}
538 if s := scene(t, scenes); s.CaptureIndicators != nil {
539 t.Fatalf("indicators %+v after the flash", s.CaptureIndicators)
540 }
541}
542
543// popupIDs lists the popups of a scene.
544func popupIDs(s ports.Scene) []ports.WindowID {
545 var ids []ports.WindowID
546 for _, w := range s.Windows {
547 if w.Popup {
548 ids = append(ids, w.ID)
549 }
550 }
551 return ids
552}
553
554// A popup of a HUD layer is excluded for as long as it is in the scene, from
555// the session's end to the layer's: when the session closes, or the layer is
556// destroyed, the popup is never left in the scene while no longer excluded.
557func TestCaptureExclusionPopupOfHUDLayerNeverLeaksWhenTheExclusionEnds(t *testing.T) {
558 setup := func(t *testing.T) *captureRig {
559 r := captureCore(t)
560 r.client <- ports.LayerChanged{Layers: []ports.LayerSurface{r.layer(10, ports.LayerOverlay, 0)}}
561 r.client <- ports.CaptureSessionOpen{ID: 1, Output: "OUT-1"}
562 r.client <- ports.CaptureExclusionBegin{Session: 1}
563 r.client <- ports.CaptureExclusionLayer{Session: 1, Layer: 10, Attached: true}
564 r.client <- ports.PopupRequest{ID: 20, Parent: 10, Positioner: ports.Positioner{Width: 5, Height: 5, AnchorRect: ports.Rect{W: 1, H: 1}}}
565 r.client <- ports.PopupMapped{ID: 20}
566 for {
567 s := r.excludedScene(t, 2)
568 if ids := popupIDs(s); len(ids) == 1 && ids[0] == 20 {
569 break
570 }
571 }
572 return r
573 }
574 // leaks: a scene that draws the popup without excluding it, while a
575 // session that could still capture it with the exclusion is alive.
576 t.Run("exclusion object destroyed, session lives, layer goes away", func(t *testing.T) {
577 r := setup(t)
578 r.client <- ports.CaptureExclusionEnd{Session: 1}
579 // The layer is still listed: it and its popup stay excluded.
580 s := r.excludedScene(t, 2)
581 if ids := popupIDs(s); len(ids) != 1 || !slices.Contains(s.Capture.Excluded, 20) {
582 t.Fatalf("popup %v of a retained HUD layer not excluded: %+v", ids, s.Capture)
583 }
584 r.client <- ports.LayerChanged{} // the layer is destroyed
585 for {
586 s := scene(t, r.scenes)
587 if len(s.Layers) != 0 {
588 continue
589 }
590 for _, w := range s.Windows {
591 if w.ID == 20 && (s.Capture == nil || !slices.Contains(s.Capture.Excluded, 20)) {
592 t.Fatalf("popup of a destroyed layer stays in the scene, unexcluded: %+v", s.Capture)
593 }
594 }
595 break
596 }
597 })
598 t.Run("session closed", func(t *testing.T) {
599 r := setup(t)
600 r.client <- ports.CaptureSessionClose{ID: 1}
601 s := r.sceneWith(t, false)
602 if ids := popupIDs(s); len(ids) != 0 && len(s.Layers) == 0 {
603 t.Fatalf("popup %v outlived its HUD layer's exclusion while the layer is hidden", ids)
604 }
605 })
606}