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