1package neferclient_test
2
3import (
4 "encoding/binary"
5 "errors"
6 "math"
7 "slices"
8 "sync"
9 "testing"
10
11 "github.com/stretchr/testify/mock"
12 "github.com/stretchr/testify/require"
13
14 "github.com/bnema/neferclient"
15 neferclientmocks "github.com/bnema/neferclient/mocks"
16)
17
18const (
19 wmBaseGlobal = 5
20 layerGlobal = 10
21)
22
23var popupGlobals = append(append([]wireGlobal{}, seatGlobals...), wireGlobal{layerGlobal, "zwlr_layer_shell_v1", 4})
24
25// popupLog records every request the peer sees, in order.
26type popupLog struct {
27 mu sync.Mutex
28 reqs []popupReq
29}
30
31type popupReq struct {
32 obj uint32
33 op uint16
34 body []byte
35}
36
37func newPopupLog(srv *wireServer) *popupLog {
38 l := &popupLog{}
39 srv.mu.Lock()
40 srv.hook = func(obj uint32, op uint16, body []byte) {
41 l.mu.Lock()
42 l.reqs = append(l.reqs, popupReq{obj, op, append([]byte(nil), body...)})
43 l.mu.Unlock()
44 }
45 srv.mu.Unlock()
46 return l
47}
48
49func (l *popupLog) len() int {
50 l.mu.Lock()
51 defer l.mu.Unlock()
52 return len(l.reqs)
53}
54
55func (r popupReq) u32(i int) uint32 { return binary.LittleEndian.Uint32(r.body[4*i:]) }
56
57// find returns the index of the first request at or after from that matches.
58func (l *popupLog) find(from int, match func(popupReq) bool) int {
59 l.mu.Lock()
60 defer l.mu.Unlock()
61 for i := from; i < len(l.reqs); i++ {
62 if match(l.reqs[i]) {
63 return i
64 }
65 }
66 return -1
67}
68
69// findLast returns the index of the last request that matches, or -1. Object
70// ids are recycled once the compositor confirms a destruction, so a lookup of
71// a live object must take the newest request.
72func (l *popupLog) findLast(match func(popupReq) bool) int {
73 l.mu.Lock()
74 defer l.mu.Unlock()
75 for i := len(l.reqs) - 1; i >= 0; i-- {
76 if match(l.reqs[i]) {
77 return i
78 }
79 }
80 return -1
81}
82
83// mustFind is find that fails the test when nothing matches.
84func (l *popupLog) mustFind(t *testing.T, from int, match func(popupReq) bool, msg string) int {
85 t.Helper()
86 i := l.find(from, match)
87 require.GreaterOrEqual(t, i, 0, msg)
88 return i
89}
90
91func (l *popupLog) at(i int) popupReq {
92 l.mu.Lock()
93 defer l.mu.Unlock()
94 return l.reqs[i]
95}
96
97func (l *popupLog) all(match func(popupReq) bool) []popupReq {
98 l.mu.Lock()
99 defer l.mu.Unlock()
100 var out []popupReq
101 for _, r := range l.reqs {
102 if match(r) {
103 out = append(out, r)
104 }
105 }
106 return out
107}
108
109func (l *popupLog) op(obj uint32, op uint16) func(popupReq) bool {
110 return func(r popupReq) bool { return r.obj == obj && r.op == op }
111}
112
113// popupEnv is a connection with a layer surface, a toplevel and the request log.
114type popupEnv struct {
115 c *neferclient.Conn
116 srv *wireServer
117 log *popupLog
118 layer *neferclient.Surface
119 top *neferclient.Surface
120}
121
122func newPopupEnv(t *testing.T, globals []wireGlobal) *popupEnv {
123 t.Helper()
124 c, srv := connectWire(t, globals, surfaceOutputs)
125 e := &popupEnv{c: c, srv: srv, log: newPopupLog(srv)}
126 var err error
127 e.top, err = c.NewToplevel("parent", 64, 48)
128 require.NoError(t, err)
129 hasLayer := slices.ContainsFunc(globals, func(g wireGlobal) bool { return g.iface == "zwlr_layer_shell_v1" })
130 e.top.MarkPresentable(64, 48) // stands in for the configure round: popups need a configured parent
131 if hasLayer {
132 e.layer, err = c.NewLayerSurface(neferclient.LayerConfig{Level: neferclient.LayerTop,
133 Anchors: neferclient.AnchorTop | neferclient.AnchorLeft, Width: 200, Height: 30})
134 require.NoError(t, err)
135 e.layer.MarkPresentable(200, 30)
136 }
137 require.NoError(t, c.Roundtrip())
138 return e
139}
140
141func (e *popupEnv) wmBase() uint32 {
142 e.srv.mu.Lock()
143 defer e.srv.mu.Unlock()
144 return e.srv.bound[wmBaseGlobal].id
145}
146
147// xdgSurfaceOf returns the xdg_surface id created for wl_surface surf.
148// getPopup returns the newest xdg_surface.get_popup request of xdg.
149func (e *popupEnv) getPopup(t *testing.T, xdg uint32) (int, popupReq) {
150 t.Helper()
151 i := e.log.findLast(e.log.op(xdg, 2))
152 require.GreaterOrEqual(t, i, 0, "xdg_surface.get_popup")
153 return i, e.log.at(i)
154}
155
156func (e *popupEnv) xdgSurfaceOf(t *testing.T, surf uint32) uint32 {
157 t.Helper()
158 wm := e.wmBase()
159 i := e.log.findLast(func(r popupReq) bool { return r.obj == wm && r.op == 2 && len(r.body) == 8 && r.u32(1) == surf })
160 require.GreaterOrEqual(t, i, 0, "xdg_wm_base.get_xdg_surface")
161 return e.log.at(i).u32(0)
162}
163
164// configurePopup has the peer configure popup p and dispatches it, which
165// makes p a valid parent for further popups.
166func (e *popupEnv) configurePopup(t *testing.T, p *neferclient.Surface) {
167 t.Helper()
168 require.NoError(t, e.c.Roundtrip())
169 xdg := e.xdgSurfaceOf(t, p.SurfaceObjectID())
170 _, gp := e.getPopup(t, xdg)
171 e.srv.write(frame(gp.u32(0), 0, appendU32(appendU32(appendU32(appendU32(nil, 0), 0), 60), 40)))
172 e.srv.write(frame(xdg, 0, appendU32(nil, 1)))
173 require.NoError(t, e.c.Roundtrip())
174 dispatchUntil(t, e.c, neferclient.NopHandler{}, func() bool { w, _, _ := p.Size(); return w == 60 })
175 require.NoError(t, e.c.Roundtrip()) // the ack_configure reached the peer
176}
177
178// popupID returns the xdg_popup object id of p.
179func (e *popupEnv) popupID(t *testing.T, p *neferclient.Surface) uint32 {
180 t.Helper()
181 _, gp := e.getPopup(t, e.xdgSurfaceOf(t, p.SurfaceObjectID()))
182 return gp.u32(0)
183}
184
185var place = neferclient.PopupPlacement{
186 Anchor: neferclient.Rect{X: 10, Y: 0, Width: 20, Height: 30},
187 Edge: neferclient.EdgeBottom, Gravity: neferclient.EdgeBottomRight,
188 Adjust: neferclient.AdjustSlideX | neferclient.AdjustFlipY | neferclient.AdjustResizeY,
189 Offset: [2]int32{3, 4}, Width: 120, Height: 80,
190}
191
192func TestPopupOnLayerParentGrabsBeforeFirstCommit(t *testing.T) {
193 e := newPopupEnv(t, popupGlobals)
194 n := e.log.len()
195 p, err := e.c.NewPopup(neferclient.PopupConfig{Parent: e.layer, Place: place, Grab: true, Serial: 42})
196 require.NoError(t, err)
197 require.NoError(t, e.c.Roundtrip())
198
199 popupSurf := p.SurfaceObjectID()
200 xdg := e.xdgSurfaceOf(t, popupSurf)
201 getPopup, gp := e.getPopup(t, xdg)
202 popupID, positioner := gp.u32(0), gp.u32(2)
203 require.Zero(t, gp.u32(1), "layer parent: null xdg parent")
204
205 // The layer surface is the one created for e.layer.
206 layerID := e.log.at(e.log.mustFind(t, 0, func(r popupReq) bool { return r.op == 0 && len(r.body) > 8 && r.u32(1) == e.layer.SurfaceObjectID() }, "get_layer_surface")).u32(0)
207 layerPopup := e.log.mustFind(t, n, e.log.op(layerID, 5), "layer_surface.get_popup")
208 require.Greater(t, layerPopup, getPopup, "layer_surface.get_popup follows xdg_surface.get_popup")
209 require.Equal(t, popupID, e.log.at(layerPopup).u32(0))
210
211 grab := e.log.mustFind(t, n, e.log.op(popupID, 1), "xdg_popup.grab")
212 require.Greater(t, grab, layerPopup, "grab follows get_popup")
213 require.Equal(t, uint32(42), e.log.at(grab).u32(1))
214 require.NotZero(t, e.log.at(grab).u32(0), "seat")
215 commit := e.log.mustFind(t, n, e.log.op(popupSurf, 6), "wl_surface.commit")
216 require.Greater(t, commit, grab, "grab precedes the first commit")
217
218 // Positioner state.
219 pos := func(op uint16) popupReq {
220 i := e.log.mustFind(t, n, e.log.op(positioner, op), "positioner request")
221 require.Less(t, i, getPopup)
222 return e.log.at(i)
223 }
224 require.Equal(t, []uint32{120, 80}, []uint32{pos(1).u32(0), pos(1).u32(1)})
225 require.Equal(t, []uint32{10, 0, 20, 30}, []uint32{pos(2).u32(0), pos(2).u32(1), pos(2).u32(2), pos(2).u32(3)})
226 require.Equal(t, uint32(neferclient.EdgeBottom), pos(3).u32(0))
227 require.Equal(t, uint32(neferclient.EdgeBottomRight), pos(4).u32(0))
228 require.Equal(t, uint32(1|8|32), pos(5).u32(0))
229 require.Equal(t, []uint32{3, 4}, []uint32{pos(6).u32(0), pos(6).u32(1)})
230 require.NotEmpty(t, e.log.all(e.log.op(positioner, 0)), "positioner destroyed")
231}
232
233func TestPopupOnToplevelParentUsesParentXdgSurface(t *testing.T) {
234 e := newPopupEnv(t, popupGlobals)
235 n := e.log.len()
236 p, err := e.c.NewPopup(neferclient.PopupConfig{Parent: e.top, Place: place})
237 require.NoError(t, err)
238 require.NoError(t, e.c.Roundtrip())
239 parentXdg := e.xdgSurfaceOf(t, e.top.SurfaceObjectID())
240 xdg := e.xdgSurfaceOf(t, p.SurfaceObjectID())
241 _, gp := e.getPopup(t, xdg)
242 require.Equal(t, parentXdg, gp.u32(1))
243 popupID := gp.u32(0)
244 require.Negative(t, e.log.find(n, e.log.op(popupID, 1)), "no grab requested")
245}
246
247func TestPopupNestedOnPopup(t *testing.T) {
248 e := newPopupEnv(t, popupGlobals)
249 p, err := e.c.NewPopup(neferclient.PopupConfig{Parent: e.top, Place: place})
250 require.NoError(t, err)
251 _, err = e.c.NewPopup(neferclient.PopupConfig{Parent: p, Place: place})
252 require.ErrorContains(t, err, "not configured yet", "a popup parent is mapped only once configured")
253 e.configurePopup(t, p)
254 child, err := e.c.NewPopup(neferclient.PopupConfig{Parent: p, Place: place})
255 require.NoError(t, err)
256 require.NoError(t, e.c.Roundtrip())
257 _, gp := e.getPopup(t, e.xdgSurfaceOf(t, child.SurfaceObjectID()))
258 require.Equal(t, e.xdgSurfaceOf(t, p.SurfaceObjectID()), gp.u32(1))
259 require.Error(t, p.Close(), "popup with an open child")
260 require.NoError(t, child.Close())
261 require.NoError(t, p.Close())
262}
263
264func TestPopupConfigureAcksAndDone(t *testing.T) {
265 e := newPopupEnv(t, popupGlobals)
266 p, err := e.c.NewPopup(neferclient.PopupConfig{Parent: e.layer, Place: place})
267 require.NoError(t, err)
268 require.NoError(t, e.c.Roundtrip())
269 xdg := e.xdgSurfaceOf(t, p.SurfaceObjectID())
270 _, gp := e.getPopup(t, xdg)
271 popupID := gp.u32(0)
272
273 var w, h int32
274 var done bool
275 hd := neferclientmocks.NewMockHandler(t)
276 hd.EXPECT().Configure(p.ID(), mock.Anything, mock.Anything).Run(func(_ neferclient.SurfaceID, cw, ch int32) { w, h = cw, ch }).Return().Once()
277 hd.EXPECT().PopupDone(p.ID()).Run(func(neferclient.SurfaceID) { done = true }).Return().Once()
278
279 cfg := appendU32(appendU32(appendU32(appendU32(nil, 5), 6), 120), 80)
280 e.srv.write(frame(popupID, 0, cfg)) // xdg_popup.configure(5,6,120,80)
281 e.srv.write(frame(xdg, 0, appendU32(nil, 9))) // xdg_surface.configure(9)
282 require.NoError(t, e.c.Roundtrip())
283 dispatchUntil(t, e.c, hd, func() bool { return w != 0 })
284 require.Equal(t, [2]int32{120, 80}, [2]int32{w, h})
285 require.NoError(t, e.c.Roundtrip())
286 ack := e.log.mustFind(t, 0, e.log.op(xdg, 4), "ack_configure")
287 require.Equal(t, uint32(9), e.log.at(ack).u32(0))
288 gw, gh, _ := p.Size()
289 require.Equal(t, [2]int32{120, 80}, [2]int32{gw, gh})
290
291 e.srv.write(frame(popupID, 1, nil)) // popup_done
292 require.NoError(t, e.c.Roundtrip())
293 dispatchUntil(t, e.c, hd, func() bool { return done })
294 require.NoError(t, p.Close())
295 require.NoError(t, e.c.Roundtrip())
296 destroyPopup := e.log.mustFind(t, 0, e.log.op(popupID, 0), "xdg_popup.destroy")
297 destroyXdg := e.log.mustFind(t, 0, e.log.op(xdg, 0), "xdg_surface.destroy")
298 require.Less(t, destroyPopup, destroyXdg, "popup destroyed before its xdg_surface")
299}
300
301func TestPopupReposition(t *testing.T) {
302 e := newPopupEnv(t, popupGlobals)
303 p, err := e.c.NewPopup(neferclient.PopupConfig{Parent: e.layer, Place: place})
304 require.NoError(t, err)
305 require.NoError(t, e.c.Roundtrip())
306 xdg := e.xdgSurfaceOf(t, p.SurfaceObjectID())
307 _, gp := e.getPopup(t, xdg)
308 popupID := gp.u32(0)
309
310 np := place
311 np.Anchor.X, np.Width = 50, 90
312 n := e.log.len()
313 require.NoError(t, p.Reposition(np))
314 require.NoError(t, e.c.Roundtrip())
315 i := e.log.mustFind(t, n, e.log.op(popupID, 2), "xdg_popup.reposition")
316 positioner := e.log.at(i).u32(0)
317 token := e.log.at(i).u32(1)
318 setSize := e.log.at(e.log.mustFind(t, n, e.log.op(positioner, 1), "positioner.set_size"))
319 require.Equal(t, uint32(90), setSize.u32(0))
320 require.Less(t, e.log.mustFind(t, n, e.log.op(positioner, 1), "set_size"), i)
321 require.Greater(t, e.log.mustFind(t, i, e.log.op(positioner, 0), "positioner.destroy"), i)
322
323 n = e.log.len()
324 require.NoError(t, p.Reposition(np))
325 require.NoError(t, e.c.Roundtrip())
326 second := e.log.at(e.log.mustFind(t, n, e.log.op(popupID, 2), "second xdg_popup.reposition"))
327 require.NotEqual(t, token, second.u32(1), "each reposition carries its own token")
328
329 bad := np
330 bad.Width = 0
331 require.Error(t, p.Reposition(bad))
332 require.Error(t, e.top.Reposition(np), "not a popup")
333}
334
335func TestPopupRepositionNeedsXdgV3(t *testing.T) {
336 old := append([]wireGlobal{}, popupGlobals...)
337 old[wmBaseGlobal-1].version = 2
338 require.Equal(t, "xdg_wm_base", old[wmBaseGlobal-1].iface)
339 e := newPopupEnv(t, old)
340 p, err := e.c.NewPopup(neferclient.PopupConfig{Parent: e.layer, Place: place})
341 require.NoError(t, err)
342 require.NoError(t, e.c.Roundtrip())
343 n := e.log.len()
344 var ce *neferclient.CapabilityError
345 require.ErrorAs(t, p.Reposition(place), &ce)
346 require.NoError(t, e.c.Roundtrip())
347 require.Equal(t, n, e.log.len(), "nothing sent")
348}
349
350func TestPopupInvalidConfigsSendNothing(t *testing.T) {
351 e := newPopupEnv(t, surfaceGlobals) // no seat, no layer shell
352 closed, err := e.c.NewToplevel("closed", 64, 48)
353 require.NoError(t, err)
354 require.NoError(t, closed.Close())
355 unconfigured, err := e.c.NewToplevel("unconfigured", 64, 48)
356 require.NoError(t, err)
357 l, err := e.c.Lock()
358 require.NoError(t, err)
359 lockSurf, err := l.NewSurface(7)
360 require.NoError(t, err)
361 lockSurf.MarkPresentable(64, 48)
362 other, _ := connectWire(t, surfaceGlobals, surfaceOutputs)
363 foreign, err := other.NewToplevel("foreign", 64, 48)
364 require.NoError(t, err)
365 foreign.MarkPresentable(64, 48)
366 require.NoError(t, e.c.Roundtrip())
367 n := e.log.len()
368
369 with := func(f func(*neferclient.PopupConfig)) neferclient.PopupConfig {
370 cfg := neferclient.PopupConfig{Parent: e.top, Place: place}
371 f(&cfg)
372 return cfg
373 }
374 var capErr *neferclient.CapabilityError
375 for name, tc := range map[string]struct {
376 cfg neferclient.PopupConfig
377 want func(error)
378 }{
379 "nil parent": {with(func(c *neferclient.PopupConfig) { c.Parent = nil }), func(err error) { require.ErrorContains(t, err, "needs a parent surface") }},
380 "foreign parent": {with(func(c *neferclient.PopupConfig) { c.Parent = foreign }), func(err error) { require.ErrorContains(t, err, "of this connection") }},
381 "closed parent": {with(func(c *neferclient.PopupConfig) { c.Parent = closed }), func(err error) { require.ErrorIs(t, err, neferclient.ErrClosed) }},
382 "lock parent": {with(func(c *neferclient.PopupConfig) { c.Parent = lockSurf }), func(err error) { require.ErrorContains(t, err, "must be a layer surface, a toplevel or a popup") }},
383 "unconfigured": {with(func(c *neferclient.PopupConfig) { c.Parent = unconfigured }), func(err error) { require.ErrorContains(t, err, "not configured yet") }},
384 "zero width": {with(func(c *neferclient.PopupConfig) { c.Place.Width = 0 }), func(err error) { require.ErrorContains(t, err, "invalid popup size") }},
385 "negative height": {with(func(c *neferclient.PopupConfig) { c.Place.Height = -1 }), func(err error) { require.ErrorContains(t, err, "invalid popup size") }},
386 "empty anchor": {with(func(c *neferclient.PopupConfig) { c.Place.Anchor.Width = 0 }), func(err error) { require.ErrorContains(t, err, "anchor rectangle") }},
387 "anchor overflow": {with(func(c *neferclient.PopupConfig) {
388 c.Place.Anchor = neferclient.Rect{X: math.MaxInt32, Width: 10, Height: 5}
389 }), func(err error) { require.ErrorContains(t, err, "overflows") }},
390 "bad edge": {with(func(c *neferclient.PopupConfig) { c.Place.Edge = 9 }), func(err error) { require.ErrorContains(t, err, "invalid popup edge") }},
391 "bad gravity": {with(func(c *neferclient.PopupConfig) { c.Place.Gravity = 9 }), func(err error) { require.ErrorContains(t, err, "invalid popup edge") }},
392 "bad adjust": {with(func(c *neferclient.PopupConfig) { c.Place.Adjust = 64 }), func(err error) { require.ErrorContains(t, err, "constraint adjustment") }},
393 "grab no serial": {with(func(c *neferclient.PopupConfig) { c.Grab = true }), func(err error) { require.ErrorContains(t, err, "serial") }},
394 "grab no seat": {with(func(c *neferclient.PopupConfig) { c.Grab, c.Serial = true, 1 }), func(err error) { require.ErrorAs(t, err, &capErr) }},
395 } {
396 _, err := e.c.NewPopup(tc.cfg)
397 require.Error(t, err, name)
398 tc.want(err)
399 }
400 require.NoError(t, e.c.Roundtrip())
401 require.Equal(t, n, e.log.len(), "no request was sent")
402 require.NoError(t, e.top.Close(), "failed popups left no child behind")
403}
404
405// A failed seat bind is the cause of the grab's CapabilityError.
406func TestPopupGrabReportsSeatBindError(t *testing.T) {
407 e := newPopupEnv(t, surfaceGlobals)
408 bindErr := errors.New("bind refused")
409 e.c.SetSeatErr(bindErr)
410 _, err := e.c.NewPopup(neferclient.PopupConfig{Parent: e.top, Place: place, Grab: true, Serial: 1})
411 var ce *neferclient.CapabilityError
412 require.ErrorAs(t, err, &ce)
413 require.Equal(t, "wl_seat", ce.Name)
414 require.ErrorIs(t, err, bindErr)
415}
416
417// A grab needs a grabbing parent popup, and while a grab chain is open only
418// its topmost popup can be the parent of the next grab. Refusals send nothing.
419func TestPopupGrabChain(t *testing.T) {
420 e := newPopupEnv(t, popupGlobals)
421 grab := func(parent *neferclient.Surface) (*neferclient.Surface, error) {
422 return e.c.NewPopup(neferclient.PopupConfig{Parent: parent, Place: place, Grab: true, Serial: 7})
423 }
424 refused := func(parent *neferclient.Surface, want string) {
425 t.Helper()
426 require.NoError(t, e.c.Roundtrip()) // earlier requests are logged
427 n := e.log.len()
428 _, err := grab(parent)
429 require.ErrorContains(t, err, want)
430 require.NoError(t, e.c.Roundtrip())
431 require.Equal(t, n, e.log.len(), "no request was sent")
432 }
433
434 plain, err := e.c.NewPopup(neferclient.PopupConfig{Parent: e.layer, Place: place})
435 require.NoError(t, err)
436 e.configurePopup(t, plain)
437 refused(plain, "needs a grabbing parent popup")
438
439 a, err := grab(e.layer)
440 require.NoError(t, err)
441 e.configurePopup(t, a)
442 refused(e.top, "topmost grabbing popup")
443 refused(e.layer, "topmost grabbing popup")
444 refused(plain, "needs a grabbing parent popup")
445
446 b, err := grab(a)
447 require.NoError(t, err)
448 e.configurePopup(t, b)
449 refused(a, "topmost grabbing popup")
450
451 // Closing the topmost popup returns the grab to its parent.
452 require.NoError(t, b.Close())
453 c2, err := grab(a)
454 require.NoError(t, err)
455 require.NoError(t, c2.Close())
456 require.NoError(t, a.Close())
457
458 // A popup_done ends the grab of the dismissed popup, too.
459 a2, err := grab(e.layer)
460 require.NoError(t, err)
461 refused(e.top, "topmost grabbing popup")
462 e.srv.write(frame(e.popupID(t, a2), 1, nil))
463 require.NoError(t, e.c.Roundtrip())
464 var done []neferclient.SurfaceID
465 hd := neferclientmocks.NewMockHandler(t)
466 hd.EXPECT().PopupDone(mock.Anything).Run(func(id neferclient.SurfaceID) { done = append(done, id) }).Return().Once()
467 dispatchUntil(t, e.c, hd, func() bool { return len(done) > 0 })
468 require.Equal(t, []neferclient.SurfaceID{a2.ID()}, done)
469 top, err := grab(e.top)
470 require.NoError(t, err, "the chain is closed after popup_done")
471 require.NoError(t, top.Close())
472 require.NoError(t, a2.Close())
473 require.NoError(t, plain.Close())
474}