1//go:build xwayland
2
3// End-to-end test of the built-in XWM: a real Xwayland and a real X11 client
4// (testdata/x11probe) run against a headless NeferWL. It needs Xwayland on
5// PATH and a GPU, so it is excluded from `go test ./...` and CI. Run it with
6// `make e2e-xwayland`.
7package app
8
9import (
10 "context"
11 "crypto/sha256"
12 "fmt"
13 "image"
14 "image/png"
15 "io"
16 "os"
17 "os/exec"
18 "path/filepath"
19 "regexp"
20 "strconv"
21 "strings"
22 "sync"
23 "testing"
24 "time"
25
26 "github.com/bnema/zerowrap"
27
28 "github.com/bnema/neferwl/internal/adapters/config"
29 "github.com/bnema/neferwl/internal/adapters/statefile"
30 "github.com/bnema/neferwl/internal/ports"
31)
32
33const (
34 xwaylandTimeout = 10 * time.Second
35 xwaylandW, xwaylandH = 640, 360
36)
37
38var displayRe = regexp.MustCompile(`"DISPLAY":"(:\d+)"`)
39
40// probe is a running x11probe.
41type probe struct {
42 cmd *exec.Cmd
43 out *lockedBuffer
44 waited chan error
45}
46
47// xwaylandEnv is a running headless compositor with the built-in XWM.
48type xwaylandEnv struct {
49 bin, display, statePath, shots string
50 script *io.PipeWriter
51}
52
53// startXwayland runs the compositor on one headless output and waits for its
54// X11 display. It skips the test without Xwayland and stops everything when
55// the test ends.
56func startXwayland(t *testing.T) *xwaylandEnv {
57 t.Helper()
58 return startXwaylandWith(t, [][2]int{{xwaylandW, xwaylandH}}, nil)
59}
60
61// startXwaylandWith is startXwayland with the given headless outputs, left to
62// right, and an optional change to the default config.
63func startXwaylandWith(t *testing.T, sizes [][2]int, mutate func(*ports.Config)) *xwaylandEnv {
64 t.Helper()
65 if _, err := exec.LookPath("Xwayland"); err != nil {
66 t.Skip("Xwayland not on PATH")
67 }
68 // The compositor must not read or write the live session's state.
69 for _, k := range []string{"NEFERWL_STATE", "NEFERWL_SLOT"} {
70 if v, ok := os.LookupEnv(k); ok {
71 os.Unsetenv(k)
72 t.Cleanup(func() { os.Setenv(k, v) })
73 }
74 }
75 runtime := t.TempDir()
76 t.Setenv("XDG_RUNTIME_DIR", runtime)
77 t.Setenv("XDG_STATE_HOME", t.TempDir())
78 shots := t.TempDir()
79 bin := buildProbe(t)
80
81 script, scriptW := io.Pipe()
82 logs := &lockedBuffer{}
83 ctx, cancel := context.WithCancel(zerowrap.WithCtx(context.Background(), zerowrap.New(zerowrap.Config{Level: "info", Format: "json", Output: logs})))
84 cfg := config.Defaults()
85 cfg.Xwayland = ports.XwaylandConfig{Backend: ports.XwaylandBuiltin, Binary: "Xwayland", XSettings: true}
86 if mutate != nil {
87 mutate(&cfg)
88 }
89 done := make(chan error, 1)
90 go func() {
91 done <- Run(ctx, Options{
92 Backend: "headless", NoTerminal: true, Config: cfg, Script: script,
93 ScreenshotDir: shots, Sizes: sizes,
94 })
95 }()
96 t.Cleanup(func() {
97 if t.Failed() {
98 t.Logf("compositor log:\n%s", logs.String())
99 }
100 cancel()
101 _ = scriptW.Close()
102 select {
103 case err := <-done:
104 if err != nil {
105 t.Errorf("Run: %v", err)
106 }
107 case <-time.After(5 * time.Second):
108 t.Error("Run did not stop")
109 }
110 })
111
112 var display string
113 until(t, "X11 display in the log", func() bool {
114 if m := displayRe.FindStringSubmatch(logs.String()); m != nil {
115 display = m[1]
116 return true
117 }
118 return false
119 })
120 statePath, err := statefile.Path(runtime, "wayland-1")
121 if err != nil {
122 t.Fatal(err)
123 }
124 return &xwaylandEnv{bin: bin, display: display, statePath: statePath, shots: shots, script: scriptW}
125}
126
127// TestXwaylandProbe: an X11 client is managed by the built-in XWM. It shows
128// up in the state with its class and pid, is drawn, tiles next to a second
129// one, receives focus and is closed by the close-window bind.
130func TestXwaylandProbe(t *testing.T) {
131 e := startXwayland(t)
132 bin, display, statePath, shots, scriptW := e.bin, e.display, e.statePath, e.shots, e.script
133 first := startProbe(t, bin, display, "-title", "one")
134 until(t, "probe one mapped", func() bool { return strings.Contains(first.out.String(), "mapped ") })
135
136 var st statefile.State
137 until(t, "one visible X11Probe window", func() bool {
138 st = readState(statePath)
139 return len(st.Windows) == 1 && st.Windows[0].AppID == "X11Probe" && st.Windows[0].Visible
140 })
141 if w := st.Windows[0]; w.PID != first.cmd.Process.Pid {
142 t.Errorf("window pid %d, want %d", w.PID, first.cmd.Process.Pid)
143 }
144
145 // The client draws green and the compositor shows it.
146 id := st.Windows[0].ID
147 until(t, "green window in latest.png", func() bool {
148 r, ok := tileRect(readState(statePath), id)
149 return ok && greenShare(filepath.Join(shots, "latest.png"), r) >= 0.5
150 })
151
152 // Focus round trip: the compositor focused the window, the client saw it.
153 until(t, "probe one focus", func() bool { return strings.Contains(first.out.String(), "focus") })
154
155 // A second probe tiles beside the first.
156 second := startProbe(t, bin, display, "-title", "two")
157 until(t, "two tiled windows", func() bool {
158 st = readState(statePath)
159 if len(st.Windows) != 2 {
160 return false
161 }
162 for _, w := range st.Windows {
163 if w.AppID != "X11Probe" || !w.Visible || w.Floating {
164 return false
165 }
166 }
167 return true
168 })
169 until(t, "probe two focus", func() bool { return strings.Contains(second.out.String(), "focus") })
170
171 // The close-window bind asks the focused (second) probe to close.
172 if _, err := io.WriteString(scriptW, "key Super+q\n"); err != nil {
173 t.Fatal(err)
174 }
175 select {
176 case err := <-second.waited:
177 if err != nil {
178 t.Errorf("probe two exited with %v, want 0", err)
179 }
180 case <-time.After(2 * time.Second):
181 t.Fatal("probe two did not exit within 2s of Super+q")
182 }
183 until(t, "one window left", func() bool {
184 st = readState(statePath)
185 return len(st.Windows) == 1 && st.Windows[0].PID == first.cmd.Process.Pid
186 })
187 select {
188 case err := <-first.waited:
189 t.Fatalf("probe one exited (%v) though only the focused probe was closed", err)
190 default:
191 }
192}
193
194// buildProbe builds testdata/x11probe.
195func buildProbe(t *testing.T) string {
196 t.Helper()
197 bin := filepath.Join(t.TempDir(), "x11probe")
198 build := exec.Command("go", "build", "-o", bin, "./testdata/x11probe")
199 build.Env = append(os.Environ(), "CGO_ENABLED=0")
200 if out, err := build.CombinedOutput(); err != nil {
201 t.Fatalf("build x11probe: %v: %s", err, out)
202 }
203 return bin
204}
205
206// startProbe starts x11probe on display, with X11 as its only display
207// server, and stops it when the test ends.
208func startProbe(t *testing.T, bin, display string, args ...string) *probe {
209 t.Helper()
210 p := &probe{out: &lockedBuffer{}, waited: make(chan error, 1)}
211 p.cmd = exec.Command(bin, args...)
212 p.cmd.Env = append(envWithout("DISPLAY", "WAYLAND_DISPLAY", "NEFERWL_STATE", "NEFERWL_SLOT"), "DISPLAY="+display)
213 p.cmd.Stdout = p.out
214 stderr := &lockedBuffer{}
215 p.cmd.Stderr = stderr
216 if err := p.cmd.Start(); err != nil {
217 t.Fatal(err)
218 }
219 var exited sync.Once
220 go func() { p.waited <- p.cmd.Wait() }()
221 t.Cleanup(func() {
222 exited.Do(func() {
223 _ = p.cmd.Process.Kill()
224 select {
225 case <-p.waited:
226 case <-time.After(2 * time.Second):
227 }
228 })
229 if s := stderr.String(); s != "" {
230 t.Logf("x11probe stderr: %s", s)
231 }
232 })
233 return p
234}
235
236func envWithout(keys ...string) []string {
237 var env []string
238next:
239 for _, kv := range os.Environ() {
240 for _, k := range keys {
241 if strings.HasPrefix(kv, k+"=") {
242 continue next
243 }
244 }
245 env = append(env, kv)
246 }
247 return env
248}
249
250// until polls cond every 20 ms and fails the test after xwaylandTimeout.
251func until(t *testing.T, what string, cond func() bool) {
252 t.Helper()
253 for deadline := time.Now().Add(xwaylandTimeout); !cond(); time.Sleep(20 * time.Millisecond) {
254 if time.Now().After(deadline) {
255 t.Fatalf("timeout waiting for %s", what)
256 }
257 }
258}
259
260// readState returns the current state file, or an empty state while it does
261// not exist or is being replaced.
262func readState(path string) statefile.State {
263 st, err := statefile.Read(path)
264 if err != nil {
265 return statefile.State{}
266 }
267 return st
268}
269
270// tileRect finds the rectangle of window id in the tiled columns.
271func tileRect(st statefile.State, id uint64) (image.Rectangle, bool) {
272 for _, ws := range st.Workspaces {
273 for _, c := range ws.Columns {
274 for _, w := range c.Windows {
275 if w.ID == id {
276 return image.Rect(w.X, w.Y, w.X+w.Width, w.Y+w.Height), true
277 }
278 }
279 }
280 }
281 return image.Rectangle{}, false
282}
283
284// greenShare is the fraction of pixels of r in the PNG at path that are
285// clearly green; 0 when the image cannot be read yet.
286func greenShare(path string, r image.Rectangle) float64 {
287 f, err := os.Open(path)
288 if err != nil {
289 return 0
290 }
291 defer f.Close()
292 img, err := png.Decode(f)
293 if err != nil {
294 return 0
295 }
296 r = r.Intersect(img.Bounds())
297 if r.Empty() {
298 return 0
299 }
300 n := 0
301 for y := r.Min.Y; y < r.Max.Y; y++ {
302 for x := r.Min.X; x < r.Max.X; x++ {
303 cr, cg, cb, _ := img.At(x, y).RGBA()
304 if cg>>8 > 200 && cr>>8 < 60 && cb>>8 < 60 {
305 n++
306 }
307 }
308 }
309 return float64(n) / float64(r.Dx()*r.Dy())
310}
311
312// magentaShare is the fraction of pixels of r in the PNG at path that are
313// clearly magenta; 0 when the image cannot be read yet.
314func magentaShare(path string, r image.Rectangle) float64 {
315 f, err := os.Open(path)
316 if err != nil {
317 return 0
318 }
319 defer f.Close()
320 img, err := png.Decode(f)
321 if err != nil {
322 return 0
323 }
324 r = r.Intersect(img.Bounds())
325 if r.Empty() {
326 return 0
327 }
328 n := 0
329 for y := r.Min.Y; y < r.Max.Y; y++ {
330 for x := r.Min.X; x < r.Max.X; x++ {
331 cr, cg, cb, _ := img.At(x, y).RGBA()
332 if cr>>8 > 200 && cg>>8 < 60 && cb>>8 > 200 {
333 n++
334 }
335 }
336 }
337 return float64(n) / float64(r.Dx()*r.Dy())
338}
339
340// TestXwaylandOverride: an override-redirect window (a menu) of an X11 client
341// is drawn above the toplevel at its X position, is not a window in the
342// state, leaves keyboard focus on the toplevel and vanishes when unmapped.
343func TestXwaylandOverride(t *testing.T) {
344 e := startXwayland(t)
345 // Probe unmaps the menu 3 s after mapping it; the first checks run well
346 // inside that window.
347 p := startProbe(t, e.bin, e.display, "-title", "menu", "-override", "-override-at", "100,50",
348 "-override-size", "120x80", "-override-for", "3s")
349 until(t, "override mapped", func() bool { return strings.Contains(p.out.String(), "override mapped") })
350 shot := filepath.Join(e.shots, "latest.png")
351 box := image.Rect(100, 50, 220, 130) // output-local: one output at the X origin
352 until(t, "magenta menu in latest.png", func() bool { return magentaShare(shot, box) >= 0.9 })
353
354 var st statefile.State
355 until(t, "focused X11Probe toplevel", func() bool {
356 st = readState(e.statePath)
357 return len(st.Windows) >= 1 && st.Window != nil
358 })
359 if len(st.Windows) != 1 || st.Windows[0].AppID != "X11Probe" {
360 t.Fatalf("state windows = %+v, want only the X11Probe toplevel", st.Windows)
361 }
362 id := st.Windows[0].ID
363 if st.Window.ID != id {
364 t.Errorf("focused window = %+v, want the toplevel %d", st.Window, id)
365 }
366 until(t, "probe focus", func() bool { return strings.Contains(p.out.String(), "focus") })
367
368 // The menu is above the toplevel: its box is magenta, not green.
369 if g := greenShare(shot, box); g > 0.1 {
370 t.Errorf("green share in menu box = %.2f, menu is not above the toplevel", g)
371 }
372 until(t, "override unmapped", func() bool { return strings.Contains(p.out.String(), "override unmapped") })
373 until(t, "menu gone from latest.png", func() bool {
374 return magentaShare(shot, box) == 0 && greenShare(shot, box) >= 0.9
375 })
376 if st := readState(e.statePath); st.Window == nil || st.Window.ID != id {
377 t.Errorf("focus after the menu closed = %+v, want %d", st.Window, id)
378 }
379}
380
381// TestXwaylandOverrideDismiss: the menu owner sees FocusOut on the toplevel
382// when core focuses another window, which is how toolkits close their menus.
383func TestXwaylandOverrideDismiss(t *testing.T) {
384 e := startXwayland(t)
385 p := startProbe(t, e.bin, e.display, "-title", "menu", "-override", "-override-dismiss-on-focusout")
386 until(t, "override mapped", func() bool { return strings.Contains(p.out.String(), "override mapped") })
387 until(t, "probe focus", func() bool { return strings.Contains(p.out.String(), "focus") })
388 // A second X11 window takes the focus away from the menu owner.
389 startProbe(t, e.bin, e.display, "-title", "other")
390 until(t, "menu dismissed on FocusOut", func() bool { return strings.Contains(p.out.String(), "dismissed") })
391 box := image.Rect(100, 50, 220, 130)
392 until(t, "menu gone from latest.png", func() bool {
393 return magentaShare(filepath.Join(e.shots, "latest.png"), box) == 0
394 })
395}
396
397// TestXwaylandOverrideShape: a menu with an empty input shape is still drawn
398// and still not a window.
399func TestXwaylandOverrideShape(t *testing.T) {
400 e := startXwayland(t)
401 p := startProbe(t, e.bin, e.display, "-title", "menu", "-override", "-override-shape", "-override-type", "tooltip")
402 until(t, "override mapped", func() bool { return strings.Contains(p.out.String(), "override mapped") })
403 until(t, "magenta tooltip in latest.png", func() bool {
404 return magentaShare(filepath.Join(e.shots, "latest.png"), image.Rect(100, 50, 220, 130)) >= 0.9
405 })
406 until(t, "only the toplevel in state", func() bool { return len(readState(e.statePath).Windows) == 1 })
407}
408
409// fullOutput is the share of the output screenshot at path that is green.
410func fullOutput(path string) float64 {
411 return greenShare(path, image.Rect(0, 0, xwaylandW, xwaylandH))
412}
413
414// windowOf returns the state window with the given pid.
415func windowOf(st statefile.State, pid int) (statefile.Window, bool) {
416 for _, w := range st.Windows {
417 if w.PID == pid {
418 return w, true
419 }
420 }
421 return statefile.Window{}, false
422}
423
424// TestXwaylandFullscreen: a fullscreen request after the 1 s grace fills the
425// output, the toggle bind leaves it, and a request made right at the map is
426// ignored (the grace, current policy).
427func TestXwaylandFullscreen(t *testing.T) {
428 // Gaps make a tile visibly smaller than the output.
429 e := startXwaylandWith(t, [][2]int{{xwaylandW, xwaylandH}}, func(c *ports.Config) { c.Layout.Gaps = 20 })
430 shot := filepath.Join(e.shots, "latest.png")
431 corner := image.Rect(0, 0, 4, 4)
432 p := startProbe(t, e.bin, e.display, "-fullscreen", "-fullscreen-delay", "2s")
433 until(t, "tiled with a gap", func() bool {
434 return strings.Contains(p.out.String(), "mapped ") && fullOutput(shot) > 0.3 && greenShare(shot, corner) == 0
435 })
436 until(t, "probe fullscreen at output size", func() bool {
437 return strings.Contains(p.out.String(), "fullscreen 640x360")
438 })
439 until(t, "output all green", func() bool { return fullOutput(shot) >= 0.99 })
440
441 // The toggle bind (cmd+shift+f) leaves fullscreen.
442 if _, err := io.WriteString(e.script, "key Super+Shift+f\n"); err != nil {
443 t.Fatal(err)
444 }
445 until(t, "probe left fullscreen", func() bool { return strings.Contains(p.out.String(), "fullscreen off ") })
446 until(t, "gap back", func() bool { return greenShare(shot, corner) == 0 && fullOutput(shot) < 0.99 })
447}
448
449// TestXwaylandFullscreenOff: the client leaves fullscreen itself.
450func TestXwaylandFullscreenOff(t *testing.T) {
451 e := startXwayland(t)
452 p := startProbe(t, e.bin, e.display, "-fullscreen", "-fullscreen-delay", "1500ms", "-fullscreen-off", "1s")
453 until(t, "fullscreen", func() bool { return strings.Contains(p.out.String(), "fullscreen 640x360") })
454 until(t, "fullscreen off", func() bool { return strings.Contains(p.out.String(), "fullscreen off ") })
455 until(t, "tiled", func() bool {
456 st := readState(e.statePath)
457 return len(st.Windows) == 1 && func() bool { _, ok := tileRect(st, st.Windows[0].ID); return ok }()
458 })
459}
460
461// TestXwaylandInitialFullscreen: a fixed-size window that maps with
462// _NET_WM_STATE_FULLSCREEN already set (a Proton game) opens fullscreen in a
463// column of its own, focused, beside the tiled window already open. This is
464// the state it maps with, not a request: the grace does not apply.
465func TestXwaylandInitialFullscreen(t *testing.T) {
466 e := startXwaylandWith(t, [][2]int{{xwaylandW, xwaylandH}}, func(c *ports.Config) { c.Layout.Gaps = 20 })
467 shot := filepath.Join(e.shots, "latest.png")
468 first := startProbe(t, e.bin, e.display, "-class", "First", "-size", "200x100")
469 until(t, "first tiled", func() bool {
470 return strings.Contains(first.out.String(), "mapped ") && len(readState(e.statePath).Windows) == 1
471 })
472 p := startProbe(t, e.bin, e.display, "-class", "Game", "-initial-fullscreen", "-size", "640x360")
473 until(t, "game mapped", func() bool { return strings.Contains(p.out.String(), "mapped ") })
474 until(t, "game in its own column, fullscreen", func() bool {
475 st := readState(e.statePath)
476 for _, w := range st.Windows {
477 if w.AppID == "Game" {
478 return !w.Floating && w.Column == 2 && w.Row == 1
479 }
480 }
481 return false
482 })
483 until(t, "output all green", func() bool { return fullOutput(shot) >= 0.99 })
484 until(t, "game focused", func() bool { return strings.Contains(p.out.String(), "focus") })
485}
486
487// TestXwaylandFullscreenGrace: a request made at once after the map (not the
488// state it maps with) stays tiled (fullscreenGrace in core).
489func TestXwaylandFullscreenGrace(t *testing.T) {
490 e := startXwayland(t)
491 p := startProbe(t, e.bin, e.display, "-fullscreen")
492 until(t, "mapped", func() bool { return strings.Contains(p.out.String(), "mapped ") })
493 until(t, "tiled window", func() bool {
494 st := readState(e.statePath)
495 return len(st.Windows) == 1 && func() bool { _, ok := tileRect(st, st.Windows[0].ID); return ok }()
496 })
497 // Stay inside the grace, then check it is still not fullscreen.
498 time.Sleep(500 * time.Millisecond)
499 st := readState(e.statePath)
500 if _, ok := tileRect(st, st.Windows[0].ID); !ok {
501 t.Error("window went fullscreen inside the grace")
502 }
503 if strings.Contains(p.out.String(), "fullscreen ") {
504 t.Errorf("probe saw a fullscreen resize inside the grace: %q", p.out.String())
505 }
506}
507
508// TestXwaylandKeyboardGrab: while a fullscreen, focused X11 client holds the
509// keyboard grab the close bind does not fire; after it leaves fullscreen the
510// binds work again.
511func TestXwaylandKeyboardGrab(t *testing.T) {
512 e := startXwayland(t)
513 p := startProbe(t, e.bin, e.display, "-fullscreen", "-fullscreen-delay", "1500ms", "-fullscreen-off", "2500ms", "-keyboard-grab")
514 until(t, "fullscreen", func() bool { return strings.Contains(p.out.String(), "fullscreen 640x360") })
515 until(t, "grab", func() bool { return strings.Contains(p.out.String(), "grab") })
516 until(t, "probe focus", func() bool { return strings.Contains(p.out.String(), "focus") })
517 if _, err := io.WriteString(e.script, "key Super+q\n"); err != nil {
518 t.Fatal(err)
519 }
520 select {
521 case err := <-p.waited:
522 t.Fatalf("probe closed (%v) by Super+q despite the keyboard grab", err)
523 case <-time.After(2 * time.Second):
524 }
525
526 // The probe leaves fullscreen itself; its grab stays, but the binds work again.
527 until(t, "fullscreen off", func() bool { return strings.Contains(p.out.String(), "fullscreen off ") })
528 if _, err := io.WriteString(e.script, "key Super+q\n"); err != nil {
529 t.Fatal(err)
530 }
531 select {
532 case err := <-p.waited:
533 if err != nil {
534 t.Errorf("probe exited with %v, want 0", err)
535 }
536 case <-time.After(2 * time.Second):
537 t.Fatal("Super+q did not close the client after it left fullscreen")
538 }
539}
540
541// TestXwaylandKeyboardGrabWindowed: a grab on a windowed client does not lock
542// the binds.
543func TestXwaylandKeyboardGrabWindowed(t *testing.T) {
544 e := startXwayland(t)
545 p := startProbe(t, e.bin, e.display, "-keyboard-grab")
546 until(t, "grab", func() bool { return strings.Contains(p.out.String(), "grab") })
547 until(t, "probe focus", func() bool { return strings.Contains(p.out.String(), "focus") })
548 if _, err := io.WriteString(e.script, "key Super+q\n"); err != nil {
549 t.Fatal(err)
550 }
551 select {
552 case err := <-p.waited:
553 if err != nil {
554 t.Errorf("probe exited with %v, want 0", err)
555 }
556 case <-time.After(2 * time.Second):
557 t.Fatal("Super+q did not close a windowed client that holds a keyboard grab")
558 }
559}
560
561// TestXwaylandOutputHint: with two outputs a window created at the origin of
562// the second one opens on it, and a fullscreen request fills that output.
563func TestXwaylandOutputHint(t *testing.T) {
564 sizes := [][2]int{{xwaylandW, xwaylandH}, {xwaylandW, xwaylandH}}
565 e := startXwaylandWith(t, sizes, nil)
566 p := startProbe(t, e.bin, e.display, "-size", "640x360", "-at", "640,0", "-fullscreen", "-fullscreen-delay", "1500ms")
567 var w statefile.Window
568 until(t, "window on HEADLESS-2", func() bool {
569 var ok bool
570 w, ok = windowOf(readState(e.statePath), p.cmd.Process.Pid)
571 if ok && w.Output != "HEADLESS-2" {
572 t.Logf("window output = %s", w.Output)
573 }
574 return ok && w.Output == "HEADLESS-2"
575 })
576 until(t, "probe fullscreen", func() bool { return strings.Contains(p.out.String(), "fullscreen 640x360") })
577 until(t, "HEADLESS-2 all green", func() bool {
578 return fullOutput(filepath.Join(e.shots, "HEADLESS-2", "latest.png")) >= 0.99
579 })
580 if g := fullOutput(filepath.Join(e.shots, "HEADLESS-1", "latest.png")); g > 0.1 {
581 t.Errorf("HEADLESS-1 green share = %.2f, want empty", g)
582 }
583}
584
585// TestXwaylandClassRule: [window-rule] matches the WM_CLASS of an X11 window
586// the way it matches a Wayland app ID (steam_app_<id> from Proton).
587func TestXwaylandClassRule(t *testing.T) {
588 yes := true
589 e := startXwaylandWith(t, [][2]int{{xwaylandW, xwaylandH}}, func(c *ports.Config) {
590 c.Rules = append(c.Rules, ports.WindowRule{Name: "games", AppID: regexp.MustCompile(`^steam_app_\d+$`), Floating: &yes})
591 })
592 startProbe(t, e.bin, e.display, "-class", "steam_app_413150")
593 until(t, "floating steam_app window", func() bool {
594 st := readState(e.statePath)
595 return len(st.Windows) == 1 && st.Windows[0].AppID == "steam_app_413150" && st.Windows[0].Floating
596 })
597 startProbe(t, e.bin, e.display, "-class", "Other")
598 until(t, "other class tiled", func() bool {
599 for _, w := range readState(e.statePath).Windows {
600 if w.AppID == "Other" {
601 return !w.Floating
602 }
603 }
604 return false
605 })
606}
607
608// waylandEnv is the environment of a Wayland client of the headless
609// compositor: its socket, not the user's session.
610func waylandEnv() []string {
611 return append(envWithout("DISPLAY", "WAYLAND_DISPLAY", "NEFERWL_STATE", "NEFERWL_SLOT"), "WAYLAND_DISPLAY=wayland-1")
612}
613
614// runProbe runs a one-shot x11probe mode and returns its stdout.
615func runProbe(t *testing.T, e *xwaylandEnv, args ...string) string {
616 t.Helper()
617 ctx, cancel := context.WithTimeout(context.Background(), xwaylandTimeout)
618 defer cancel()
619 cmd := exec.CommandContext(ctx, e.bin, args...)
620 cmd.Env = append(envWithout("DISPLAY", "WAYLAND_DISPLAY", "NEFERWL_STATE", "NEFERWL_SLOT"), "DISPLAY="+e.display)
621 out, err := cmd.Output()
622 if err != nil {
623 t.Fatalf("x11probe %v: %v (stdout %q)", args, err, out)
624 }
625 return strings.TrimSpace(string(out))
626}
627
628// needWlClipboard skips the test without wl-copy and wl-paste.
629func needWlClipboard(t *testing.T) {
630 t.Helper()
631 for _, c := range []string{"wl-copy", "wl-paste"} {
632 if _, err := exec.LookPath(c); err != nil {
633 t.Skipf("%s not on PATH (wl-clipboard)", c)
634 }
635 }
636}
637
638// wlCopy starts wl-copy --foreground serving data on the headless
639// compositor's seat and stops it when the test ends.
640func wlCopy(t *testing.T, data string, args ...string) {
641 t.Helper()
642 cmd := exec.Command("wl-copy", append([]string{"--foreground", "--type", "text/plain;charset=utf-8"}, args...)...)
643 cmd.Env = waylandEnv()
644 cmd.Stdin = strings.NewReader(data)
645 stderr := &lockedBuffer{}
646 cmd.Stderr = stderr
647 if err := cmd.Start(); err != nil {
648 t.Fatal(err)
649 }
650 waited := make(chan struct{})
651 go func() { _ = cmd.Wait(); close(waited) }()
652 t.Cleanup(func() {
653 _ = cmd.Process.Kill()
654 <-waited
655 if s := stderr.String(); s != "" {
656 t.Logf("wl-copy stderr: %s", s)
657 }
658 })
659}
660
661// wlPaste returns what wl-paste prints, and whether it succeeded.
662func wlPaste(args ...string) (string, bool) {
663 ctx, cancel := context.WithTimeout(context.Background(), xwaylandTimeout)
664 defer cancel()
665 cmd := exec.CommandContext(ctx, "wl-paste", append([]string{"--no-newline", "--type", "text/plain;charset=utf-8"}, args...)...)
666 cmd.Env = waylandEnv()
667 out, err := cmd.Output()
668 return string(out), err == nil
669}
670
671// payload is n bytes of printable text that differ along the way, so a
672// misplaced chunk is caught.
673func payload(n int) string {
674 var b strings.Builder
675 for i := 0; b.Len() < n; i++ {
676 fmt.Fprintf(&b, "line %d é\n", i)
677 }
678 return b.String()[:n]
679}
680
681// pasteLine is what x11probe -paste prints for want.
682func pasteLine(want string) string {
683 if len(want) <= 4096 {
684 return fmt.Sprintf("paste %q", want)
685 }
686 return fmt.Sprintf("paste len=%d sha256=%x", len(want), sha256.Sum256([]byte(want)))
687}
688
689// TestXwaylandClipboard: the clipboard and the primary selection cross the
690// X11/Wayland boundary in both directions, text only, small and INCR sized.
691func TestXwaylandClipboard(t *testing.T) {
692 needWlClipboard(t)
693 e := startXwayland(t)
694 startProbe(t, e.bin, e.display, "-title", "focus")
695 until(t, "probe focused", func() bool { st := readState(e.statePath); return st.Window != nil })
696
697 for _, sel := range []struct{ x, w string }{{"CLIPBOARD", ""}, {"PRIMARY", "--primary"}} {
698 for _, size := range []int{4 << 10, 1 << 20} {
699 name := fmt.Sprintf("%s/%dKiB", sel.x, size>>10)
700 text := payload(size)
701 t.Run("W2X/"+name, func(t *testing.T) {
702 var wargs []string
703 if sel.w != "" {
704 wargs = append(wargs, sel.w)
705 }
706 wlCopy(t, text, wargs...)
707 until(t, "x11probe pastes the Wayland copy", func() bool {
708 return runProbe(t, e, "-paste", sel.x) == pasteLine(text)
709 })
710 })
711 t.Run("X2W/"+name, func(t *testing.T) {
712 file := filepath.Join(t.TempDir(), "text")
713 if err := os.WriteFile(file, []byte(text), 0o600); err != nil {
714 t.Fatal(err)
715 }
716 p := startProbe(t, e.bin, e.display, "-copy", "@"+file, "-copy-sel", sel.x, "-copy-for", "30s")
717 until(t, "copy owned", func() bool { return strings.Contains(p.out.String(), "copy owned") })
718 var wargs []string
719 if sel.w != "" {
720 wargs = append(wargs, sel.w)
721 }
722 var got string
723 until(t, "wl-paste returns the X copy", func() bool {
724 var ok bool
725 got, ok = wlPaste(wargs...)
726 return ok && got == text
727 })
728 if got != text {
729 t.Errorf("wl-paste differs: %d bytes", len(got))
730 }
731 })
732 }
733 }
734
735 t.Run("clear", func(t *testing.T) {
736 wlCopy(t, "to be cleared")
737 until(t, "paste before clear", func() bool { return runProbe(t, e, "-paste", "CLIPBOARD") == `paste "to be cleared"` })
738 cmd := exec.Command("wl-copy", "--clear")
739 cmd.Env = waylandEnv()
740 if out, err := cmd.CombinedOutput(); err != nil {
741 t.Fatalf("wl-copy --clear: %v: %s", err, out)
742 }
743 until(t, "paste none after clear", func() bool { return runProbe(t, e, "-paste", "CLIPBOARD") == "paste none" })
744 })
745}
746
747// cursorTheme installs a one-arrow Xcursor theme of the given size and
748// selects it through the XCURSOR_* environment.
749func cursorTheme(t *testing.T, size int) {
750 t.Helper()
751 dir := t.TempDir()
752 themeCursor(t, dir, "left_ptr", size, 1)
753 t.Setenv("XCURSOR_PATH", dir)
754 t.Setenv("XCURSOR_THEME", "theme")
755 t.Setenv("XCURSOR_SIZE", strconv.Itoa(size))
756}
757
758// TestXwaylandXSettings: the XSETTINGS manager publishes the cursor theme,
759// the cursor size and a fixed scale 1 / 96 dpi.
760func TestXwaylandXSettings(t *testing.T) {
761 cursorTheme(t, 32)
762 e := startXwayland(t)
763 want := "xsettings serial=1 Xft/DPI=98304 Gdk/WindowScalingFactor=1 Gdk/UnscaledDPI=98304 Gtk/CursorThemeName=theme Gtk/CursorThemeSize=32"
764 var got string
765 until(t, "XSETTINGS owner", func() bool { got = runProbe(t, e, "-xsettings"); return got != "xsettings none" })
766 if got != want {
767 t.Errorf("-xsettings = %q, want %q", got, want)
768 }
769}
770
771// TestXwaylandXSettingsScale2AnnouncesScale1: X11 sees logical sizes, so the
772// DPI stays 96 even on a scale-2 output.
773func TestXwaylandXSettingsScale2AnnouncesScale1(t *testing.T) {
774 cursorTheme(t, 24)
775 e := startXwaylandWith(t, [][2]int{{xwaylandW, xwaylandH}}, func(c *ports.Config) {
776 c.Outputs = append(c.Outputs, ports.OutputConfig{Name: "HEADLESS-1", Scale: 2, ScaleOnly: true})
777 })
778 var got string
779 until(t, "XSETTINGS owner", func() bool { got = runProbe(t, e, "-xsettings"); return got != "xsettings none" })
780 if !strings.Contains(got, "Xft/DPI=98304") || !strings.Contains(got, "Gdk/WindowScalingFactor=1") {
781 t.Errorf("-xsettings = %q, want Xft/DPI=98304 and Gdk/WindowScalingFactor=1", got)
782 }
783 if !strings.Contains(got, "Gtk/CursorThemeSize=24") {
784 t.Errorf("-xsettings = %q, want Gtk/CursorThemeSize=24", got)
785 }
786}
787
788// TestXwaylandXSettingsOff: xwayland.xsettings = off leaves no manager.
789func TestXwaylandXSettingsOff(t *testing.T) {
790 e := startXwaylandWith(t, [][2]int{{xwaylandW, xwaylandH}}, func(c *ports.Config) { c.Xwayland.XSettings = false })
791 // A mapped window proves the XWM is up before the absence is checked.
792 p := startProbe(t, e.bin, e.display)
793 until(t, "probe mapped", func() bool { return strings.Contains(p.out.String(), "mapped ") })
794 if got := runProbe(t, e, "-xsettings"); got != "xsettings none" {
795 t.Errorf("-xsettings = %q, want none", got)
796 }
797}
798
799// TestXwaylandRootCursor: the X background shows the themed arrow, at the
800// theme size.
801func TestXwaylandRootCursor(t *testing.T) {
802 cursorTheme(t, 32)
803 e := startXwayland(t)
804 until(t, "XWM up", func() bool { return runProbe(t, e, "-xsettings") != "xsettings none" })
805 if got := runProbe(t, e, "-root-cursor"); got != "cursor 32x32" {
806 t.Errorf("-root-cursor = %q, want cursor 32x32", got)
807 }
808}