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