xwayland_test.go RawHistoryFolder

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}

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