feat/data-dnd e05cb15

screencast_test.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 23

Tags 10

↑↓ moveenter openesc close