1package wayland
2
3import (
4 "context"
5 "errors"
6 "slices"
7 "testing"
8 "time"
9
10 "github.com/bnema/go-wayland-bindings/server/xdgshell"
11 "github.com/bnema/neferwl/internal/adapters/logging"
12 "github.com/bnema/neferwl/internal/adapters/sessionsecurity"
13 portsmocks "github.com/bnema/neferwl/internal/mocks/ports"
14 "github.com/bnema/neferwl/internal/ports"
15 "github.com/stretchr/testify/mock"
16)
17
18// castLayout: two small outputs, so frames are cheap and a flip of the other
19// output can be told from a flip of the cast's.
20var castLayout = ports.Layout{
21 {Info: ports.OutputInfo{Name: "HEADLESS-1", Width: 64, Height: 32}, Width: 64, Height: 32, Scale: 1},
22 {Info: ports.OutputInfo{Name: "HEADLESS-2", Width: 32, Height: 32}, X: 64, Width: 32, Height: 32, Scale: 1},
23}
24
25// castHarness runs a server with the cast channels wired: the commands go in
26// through casts, the events and inventories the server publishes are recorded
27// by the generated CastSink mock into got and invs, and the capture requests
28// and replies are the test's.
29type castHarness struct {
30 t *testing.T
31 s *Server
32 dir string
33 events chan ports.ClientEvent
34 commands chan ports.ClientCommand
35 requests chan ports.CaptureRequest
36 replies chan ports.CaptureDone
37 casts chan ports.CastCommand
38 got chan ports.CastEvent
39 invs chan ports.CastInventory
40 changes chan ports.SecurityState
41}
42
43// newCastHarness starts the server; withSink false leaves Options.Casts nil
44// (the allocation test measures the server alone).
45func newCastHarness(t *testing.T, withSink bool) *castHarness {
46 t.Helper()
47 h := &castHarness{
48 t: t,
49 dir: t.TempDir(),
50 events: make(chan ports.ClientEvent, 256),
51 commands: make(chan ports.ClientCommand, 16),
52 requests: make(chan ports.CaptureRequest, maxCaptureInflight),
53 replies: make(chan ports.CaptureDone, maxCaptureInflight+2),
54 casts: make(chan ports.CastCommand, 16),
55 got: make(chan ports.CastEvent, 4096),
56 invs: make(chan ports.CastInventory, 64),
57 changes: make(chan ports.SecurityState, 16),
58 }
59 opts := Options{RuntimeDir: h.dir, Outputs: castLayout, Security: &sessionsecurity.Gate{}}
60 if withSink {
61 sink := portsmocks.NewMockCastSink(t)
62 sink.EXPECT().Push(mock.Anything).RunAndReturn(func(ev ports.CastEvent) { h.got <- ev }).Maybe()
63 sink.EXPECT().Inventory(mock.Anything).RunAndReturn(func(inv ports.CastInventory) { h.invs <- inv }).Maybe()
64 opts.Casts = sink
65 }
66 ch := Channels{
67 Events: h.events, Commands: h.commands, Captures: h.requests, Captured: h.replies, CastCommands: h.casts,
68 SecurityChanges: h.changes, SecurityEvents: make(chan ports.SecurityBackendEvent, 16),
69 }
70 s, err := New(opts, ch, logging.For(context.Background(), "wayland"))
71 if err != nil {
72 t.Fatal(err)
73 }
74 h.s = s
75 relayContents(t, s)
76 ctx, cancel := context.WithCancel(context.Background())
77 done := make(chan error, 1)
78 go func() { done <- s.Run(ctx) }()
79 t.Cleanup(func() { cancel(); <-done })
80 return h
81}
82
83// sync returns once the display goroutine ran everything posted before.
84func (h *castHarness) sync() {
85 h.t.Helper()
86 ran := make(chan struct{})
87 h.s.display.Post(func() { close(ran) })
88 select {
89 case <-ran:
90 case <-time.After(3 * time.Second):
91 h.t.Fatal("display goroutine stalled")
92 }
93}
94
95// next is the next event pushed to the sink, which must have that kind.
96func (h *castHarness) next(kind ports.CastEventKind) ports.CastEvent {
97 h.t.Helper()
98 select {
99 case ev := <-h.got:
100 if ev.Kind != kind {
101 h.t.Fatalf("event %+v, want kind %d", ev, kind)
102 }
103 return ev
104 case <-time.After(3 * time.Second):
105 h.t.Fatalf("no cast event of kind %d", kind)
106 return ports.CastEvent{}
107 }
108}
109
110// none says that no event is pending once the display goroutine caught up.
111func (h *castHarness) none() {
112 h.t.Helper()
113 h.sync()
114 select {
115 case ev := <-h.got:
116 h.t.Fatalf("unexpected cast event %+v", ev)
117 default:
118 }
119}
120
121func (h *castHarness) request() ports.CaptureRequest {
122 h.t.Helper()
123 select {
124 case r := <-h.requests:
125 return r
126 case <-time.After(3 * time.Second):
127 h.t.Fatal("no capture request")
128 return ports.CaptureRequest{}
129 }
130}
131
132// noRequest says that no capture was requested.
133func (h *castHarness) noRequest(wait time.Duration) {
134 h.t.Helper()
135 select {
136 case r := <-h.requests:
137 h.t.Fatalf("unexpected capture request for cast session %d on %s", r.Session, r.Output)
138 case <-time.After(wait):
139 }
140}
141
142// castClientEvent waits for the next ClientEvent of type T, skipping others.
143func castClientEvent[T ports.ClientEvent](h *castHarness) T {
144 h.t.Helper()
145 for {
146 select {
147 case ev := <-h.events:
148 if v, ok := ev.(T); ok {
149 return v
150 }
151 case <-time.After(3 * time.Second):
152 var zero T
153 h.t.Fatalf("no %T", zero)
154 return zero
155 }
156 }
157}
158
159// complete answers a capture request the way an output does.
160func (h *castHarness) complete(req ports.CaptureRequest, err error) {
161 h.replies <- ports.CaptureDone{ID: req.ID, Output: req.Output, Err: err, Time: time.Unix(5, 6)}
162}
163
164func (h *castHarness) open(id uint64, output string) (ports.CastEvent, uint64) {
165 h.t.Helper()
166 h.casts <- ports.CastCommand{Kind: ports.CastOpen, ID: id, Output: output, Cursor: true}
167 size := h.next(ports.CastSize)
168 return size, castClientEvent[ports.CaptureSessionOpen](h).ID
169}
170
171// state sends core's decision and waits until the display goroutine took it.
172func (h *castHarness) state(core uint64, st ports.CaptureSessionState) {
173 h.t.Helper()
174 h.commands <- st
175 for deadline := time.Now().Add(3 * time.Second); time.Now().Before(deadline); time.Sleep(time.Millisecond) {
176 var seen bool
177 h.s.display.Do(func() { seen = h.s.castByCore[core].haveState })
178 if seen {
179 return
180 }
181 }
182 h.t.Fatal("state not applied")
183}
184
185func castFrame(id uint64, buf int, data []byte, w, hgt int) ports.CastCommand {
186 return ports.CastCommand{Kind: ports.CastFrame, ID: id, Buf: buf, Data: data, Stride: w * 4, Width: w, Height: hgt, Format: 1}
187}
188
189func (h *castHarness) flip(output string) {
190 h.t.Helper()
191 h.s.Presentation().Push(ports.OutputPresented{Output: output, Flip: &ports.FlipInfo{When: 3 * time.Second, Seq: 1}})
192 h.sync()
193}
194
195// An output cast: open registers it with core and reports its size; a frame is
196// captured into the lent mapping, indicated once, and reported done.
197func TestCastOpenSizeFrameDone(t *testing.T) {
198 h := newCastHarness(t, true)
199 size, core := h.open(7, "HEADLESS-1")
200 if size.ID != 7 || size.Width != 64 || size.Height != 32 {
201 t.Fatalf("size %+v", size)
202 }
203 if core == 0 {
204 t.Fatal("cast not registered with core")
205 }
206 data := make([]byte, 64*32*4)
207 h.casts <- castFrame(7, 3, data, 64, 32)
208 req := h.request()
209 if req.Output != "HEADLESS-1" || req.Session != core || !req.Indicate || !req.Cursor || req.Dst.File != nil ||
210 len(req.Mapped) != len(data) || &req.Mapped[0] != &data[0] || req.Stride != 256 || req.Format != 1 || req.Region.Dx() != 64 || req.Region.Dy() != 32 {
211 t.Fatalf("request %+v", req)
212 }
213 taken := castClientEvent[ports.CaptureFrameTaken](h)
214 if taken.Session != core || taken.Output != "HEADLESS-1" {
215 t.Fatalf("taken %+v", taken)
216 }
217 h.complete(req, nil)
218 done := h.next(ports.CastFrameDone)
219 if done.ID != 7 || done.Buf != 3 || done.Err != nil || !done.Time.Equal(time.Unix(5, 6)) {
220 t.Fatalf("done %+v", done)
221 }
222 // Later frames are not indicated again: the cast keeps the indicator on.
223 h.casts <- castFrame(7, 4, data, 64, 32)
224 h.complete(h.request(), nil)
225 if done = h.next(ports.CastFrameDone); done.Buf != 4 || done.Err != nil {
226 t.Fatalf("second done %+v", done)
227 }
228 h.sync()
229 for len(h.events) > 0 {
230 if ev := <-h.events; ev != nil {
231 if _, again := ev.(ports.CaptureFrameTaken); again {
232 t.Fatalf("second frame indicated again: %+v", ev)
233 }
234 }
235 }
236 // CastClose ends the cast without a reply and tells core.
237 h.casts <- ports.CastCommand{Kind: ports.CastClose, ID: 7}
238 if c := castClientEvent[ports.CaptureSessionClose](h); c.ID != core {
239 t.Fatalf("close %+v", c)
240 }
241 h.none()
242 // A frame for a closed cast gives its buffer back.
243 h.casts <- castFrame(7, 5, data, 64, 32)
244 if done = h.next(ports.CastFrameDone); done.Buf != 5 || done.Err == nil {
245 t.Fatalf("frame of a closed cast: %+v", done)
246 }
247}
248
249// A target that is missing refuses the cast.
250func TestCastOpenRefused(t *testing.T) {
251 h := newCastHarness(t, true)
252 h.casts <- ports.CastCommand{Kind: ports.CastOpen, ID: 1, Output: "NOPE-1"}
253 if ev := h.next(ports.CastStopped); ev.ID != 1 || ev.Err == nil {
254 t.Fatalf("stopped %+v", ev)
255 }
256 h.casts <- ports.CastCommand{Kind: ports.CastOpen, ID: 2, Window: 99}
257 if ev := h.next(ports.CastStopped); ev.ID != 2 || ev.Reason != ports.CaptureReasonWindowGone {
258 t.Fatalf("stopped %+v", ev)
259 }
260 for id := uint64(10); id < 10+maxCasts+1; id++ {
261 h.casts <- ports.CastCommand{Kind: ports.CastOpen, ID: id, Output: "HEADLESS-1"}
262 }
263 for range maxCasts {
264 h.next(ports.CastSize)
265 }
266 if ev := h.next(ports.CastStopped); ev.ID != 10+maxCasts || !errors.Is(ev.Err, errCastLimit) {
267 t.Fatalf("over the limit: %+v", ev)
268 }
269}
270
271// A window cast follows the window and stops when it closes.
272func TestCastWindowCloseStops(t *testing.T) {
273 h := newCastHarness(t, true)
274 c := protocolClient(t, h.s, h.dir)
275 w, _, xdg := surfaceMapper(t, c, h.events)()
276 registerProtocol(t, c, xdg)
277 top := xdg + 1
278 placeWindow(t, h.s, h.commands, w, "HEADLESS-1")
279 h.casts <- ports.CastCommand{Kind: ports.CastOpen, ID: 4, Window: w.ID}
280 if size := h.next(ports.CastSize); size.ID != 4 || size.Width != 100 || size.Height != 100 {
281 t.Fatalf("size %+v", size)
282 }
283 core := castClientEvent[ports.CaptureSessionOpen](h)
284 if core.Window != w.ID {
285 t.Fatalf("open %+v", core)
286 }
287 requestProtocol(t, c, top, xdgshell.ToplevelRequestDestroy)
288 roundtrip(t, c)
289 ev := h.next(ports.CastStopped)
290 if ev.ID != 4 || ev.Reason != ports.CaptureReasonWindowGone {
291 t.Fatalf("stopped %+v", ev)
292 }
293 if cl := castClientEvent[ports.CaptureSessionClose](h); cl.ID != core.ID {
294 t.Fatalf("close %+v", cl)
295 }
296 h.none()
297}
298
299// A session lock ends every cast and refuses new ones.
300func TestCastSessionLockStops(t *testing.T) {
301 h := newCastHarness(t, true)
302 h.open(1, "HEADLESS-1")
303 data := make([]byte, 64*32*4)
304 h.casts <- castFrame(1, 0, data, 64, 32)
305 req := h.request()
306 c := protocolClient(t, h.s, h.dir)
307 if _, err := bindLockManager(t, c).Lock(); err != nil {
308 t.Fatal(err)
309 }
310 roundtrip(t, c)
311 // The capture still writes into the lent buffer: its reply comes first.
312 h.none()
313 h.complete(req, nil)
314 if done := h.next(ports.CastFrameDone); done.Buf != 0 || !errors.Is(done.Err, errCastStopped) {
315 t.Fatalf("done %+v", done)
316 }
317 if ev := h.next(ports.CastStopped); ev.ID != 1 || !errors.Is(ev.Err, errCastProtected) {
318 t.Fatalf("stopped %+v", ev)
319 }
320 h.casts <- ports.CastCommand{Kind: ports.CastOpen, ID: 2, Output: "HEADLESS-1"}
321 if ev := h.next(ports.CastStopped); ev.ID != 2 || !errors.Is(ev.Err, errCastProtected) {
322 t.Fatalf("open under lock: %+v", ev)
323 }
324}
325
326// An unplugged output ends the casts on it, after the capture in flight.
327func TestCastOutputUnplugStops(t *testing.T) {
328 h := newCastHarness(t, true)
329 h.open(1, "HEADLESS-1")
330 h.open(2, "HEADLESS-2")
331 data := make([]byte, 64*32*4)
332 h.casts <- castFrame(1, 9, data, 64, 32)
333 req := h.request()
334 h.commands <- ports.SetOutputs{Outputs: castLayout[1:], Focused: "HEADLESS-2"}
335 h.none()
336 h.complete(req, nil)
337 if done := h.next(ports.CastFrameDone); done.ID != 1 || done.Buf != 9 || done.Err == nil {
338 t.Fatalf("a frame written for a dead cast must not be queued: %+v", done)
339 }
340 if ev := h.next(ports.CastStopped); ev.ID != 1 || ev.Reason != ports.CaptureReasonOutputGone {
341 t.Fatalf("stopped %+v", ev)
342 }
343 // The cast on the other output lives.
344 h.casts <- castFrame(2, 1, make([]byte, 32*32*4), 32, 32)
345 h.complete(h.request(), nil)
346 if done := h.next(ports.CastFrameDone); done.ID != 2 || done.Err != nil {
347 t.Fatalf("done %+v", done)
348 }
349}
350
351// A new mode renegotiates (CastSize); a frame of the old size is refused.
352func TestCastSizeChange(t *testing.T) {
353 h := newCastHarness(t, true)
354 h.open(1, "HEADLESS-1")
355 bigger := ports.Layout{castLayout[0], castLayout[1]}
356 bigger[0].Info.Width, bigger[0].Info.Height, bigger[0].Width, bigger[0].Height = 128, 64, 128, 64
357 h.commands <- ports.SetOutputs{Outputs: bigger, Focused: "HEADLESS-1"}
358 if size := h.next(ports.CastSize); size.Width != 128 || size.Height != 64 {
359 t.Fatalf("size %+v", size)
360 }
361 h.casts <- castFrame(1, 2, make([]byte, 64*32*4), 64, 32)
362 if done := h.next(ports.CastFrameDone); done.Buf != 2 || !errors.Is(done.Err, errCastSize) {
363 t.Fatalf("done %+v", done)
364 }
365 h.noRequest(20 * time.Millisecond)
366 h.casts <- castFrame(1, 2, make([]byte, 128*64*4), 128, 64)
367 h.complete(h.request(), nil)
368 if done := h.next(ports.CastFrameDone); done.Err != nil {
369 t.Fatalf("done %+v", done)
370 }
371}
372
373// A transient failure is retried on the cadence of an ext frame; past the
374// deadline the frame fails with the error.
375func TestCastTransientRetry(t *testing.T) {
376 h := newCastHarness(t, true)
377 h.open(1, "HEADLESS-1")
378 data := make([]byte, 64*32*4)
379 h.casts <- castFrame(1, 0, data, 64, 32)
380 first := h.request()
381 h.complete(first, ports.ErrCaptureBusy)
382 start := time.Now()
383 retried := h.request()
384 if retried.ID == first.ID || time.Since(start) < captureRetryEvery/2 {
385 t.Fatalf("retry %+v after %v", retried, time.Since(start))
386 }
387 h.complete(retried, nil)
388 if done := h.next(ports.CastFrameDone); done.Err != nil {
389 t.Fatalf("done %+v", done)
390 }
391
392 // Never ready: retried until the deadline, then reported.
393 h.casts <- castFrame(1, 1, data, 64, 32)
394 attempts := 0
395 for {
396 select {
397 case req := <-h.requests:
398 attempts++
399 h.complete(req, ports.ErrCaptureBusy)
400 continue
401 case ev := <-h.got:
402 if ev.Kind != ports.CastFrameDone || ev.Buf != 1 || !errors.Is(ev.Err, ports.ErrCaptureTransient) {
403 t.Fatalf("event %+v", ev)
404 }
405 case <-time.After(5 * time.Second):
406 t.Fatal("frame neither retried nor failed")
407 }
408 break
409 }
410 if attempts < 10 {
411 t.Fatalf("%d attempts in %v", attempts, captureRetryFor)
412 }
413}
414
415// Until core says the cast is active the frame waits, then goes out.
416func TestCastWaitsForActiveState(t *testing.T) {
417 h := newCastHarness(t, true)
418 _, core := h.open(1, "HEADLESS-1")
419 h.state(core, ports.CaptureSessionState{ID: core, Output: "HEADLESS-1"})
420 h.casts <- castFrame(1, 0, make([]byte, 64*32*4), 64, 32)
421 h.noRequest(60 * time.Millisecond)
422 h.commands <- ports.CaptureSessionState{ID: core, Output: "HEADLESS-1", Active: true}
423 h.complete(h.request(), nil)
424 if done := h.next(ports.CastFrameDone); done.Err != nil {
425 t.Fatalf("done %+v", done)
426 }
427 // A terminal decision of core ends the cast without telling core.
428 h.commands <- ports.CaptureSessionState{ID: core, Reason: ports.CaptureReasonOutputGone}
429 if ev := h.next(ports.CastStopped); ev.Reason != ports.CaptureReasonOutputGone {
430 t.Fatalf("stopped %+v", ev)
431 }
432 h.sync()
433 for len(h.events) > 0 {
434 if _, closed := (<-h.events).(ports.CaptureSessionClose); closed {
435 t.Fatal("core told of an end it decided")
436 }
437 }
438}
439
440// A flip of the cast's output damages an output cast, and only that flip.
441func TestCastFlipDamagesOutputCast(t *testing.T) {
442 h := newCastHarness(t, true)
443 h.open(1, "HEADLESS-1")
444 h.open(2, "HEADLESS-2")
445 h.flip("HEADLESS-1")
446 ev := h.next(ports.CastDamage)
447 if ev.ID != 1 || !ev.Time.Equal(time.Unix(3, 0)) {
448 t.Fatalf("damage %+v", ev)
449 }
450 h.none()
451 h.flip("HEADLESS-2")
452 if ev = h.next(ports.CastDamage); ev.ID != 2 {
453 t.Fatalf("damage %+v", ev)
454 }
455 // Every flip damages an output cast, committed or not.
456 h.flip("HEADLESS-1")
457 h.flip("HEADLESS-1")
458 h.next(ports.CastDamage)
459 h.next(ports.CastDamage)
460 h.none()
461}
462
463// A window cast is damaged by a flip of its output only after the window
464// committed since the last damage.
465func TestCastFlipDamagesWindowCastAfterCommit(t *testing.T) {
466 h := newCastHarness(t, true)
467 c := protocolClient(t, h.s, h.dir)
468 w, _, _ := surfaceMapper(t, c, h.events)()
469 placeWindow(t, h.s, h.commands, w, "HEADLESS-1")
470 h.casts <- ports.CastCommand{Kind: ports.CastOpen, ID: 1, Window: w.ID}
471 h.next(ports.CastSize)
472
473 h.flip("HEADLESS-1")
474 h.none() // nothing committed since the cast began
475 commit := func() {
476 if !h.s.display.Do(func() { h.s.emitContent(ports.SurfaceContent{ID: w.ID}, damage{full: true}) }) {
477 t.Fatal("display stopped")
478 }
479 }
480 commit()
481 h.flip("HEADLESS-2")
482 h.none() // not the window's output
483 h.flip("HEADLESS-1")
484 if ev := h.next(ports.CastDamage); ev.ID != 1 {
485 t.Fatalf("damage %+v", ev)
486 }
487 h.flip("HEADLESS-1")
488 h.none() // the damage consumed the commit
489 commit()
490 commit()
491 h.flip("HEADLESS-1")
492 h.next(ports.CastDamage)
493 h.none() // two commits, one damage
494}
495
496// A popup is drawn with its toplevel: a static toplevel whose popup (or a
497// popup of that popup) keeps committing, mapping, unmapping or moving still
498// damages the window cast. The popups are windows of the server, as an
499// xdg_popup makes them.
500func TestCastWindowDamagedByPopupChanges(t *testing.T) {
501 h := newCastHarness(t, true)
502 c := protocolClient(t, h.s, h.dir)
503 w, _, _ := surfaceMapper(t, c, h.events)()
504 placeWindow(t, h.s, h.commands, w, "HEADLESS-1")
505 h.casts <- ports.CastCommand{Kind: ports.CastOpen, ID: 1, Window: w.ID}
506 h.next(ports.CastSize)
507
508 var menu, sub ports.WindowID
509 if !h.s.display.Do(func() {
510 top := h.s.windows[w.ID]
511 m := &window{id: h.s.nextWindow, xdg: top.xdg, mapped: true}
512 m.popup = &popup{w: m, parent: top}
513 h.s.windows[m.id] = m
514 s2 := &window{id: m.id + 1, xdg: top.xdg, mapped: true}
515 s2.popup = &popup{w: s2, parent: m}
516 h.s.windows[s2.id] = s2
517 h.s.nextWindow += 2
518 menu, sub = m.id, s2.id
519 }) {
520 t.Fatal("display stopped")
521 }
522 t.Cleanup(func() {
523 h.s.display.Do(func() { delete(h.s.windows, menu); delete(h.s.windows, sub) })
524 })
525 h.flip("HEADLESS-1")
526 h.none() // nothing changed yet
527 change := func(name string, f func()) {
528 t.Helper()
529 if !h.s.display.Do(f) {
530 t.Fatal("display stopped")
531 }
532 h.flip("HEADLESS-1")
533 if ev := h.next(ports.CastDamage); ev.ID != 1 {
534 t.Fatalf("%s: damage %+v", name, ev)
535 }
536 h.flip("HEADLESS-1")
537 h.none() // the damage consumed the change
538 }
539 content := func(id ports.WindowID) func() {
540 return func() { h.s.emitContent(ports.SurfaceContent{ID: id}, damage{full: true}) }
541 }
542 change("popup commit", content(menu))
543 change("popup commit again", content(menu))
544 change("nested popup commit", content(sub))
545 change("popup unmap", func() { h.s.windows[menu].mapped = false; content(menu)() })
546 change("popup map", func() { h.s.windows[menu].mapped = true; h.s.castCommitted(menu) })
547 change("popup reposition", func() { h.s.castCommitted(menu) })
548 // A window nobody casts leaves the cast alone.
549 if !h.s.display.Do(func() { h.s.emitContent(ports.SurfaceContent{ID: w.ID + 1000}, damage{full: true}) }) {
550 t.Fatal("display stopped")
551 }
552 h.flip("HEADLESS-1")
553 h.none()
554}
555
556// The inventory is published once per change, not once per frame or flip, and
557// changes of one display loop turn share one publication.
558func TestCastInventoryPublishedOncePerChange(t *testing.T) {
559 h := newCastHarness(t, true)
560 inv := func() ports.CastInventory {
561 t.Helper()
562 select {
563 case v := <-h.invs:
564 return v
565 case <-time.After(3 * time.Second):
566 t.Fatal("no inventory")
567 return ports.CastInventory{}
568 }
569 }
570 noInv := func() {
571 t.Helper()
572 h.sync()
573 h.sync()
574 select {
575 case v := <-h.invs:
576 t.Fatalf("unexpected inventory %+v", v)
577 default:
578 }
579 }
580 first := inv()
581 if len(first.Outputs) != 2 || len(first.Windows) != 0 || first.Outputs[0].Info.Name != "HEADLESS-1" || first.Outputs[0].Width != 64 {
582 t.Fatalf("first inventory %+v", first)
583 }
584 for _, o := range first.Outputs {
585 if o.Current < 0 || o.Current >= len(o.Modes) || o.Modes[o.Current].Width != o.Info.Width {
586 t.Fatalf("current mode of %s: %+v", o.Info.Name, o)
587 }
588 }
589 noInv()
590
591 c := protocolClient(t, h.s, h.dir)
592 w, _, xdg := surfaceMapper(t, c, h.events)()
593 registerProtocol(t, c, xdg)
594 top := xdg + 1
595 if got := inv(); len(got.Windows) != 1 || got.Windows[0].ID != w.ID {
596 t.Fatalf("mapped inventory %+v", got)
597 }
598 noInv()
599 requestProtocol(t, c, top, xdgshell.ToplevelRequestSetTitle, "editor")
600 requestProtocol(t, c, top, xdgshell.ToplevelRequestSetAppId, "app.editor")
601 roundtrip(t, c)
602 var got ports.CastInventory
603 for got = inv(); got.Windows[0].Title != "editor" || got.Windows[0].AppID != "app.editor"; got = inv() {
604 }
605 noInv()
606 // The same title again changes nothing.
607 requestProtocol(t, c, top, xdgshell.ToplevelRequestSetTitle, "editor")
608 roundtrip(t, c)
609 noInv()
610
611 // Frames and flips do not publish.
612 h.open(1, "HEADLESS-1")
613 data := make([]byte, 64*32*4)
614 for i := range 20 {
615 h.flip("HEADLESS-1")
616 h.casts <- castFrame(1, i%3, data, 64, 32)
617 h.complete(h.request(), nil)
618 }
619 noInv()
620
621 // Several changes in one turn: one publication, of the last state. An
622 // unchanged inventory is not published at all.
623 h.s.display.Do(func() { h.s.castInventoryChanged(); h.s.castInventoryChanged() })
624 noInv()
625 h.s.display.Do(func() {
626 for _, title := range []string{"a", "b", "c"} {
627 h.s.windows[w.ID].title = title
628 h.s.castInventoryChanged()
629 }
630 })
631 if got = inv(); len(got.Windows) != 1 || got.Windows[0].Title != "c" {
632 t.Fatalf("inventory %+v", got)
633 }
634 noInv()
635
636 // An output moving is a change; the focused output too.
637 moved := slices.Clone(castLayout)
638 moved[1].X = 200
639 h.commands <- ports.SetOutputs{Outputs: moved, Focused: "HEADLESS-2"}
640 if got = inv(); got.Outputs[1].X != 200 || got.Focused != "HEADLESS-2" {
641 t.Fatalf("inventory %+v", got)
642 }
643 noInv()
644
645 // Under a session lock the windows are not listed.
646 lc := protocolClient(t, h.s, h.dir)
647 if _, err := bindLockManager(t, lc).Lock(); err != nil {
648 t.Fatal(err)
649 }
650 roundtrip(t, lc)
651 for got = inv(); len(got.Windows) != 0; got = inv() {
652 }
653}
654
655// Pushing a cast frame, capturing it and answering the flip allocates nothing
656// in steady state: the cast command rides the post ring, the request and the
657// reply are plain values, the reply function is bound once. The count
658// includes the display goroutine (AllocsPerRun reads the global malloc
659// counter). Nothing in the closure is a testify mock; the sink is left out so
660// the measured code is the server's.
661func TestCastFrameAllocations(t *testing.T) {
662 if raceEnabled {
663 t.Skip("the race detector makes the purego wake callback allocate")
664 }
665 h := newCastHarness(t, false)
666 s := h.s
667 s.castRing.Push(ports.CastCommand{Kind: ports.CastOpen, ID: 1, Output: "HEADLESS-1"})
668 core := castClientEvent[ports.CaptureSessionOpen](h).ID
669 data := make([]byte, 64*32*4)
670 frame := castFrame(1, 0, data, 64, 32)
671 flip := ports.OutputPresented{Output: "HEADLESS-1", Flip: &ports.FlipInfo{When: time.Second, Seq: 1}}
672 ran := make(chan struct{}, 1)
673 marker := func() { ran <- struct{}{} }
674 at := time.Unix(1, 2)
675 cycle := func() {
676 s.castRing.Push(frame)
677 req := <-h.requests
678 s.capturedRing.Push(ports.CaptureDone{ID: req.ID, Output: req.Output, Time: at})
679 s.Presentation().Push(flip)
680 s.display.Post(marker)
681 <-ran
682 }
683 for range 4 {
684 cycle()
685 }
686 if taken := castClientEvent[ports.CaptureFrameTaken](h); taken.Session != core {
687 t.Fatalf("taken %+v", taken)
688 }
689 var live int
690 s.display.Do(func() { live = len(s.castSessions) + len(s.captureInflight) + len(s.captureReplies) })
691 if live != 1 {
692 t.Fatalf("cast %d, inflight and replies must be empty after a cycle", live)
693 }
694 if allocs := testing.AllocsPerRun(200, cycle); allocs != 0 {
695 t.Errorf("cast frame cycle: %.1f allocs, want 0", allocs)
696 }
697}