detached b64a207

test(app): add headless Xwayland end-to-end test with an x11probe client

commit
b64a2071901ea81ab2ac98726bd98fa960b707a8
author
brice · · 2026-10-22 13:59 UTC
committer
brice · · 2026-10-22 17:54 UTC
parent
ace7dd1
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +430 -1
M Makefile +7 -1
@@ -1,4 +1,4 @@
1-.PHONY: build test vet race mocks mocks-check spv-check fakes-check log-check arch adapter-imports check perf-check color-check bin tty logs pkg install
1+.PHONY: build test vet race e2e-xwayland mocks mocks-check spv-check fakes-check log-check arch adapter-imports check perf-check color-check bin tty logs pkg install
22
33 # 0 runs until quit; set e.g. TTY_TIMEOUT=60s for a safety net.
44 TTY_TIMEOUT ?= 0
@@ -26,6 +26,7 @@ test:
2626 CGO_ENABLED=0 go test ./...
2727 vet:
2828 CGO_ENABLED=0 go vet ./...
29+ CGO_ENABLED=0 go vet -tags xwayland ./internal/app
2930 race:
3031 # cgo is enabled only for the race test binary.
3132 CGO_ENABLED=1 go test -race ./...
@@ -78,6 +79,11 @@ check: vet test arch fakes-check log-check perf-check
7879 color-check:
7980 CGO_ENABLED=0 go test -tags colorcheck ./internal/app -run '^TestColorCheck' -count=1 -v
8081
82+# End-to-end test of the built-in XWM: a real Xwayland and an X11 probe on a
83+# headless NeferWL. Needs Xwayland and a GPU; not part of check.
84+e2e-xwayland:
85+ CGO_ENABLED=1 go test -tags xwayland -race ./internal/app -run TestXwayland -count=1 -v
86+
8187 # Arch package of the committed HEAD (packaging/arch/PKGBUILD). Go modules
8288 # come from the module proxy in prepare(); the build itself runs offline.
8389 # pacman-ordered version: 0.0.0.r<commits>.g<hash>; a tag replaces 0.0.0.
@@ -1,4 +1,4 @@
1-.PHONY: build test vet race mocks mocks-check spv-check fakes-check log-check arch adapter-imports check perf-check color-check bin tty logs pkg install1+.PHONY: build test vet race e2e-xwayland mocks mocks-check spv-check fakes-check log-check arch adapter-imports check perf-check color-check bin tty logs pkg install
2 2
3 # 0 runs until quit; set e.g. TTY_TIMEOUT=60s for a safety net.3 # 0 runs until quit; set e.g. TTY_TIMEOUT=60s for a safety net.
4 TTY_TIMEOUT ?= 04 TTY_TIMEOUT ?= 0
@@ -26,6 +26,7 @@ test:
26 CGO_ENABLED=0 go test ./...26 CGO_ENABLED=0 go test ./...
27 vet:27 vet:
28 CGO_ENABLED=0 go vet ./...28 CGO_ENABLED=0 go vet ./...
29+ CGO_ENABLED=0 go vet -tags xwayland ./internal/app
29 race:30 race:
30 # cgo is enabled only for the race test binary.31 # cgo is enabled only for the race test binary.
31 CGO_ENABLED=1 go test -race ./...32 CGO_ENABLED=1 go test -race ./...
@@ -78,6 +79,11 @@ check: vet test arch fakes-check log-check perf-check
78 color-check:79 color-check:
79 CGO_ENABLED=0 go test -tags colorcheck ./internal/app -run '^TestColorCheck' -count=1 -v80 CGO_ENABLED=0 go test -tags colorcheck ./internal/app -run '^TestColorCheck' -count=1 -v
80 81
82+# End-to-end test of the built-in XWM: a real Xwayland and an X11 probe on a
83+# headless NeferWL. Needs Xwayland and a GPU; not part of check.
84+e2e-xwayland:
85+ CGO_ENABLED=1 go test -tags xwayland -race ./internal/app -run TestXwayland -count=1 -v
86+
81 # Arch package of the committed HEAD (packaging/arch/PKGBUILD). Go modules87 # Arch package of the committed HEAD (packaging/arch/PKGBUILD). Go modules
82 # come from the module proxy in prepare(); the build itself runs offline.88 # come from the module proxy in prepare(); the build itself runs offline.
83 # pacman-ordered version: 0.0.0.r<commits>.g<hash>; a tag replaces 0.0.0.89 # pacman-ordered version: 0.0.0.r<commits>.g<hash>; a tag replaces 0.0.0.
A internal/app/testdata/x11probe/main.go +141 -0
@@ -0,0 +1,141 @@
1+// Command x11probe is a minimal X11 client for the Xwayland end-to-end test.
2+//
3+// It opens one window filled with solid green, prints "mapped WxH" after the
4+// first ConfigureNotify and "focus" on each FocusIn, and exits 0 when the
5+// window manager sends WM_DELETE_WINDOW.
6+package main
7+
8+import (
9+ "encoding/binary"
10+ "flag"
11+ "fmt"
12+ "os"
13+ "strconv"
14+ "strings"
15+ "time"
16+
17+ "golang.org/x/sys/unix"
18+
19+ "bnema.dev/xturbo"
20+ "bnema.dev/xturbo/xproto"
21+)
22+
23+const green = 0x00ff00
24+
25+func main() {
26+ if err := run(); err != nil {
27+ fmt.Fprintln(os.Stderr, "x11probe:", err)
28+ os.Exit(1)
29+ }
30+}
31+
32+func run() error {
33+ class := flag.String("class", "X11Probe", "WM_CLASS class (the instance is x11probe)")
34+ title := flag.String("title", "probe", "_NET_WM_NAME")
35+ size := flag.String("size", "300x200", "window size WxH")
36+ sleep := flag.Duration("sleep", 0, "wait before mapping the window")
37+ flag.Parse()
38+ w, h, ok := parseSize(*size)
39+ if !ok {
40+ return fmt.Errorf("bad -size %q", *size)
41+ }
42+
43+ c, err := xturbo.Dial("") // $DISPLAY
44+ if err != nil {
45+ return err
46+ }
47+ defer c.Close()
48+
49+ var atoms [4]uint32
50+ if err := c.InternAtoms([]string{"WM_PROTOCOLS", "WM_DELETE_WINDOW", "_NET_WM_NAME", "UTF8_STRING"}, false, atoms[:]); err != nil {
51+ return err
52+ }
53+ protocols, deleteWin, netWMName, utf8 := xproto.Atom(atoms[0]), xproto.Atom(atoms[1]), xproto.Atom(atoms[2]), xproto.Atom(atoms[3])
54+
55+ b := c.Buf()
56+ xwin, err := c.NewXID()
57+ if err != nil {
58+ return err
59+ }
60+ xgc, err := c.NewXID()
61+ if err != nil {
62+ return err
63+ }
64+ win, gc := xproto.Window(xwin), xproto.Gcontext(xgc)
65+
66+ var v xproto.CreateWindowValues
67+ v.SetEventMask(xproto.EventMaskExposure | xproto.EventMaskStructureNotify | xproto.EventMaskFocusChange)
68+ xproto.CreateWindow(b, 0, win, xproto.Window(c.Root()), 0, 0, uint16(w), uint16(h), 0, xproto.WindowClassInputOutput, 0, &v)
69+
70+ wmClass := []byte("x11probe\x00" + *class + "\x00")
71+ xproto.ChangeProperty(b, xproto.PropModeReplace, win, xproto.AtomWmClass, xproto.AtomString, 8, uint32(len(wmClass)), wmClass)
72+ name := []byte(*title)
73+ xproto.ChangeProperty(b, xproto.PropModeReplace, win, netWMName, utf8, 8, uint32(len(name)), name)
74+ var del [4]byte
75+ binary.LittleEndian.PutUint32(del[:], uint32(deleteWin))
76+ xproto.ChangeProperty(b, xproto.PropModeReplace, win, protocols, xproto.AtomAtom, 32, 1, del[:])
77+
78+ var gv xproto.CreateGCValues
79+ gv.SetForeground(green)
80+ xproto.CreateGC(b, gc, xproto.Drawable(win), &gv)
81+ if err := c.Flush(); err != nil {
82+ return err
83+ }
84+ time.Sleep(*sleep)
85+ xproto.MapWindow(b, win)
86+
87+ mapped, done := false, false
88+ cw, ch := uint16(w), uint16(h) // current size, tracked from ConfigureNotify
89+ hd := &xturbo.Handler{
90+ OnEvent: func(ev *xturbo.Event) {
91+ switch ev.Kind {
92+ case xproto.KindExpose:
93+ xproto.PolyFillRectangle(b, xproto.Drawable(win), gc, []xproto.Rectangle{{Width: cw, Height: ch}})
94+ case xproto.KindConfigureNotify:
95+ e := xproto.ConfigureNotifyOf(ev)
96+ if e.Window() != win {
97+ break
98+ }
99+ cw, ch = e.Width(), e.Height()
100+ if !mapped {
101+ mapped = true
102+ fmt.Printf("mapped %dx%d\n", cw, ch)
103+ }
104+ case xproto.KindFocusIn:
105+ fmt.Println("focus")
106+ case xproto.KindClientMessage:
107+ e := xproto.ClientMessageOf(ev)
108+ if e.Type() == protocols && e.Format() == 32 && e.Data().Data32()[0] == uint32(deleteWin) {
109+ done = true
110+ }
111+ }
112+ },
113+ OnError: func(e *xturbo.XError) { fmt.Fprintln(os.Stderr, "x11probe:", e) },
114+ }
115+ for !done {
116+ events := int16(unix.POLLIN)
117+ if err := c.Flush(); err == xturbo.ErrWouldBlock {
118+ events |= unix.POLLOUT
119+ } else if err != nil {
120+ return err
121+ }
122+ fds := []unix.PollFd{{Fd: int32(c.Fd()), Events: events}}
123+ if _, err := unix.Poll(fds, -1); err != nil && err != unix.EINTR {
124+ return err
125+ }
126+ if err := c.Dispatch(hd); err != nil {
127+ return err
128+ }
129+ }
130+ return nil
131+}
132+
133+func parseSize(s string) (w, h int, ok bool) {
134+ ws, hs, found := strings.Cut(s, "x")
135+ if !found {
136+ return 0, 0, false
137+ }
138+ w, err1 := strconv.Atoi(ws)
139+ h, err2 := strconv.Atoi(hs)
140+ return w, h, err1 == nil && err2 == nil && w > 0 && h > 0 && w < 0xffff && h < 0xffff
141+}
@@ -0,0 +1,141 @@
1+// Command x11probe is a minimal X11 client for the Xwayland end-to-end test.
2+//
3+// It opens one window filled with solid green, prints "mapped WxH" after the
4+// first ConfigureNotify and "focus" on each FocusIn, and exits 0 when the
5+// window manager sends WM_DELETE_WINDOW.
6+package main
7+
8+import (
9+ "encoding/binary"
10+ "flag"
11+ "fmt"
12+ "os"
13+ "strconv"
14+ "strings"
15+ "time"
16+
17+ "golang.org/x/sys/unix"
18+
19+ "bnema.dev/xturbo"
20+ "bnema.dev/xturbo/xproto"
21+)
22+
23+const green = 0x00ff00
24+
25+func main() {
26+ if err := run(); err != nil {
27+ fmt.Fprintln(os.Stderr, "x11probe:", err)
28+ os.Exit(1)
29+ }
30+}
31+
32+func run() error {
33+ class := flag.String("class", "X11Probe", "WM_CLASS class (the instance is x11probe)")
34+ title := flag.String("title", "probe", "_NET_WM_NAME")
35+ size := flag.String("size", "300x200", "window size WxH")
36+ sleep := flag.Duration("sleep", 0, "wait before mapping the window")
37+ flag.Parse()
38+ w, h, ok := parseSize(*size)
39+ if !ok {
40+ return fmt.Errorf("bad -size %q", *size)
41+ }
42+
43+ c, err := xturbo.Dial("") // $DISPLAY
44+ if err != nil {
45+ return err
46+ }
47+ defer c.Close()
48+
49+ var atoms [4]uint32
50+ if err := c.InternAtoms([]string{"WM_PROTOCOLS", "WM_DELETE_WINDOW", "_NET_WM_NAME", "UTF8_STRING"}, false, atoms[:]); err != nil {
51+ return err
52+ }
53+ protocols, deleteWin, netWMName, utf8 := xproto.Atom(atoms[0]), xproto.Atom(atoms[1]), xproto.Atom(atoms[2]), xproto.Atom(atoms[3])
54+
55+ b := c.Buf()
56+ xwin, err := c.NewXID()
57+ if err != nil {
58+ return err
59+ }
60+ xgc, err := c.NewXID()
61+ if err != nil {
62+ return err
63+ }
64+ win, gc := xproto.Window(xwin), xproto.Gcontext(xgc)
65+
66+ var v xproto.CreateWindowValues
67+ v.SetEventMask(xproto.EventMaskExposure | xproto.EventMaskStructureNotify | xproto.EventMaskFocusChange)
68+ xproto.CreateWindow(b, 0, win, xproto.Window(c.Root()), 0, 0, uint16(w), uint16(h), 0, xproto.WindowClassInputOutput, 0, &v)
69+
70+ wmClass := []byte("x11probe\x00" + *class + "\x00")
71+ xproto.ChangeProperty(b, xproto.PropModeReplace, win, xproto.AtomWmClass, xproto.AtomString, 8, uint32(len(wmClass)), wmClass)
72+ name := []byte(*title)
73+ xproto.ChangeProperty(b, xproto.PropModeReplace, win, netWMName, utf8, 8, uint32(len(name)), name)
74+ var del [4]byte
75+ binary.LittleEndian.PutUint32(del[:], uint32(deleteWin))
76+ xproto.ChangeProperty(b, xproto.PropModeReplace, win, protocols, xproto.AtomAtom, 32, 1, del[:])
77+
78+ var gv xproto.CreateGCValues
79+ gv.SetForeground(green)
80+ xproto.CreateGC(b, gc, xproto.Drawable(win), &gv)
81+ if err := c.Flush(); err != nil {
82+ return err
83+ }
84+ time.Sleep(*sleep)
85+ xproto.MapWindow(b, win)
86+
87+ mapped, done := false, false
88+ cw, ch := uint16(w), uint16(h) // current size, tracked from ConfigureNotify
89+ hd := &xturbo.Handler{
90+ OnEvent: func(ev *xturbo.Event) {
91+ switch ev.Kind {
92+ case xproto.KindExpose:
93+ xproto.PolyFillRectangle(b, xproto.Drawable(win), gc, []xproto.Rectangle{{Width: cw, Height: ch}})
94+ case xproto.KindConfigureNotify:
95+ e := xproto.ConfigureNotifyOf(ev)
96+ if e.Window() != win {
97+ break
98+ }
99+ cw, ch = e.Width(), e.Height()
100+ if !mapped {
101+ mapped = true
102+ fmt.Printf("mapped %dx%d\n", cw, ch)
103+ }
104+ case xproto.KindFocusIn:
105+ fmt.Println("focus")
106+ case xproto.KindClientMessage:
107+ e := xproto.ClientMessageOf(ev)
108+ if e.Type() == protocols && e.Format() == 32 && e.Data().Data32()[0] == uint32(deleteWin) {
109+ done = true
110+ }
111+ }
112+ },
113+ OnError: func(e *xturbo.XError) { fmt.Fprintln(os.Stderr, "x11probe:", e) },
114+ }
115+ for !done {
116+ events := int16(unix.POLLIN)
117+ if err := c.Flush(); err == xturbo.ErrWouldBlock {
118+ events |= unix.POLLOUT
119+ } else if err != nil {
120+ return err
121+ }
122+ fds := []unix.PollFd{{Fd: int32(c.Fd()), Events: events}}
123+ if _, err := unix.Poll(fds, -1); err != nil && err != unix.EINTR {
124+ return err
125+ }
126+ if err := c.Dispatch(hd); err != nil {
127+ return err
128+ }
129+ }
130+ return nil
131+}
132+
133+func parseSize(s string) (w, h int, ok bool) {
134+ ws, hs, found := strings.Cut(s, "x")
135+ if !found {
136+ return 0, 0, false
137+ }
138+ w, err1 := strconv.Atoi(ws)
139+ h, err2 := strconv.Atoi(hs)
140+ return w, h, err1 == nil && err2 == nil && w > 0 && h > 0 && w < 0xffff && h < 0xffff
141+}
A internal/app/xwayland_test.go +282 -0
@@ -0,0 +1,282 @@
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`.
7+package app
8+
9+import (
10+ "context"
11+ "image"
12+ "image/png"
13+ "io"
14+ "os"
15+ "os/exec"
16+ "path/filepath"
17+ "regexp"
18+ "strings"
19+ "sync"
20+ "testing"
21+ "time"
22+
23+ "github.com/bnema/zerowrap"
24+
25+ "github.com/bnema/neferwl/internal/adapters/config"
26+ "github.com/bnema/neferwl/internal/adapters/statefile"
27+ "github.com/bnema/neferwl/internal/ports"
28+)
29+
30+const (
31+ xwaylandTimeout = 10 * time.Second
32+ xwaylandW, xwaylandH = 640, 360
33+)
34+
35+var displayRe = regexp.MustCompile(`"DISPLAY":"(:\d+)"`)
36+
37+// probe is a running x11probe.
38+type probe struct {
39+ cmd *exec.Cmd
40+ out *lockedBuffer
41+ waited chan error
42+}
43+
44+// TestXwaylandProbe: an X11 client is managed by the built-in XWM. It shows
45+// up in the state with its class and pid, is drawn, tiles next to a second
46+// one, receives focus and is closed by the close-window bind.
47+func TestXwaylandProbe(t *testing.T) {
48+ if _, err := exec.LookPath("Xwayland"); err != nil {
49+ t.Skip("Xwayland not on PATH")
50+ }
51+ // The compositor must not read or write the live session's state.
52+ for _, k := range []string{"NEFERWL_STATE", "NEFERWL_SLOT"} {
53+ if v, ok := os.LookupEnv(k); ok {
54+ os.Unsetenv(k)
55+ t.Cleanup(func() { os.Setenv(k, v) })
56+ }
57+ }
58+ runtime := t.TempDir()
59+ t.Setenv("XDG_RUNTIME_DIR", runtime)
60+ t.Setenv("XDG_STATE_HOME", t.TempDir())
61+ shots := t.TempDir()
62+ bin := buildProbe(t)
63+
64+ script, scriptW := io.Pipe()
65+ logs := &lockedBuffer{}
66+ ctx, cancel := context.WithCancel(zerowrap.WithCtx(context.Background(), zerowrap.New(zerowrap.Config{Level: "info", Format: "json", Output: logs})))
67+ cfg := config.Defaults()
68+ cfg.Xwayland = ports.XwaylandConfig{Backend: ports.XwaylandBuiltin, Binary: "Xwayland"}
69+ done := make(chan error, 1)
70+ go func() {
71+ done <- Run(ctx, Options{
72+ Backend: "headless", NoTerminal: true, Config: cfg, Script: script,
73+ ScreenshotDir: shots, Sizes: [][2]int{{xwaylandW, xwaylandH}},
74+ })
75+ }()
76+ t.Cleanup(func() {
77+ if t.Failed() {
78+ t.Logf("compositor log:\n%s", logs.String())
79+ }
80+ cancel()
81+ _ = scriptW.Close()
82+ select {
83+ case err := <-done:
84+ if err != nil {
85+ t.Errorf("Run: %v", err)
86+ }
87+ case <-time.After(5 * time.Second):
88+ t.Error("Run did not stop")
89+ }
90+ })
91+
92+ var display string
93+ until(t, "X11 display in the log", func() bool {
94+ if m := displayRe.FindStringSubmatch(logs.String()); m != nil {
95+ display = m[1]
96+ return true
97+ }
98+ return false
99+ })
100+ statePath, err := statefile.Path(runtime, "wayland-1")
101+ if err != nil {
102+ t.Fatal(err)
103+ }
104+
105+ first := startProbe(t, bin, display, "-title", "one")
106+ until(t, "probe one mapped", func() bool { return strings.Contains(first.out.String(), "mapped ") })
107+
108+ var st statefile.State
109+ until(t, "one visible X11Probe window", func() bool {
110+ st = readState(statePath)
111+ return len(st.Windows) == 1 && st.Windows[0].AppID == "X11Probe" && st.Windows[0].Visible
112+ })
113+ if w := st.Windows[0]; w.PID != first.cmd.Process.Pid {
114+ t.Errorf("window pid %d, want %d", w.PID, first.cmd.Process.Pid)
115+ }
116+
117+ // The client draws green and the compositor shows it.
118+ id := st.Windows[0].ID
119+ until(t, "green window in latest.png", func() bool {
120+ r, ok := tileRect(readState(statePath), id)
121+ return ok && greenShare(filepath.Join(shots, "latest.png"), r) >= 0.5
122+ })
123+
124+ // Focus round trip: the compositor focused the window, the client saw it.
125+ until(t, "probe one focus", func() bool { return strings.Contains(first.out.String(), "focus") })
126+
127+ // A second probe tiles beside the first.
128+ second := startProbe(t, bin, display, "-title", "two")
129+ until(t, "two tiled windows", func() bool {
130+ st = readState(statePath)
131+ if len(st.Windows) != 2 {
132+ return false
133+ }
134+ for _, w := range st.Windows {
135+ if w.AppID != "X11Probe" || !w.Visible || w.Floating {
136+ return false
137+ }
138+ }
139+ return true
140+ })
141+ until(t, "probe two focus", func() bool { return strings.Contains(second.out.String(), "focus") })
142+
143+ // The close-window bind asks the focused (second) probe to close.
144+ if _, err := io.WriteString(scriptW, "key Super+q\n"); err != nil {
145+ t.Fatal(err)
146+ }
147+ select {
148+ case err := <-second.waited:
149+ if err != nil {
150+ t.Errorf("probe two exited with %v, want 0", err)
151+ }
152+ case <-time.After(2 * time.Second):
153+ t.Fatal("probe two did not exit within 2s of Super+q")
154+ }
155+ until(t, "one window left", func() bool {
156+ st = readState(statePath)
157+ return len(st.Windows) == 1 && st.Windows[0].PID == first.cmd.Process.Pid
158+ })
159+ select {
160+ case err := <-first.waited:
161+ t.Fatalf("probe one exited (%v) though only the focused probe was closed", err)
162+ default:
163+ }
164+}
165+
166+// buildProbe builds testdata/x11probe.
167+func buildProbe(t *testing.T) string {
168+ t.Helper()
169+ bin := filepath.Join(t.TempDir(), "x11probe")
170+ build := exec.Command("go", "build", "-o", bin, "./testdata/x11probe")
171+ build.Env = append(os.Environ(), "CGO_ENABLED=0")
172+ if out, err := build.CombinedOutput(); err != nil {
173+ t.Fatalf("build x11probe: %v: %s", err, out)
174+ }
175+ return bin
176+}
177+
178+// startProbe starts x11probe on display, with X11 as its only display
179+// server, and stops it when the test ends.
180+func startProbe(t *testing.T, bin, display string, args ...string) *probe {
181+ t.Helper()
182+ p := &probe{out: &lockedBuffer{}, waited: make(chan error, 1)}
183+ p.cmd = exec.Command(bin, args...)
184+ p.cmd.Env = append(envWithout("DISPLAY", "WAYLAND_DISPLAY", "NEFERWL_STATE", "NEFERWL_SLOT"), "DISPLAY="+display)
185+ p.cmd.Stdout = p.out
186+ stderr := &lockedBuffer{}
187+ p.cmd.Stderr = stderr
188+ if err := p.cmd.Start(); err != nil {
189+ t.Fatal(err)
190+ }
191+ var exited sync.Once
192+ go func() { p.waited <- p.cmd.Wait() }()
193+ t.Cleanup(func() {
194+ exited.Do(func() {
195+ _ = p.cmd.Process.Kill()
196+ select {
197+ case <-p.waited:
198+ case <-time.After(2 * time.Second):
199+ }
200+ })
201+ if s := stderr.String(); s != "" {
202+ t.Logf("x11probe stderr: %s", s)
203+ }
204+ })
205+ return p
206+}
207+
208+func envWithout(keys ...string) []string {
209+ var env []string
210+next:
211+ for _, kv := range os.Environ() {
212+ for _, k := range keys {
213+ if strings.HasPrefix(kv, k+"=") {
214+ continue next
215+ }
216+ }
217+ env = append(env, kv)
218+ }
219+ return env
220+}
221+
222+// until polls cond every 20 ms and fails the test after xwaylandTimeout.
223+func until(t *testing.T, what string, cond func() bool) {
224+ t.Helper()
225+ for deadline := time.Now().Add(xwaylandTimeout); !cond(); time.Sleep(20 * time.Millisecond) {
226+ if time.Now().After(deadline) {
227+ t.Fatalf("timeout waiting for %s", what)
228+ }
229+ }
230+}
231+
232+// readState returns the current state file, or an empty state while it does
233+// not exist or is being replaced.
234+func readState(path string) statefile.State {
235+ st, err := statefile.Read(path)
236+ if err != nil {
237+ return statefile.State{}
238+ }
239+ return st
240+}
241+
242+// tileRect finds the rectangle of window id in the tiled columns.
243+func tileRect(st statefile.State, id uint64) (image.Rectangle, bool) {
244+ for _, ws := range st.Workspaces {
245+ for _, c := range ws.Columns {
246+ for _, w := range c.Windows {
247+ if w.ID == id {
248+ return image.Rect(w.X, w.Y, w.X+w.Width, w.Y+w.Height), true
249+ }
250+ }
251+ }
252+ }
253+ return image.Rectangle{}, false
254+}
255+
256+// greenShare is the fraction of pixels of r in the PNG at path that are
257+// clearly green; 0 when the image cannot be read yet.
258+func greenShare(path string, r image.Rectangle) float64 {
259+ f, err := os.Open(path)
260+ if err != nil {
261+ return 0
262+ }
263+ defer f.Close()
264+ img, err := png.Decode(f)
265+ if err != nil {
266+ return 0
267+ }
268+ r = r.Intersect(img.Bounds())
269+ if r.Empty() {
270+ return 0
271+ }
272+ n := 0
273+ for y := r.Min.Y; y < r.Max.Y; y++ {
274+ for x := r.Min.X; x < r.Max.X; x++ {
275+ cr, cg, cb, _ := img.At(x, y).RGBA()
276+ if cg>>8 > 200 && cr>>8 < 60 && cb>>8 < 60 {
277+ n++
278+ }
279+ }
280+ }
281+ return float64(n) / float64(r.Dx()*r.Dy())
282+}
@@ -0,0 +1,282 @@
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`.
7+package app
8+
9+import (
10+ "context"
11+ "image"
12+ "image/png"
13+ "io"
14+ "os"
15+ "os/exec"
16+ "path/filepath"
17+ "regexp"
18+ "strings"
19+ "sync"
20+ "testing"
21+ "time"
22+
23+ "github.com/bnema/zerowrap"
24+
25+ "github.com/bnema/neferwl/internal/adapters/config"
26+ "github.com/bnema/neferwl/internal/adapters/statefile"
27+ "github.com/bnema/neferwl/internal/ports"
28+)
29+
30+const (
31+ xwaylandTimeout = 10 * time.Second
32+ xwaylandW, xwaylandH = 640, 360
33+)
34+
35+var displayRe = regexp.MustCompile(`"DISPLAY":"(:\d+)"`)
36+
37+// probe is a running x11probe.
38+type probe struct {
39+ cmd *exec.Cmd
40+ out *lockedBuffer
41+ waited chan error
42+}
43+
44+// TestXwaylandProbe: an X11 client is managed by the built-in XWM. It shows
45+// up in the state with its class and pid, is drawn, tiles next to a second
46+// one, receives focus and is closed by the close-window bind.
47+func TestXwaylandProbe(t *testing.T) {
48+ if _, err := exec.LookPath("Xwayland"); err != nil {
49+ t.Skip("Xwayland not on PATH")
50+ }
51+ // The compositor must not read or write the live session's state.
52+ for _, k := range []string{"NEFERWL_STATE", "NEFERWL_SLOT"} {
53+ if v, ok := os.LookupEnv(k); ok {
54+ os.Unsetenv(k)
55+ t.Cleanup(func() { os.Setenv(k, v) })
56+ }
57+ }
58+ runtime := t.TempDir()
59+ t.Setenv("XDG_RUNTIME_DIR", runtime)
60+ t.Setenv("XDG_STATE_HOME", t.TempDir())
61+ shots := t.TempDir()
62+ bin := buildProbe(t)
63+
64+ script, scriptW := io.Pipe()
65+ logs := &lockedBuffer{}
66+ ctx, cancel := context.WithCancel(zerowrap.WithCtx(context.Background(), zerowrap.New(zerowrap.Config{Level: "info", Format: "json", Output: logs})))
67+ cfg := config.Defaults()
68+ cfg.Xwayland = ports.XwaylandConfig{Backend: ports.XwaylandBuiltin, Binary: "Xwayland"}
69+ done := make(chan error, 1)
70+ go func() {
71+ done <- Run(ctx, Options{
72+ Backend: "headless", NoTerminal: true, Config: cfg, Script: script,
73+ ScreenshotDir: shots, Sizes: [][2]int{{xwaylandW, xwaylandH}},
74+ })
75+ }()
76+ t.Cleanup(func() {
77+ if t.Failed() {
78+ t.Logf("compositor log:\n%s", logs.String())
79+ }
80+ cancel()
81+ _ = scriptW.Close()
82+ select {
83+ case err := <-done:
84+ if err != nil {
85+ t.Errorf("Run: %v", err)
86+ }
87+ case <-time.After(5 * time.Second):
88+ t.Error("Run did not stop")
89+ }
90+ })
91+
92+ var display string
93+ until(t, "X11 display in the log", func() bool {
94+ if m := displayRe.FindStringSubmatch(logs.String()); m != nil {
95+ display = m[1]
96+ return true
97+ }
98+ return false
99+ })
100+ statePath, err := statefile.Path(runtime, "wayland-1")
101+ if err != nil {
102+ t.Fatal(err)
103+ }
104+
105+ first := startProbe(t, bin, display, "-title", "one")
106+ until(t, "probe one mapped", func() bool { return strings.Contains(first.out.String(), "mapped ") })
107+
108+ var st statefile.State
109+ until(t, "one visible X11Probe window", func() bool {
110+ st = readState(statePath)
111+ return len(st.Windows) == 1 && st.Windows[0].AppID == "X11Probe" && st.Windows[0].Visible
112+ })
113+ if w := st.Windows[0]; w.PID != first.cmd.Process.Pid {
114+ t.Errorf("window pid %d, want %d", w.PID, first.cmd.Process.Pid)
115+ }
116+
117+ // The client draws green and the compositor shows it.
118+ id := st.Windows[0].ID
119+ until(t, "green window in latest.png", func() bool {
120+ r, ok := tileRect(readState(statePath), id)
121+ return ok && greenShare(filepath.Join(shots, "latest.png"), r) >= 0.5
122+ })
123+
124+ // Focus round trip: the compositor focused the window, the client saw it.
125+ until(t, "probe one focus", func() bool { return strings.Contains(first.out.String(), "focus") })
126+
127+ // A second probe tiles beside the first.
128+ second := startProbe(t, bin, display, "-title", "two")
129+ until(t, "two tiled windows", func() bool {
130+ st = readState(statePath)
131+ if len(st.Windows) != 2 {
132+ return false
133+ }
134+ for _, w := range st.Windows {
135+ if w.AppID != "X11Probe" || !w.Visible || w.Floating {
136+ return false
137+ }
138+ }
139+ return true
140+ })
141+ until(t, "probe two focus", func() bool { return strings.Contains(second.out.String(), "focus") })
142+
143+ // The close-window bind asks the focused (second) probe to close.
144+ if _, err := io.WriteString(scriptW, "key Super+q\n"); err != nil {
145+ t.Fatal(err)
146+ }
147+ select {
148+ case err := <-second.waited:
149+ if err != nil {
150+ t.Errorf("probe two exited with %v, want 0", err)
151+ }
152+ case <-time.After(2 * time.Second):
153+ t.Fatal("probe two did not exit within 2s of Super+q")
154+ }
155+ until(t, "one window left", func() bool {
156+ st = readState(statePath)
157+ return len(st.Windows) == 1 && st.Windows[0].PID == first.cmd.Process.Pid
158+ })
159+ select {
160+ case err := <-first.waited:
161+ t.Fatalf("probe one exited (%v) though only the focused probe was closed", err)
162+ default:
163+ }
164+}
165+
166+// buildProbe builds testdata/x11probe.
167+func buildProbe(t *testing.T) string {
168+ t.Helper()
169+ bin := filepath.Join(t.TempDir(), "x11probe")
170+ build := exec.Command("go", "build", "-o", bin, "./testdata/x11probe")
171+ build.Env = append(os.Environ(), "CGO_ENABLED=0")
172+ if out, err := build.CombinedOutput(); err != nil {
173+ t.Fatalf("build x11probe: %v: %s", err, out)
174+ }
175+ return bin
176+}
177+
178+// startProbe starts x11probe on display, with X11 as its only display
179+// server, and stops it when the test ends.
180+func startProbe(t *testing.T, bin, display string, args ...string) *probe {
181+ t.Helper()
182+ p := &probe{out: &lockedBuffer{}, waited: make(chan error, 1)}
183+ p.cmd = exec.Command(bin, args...)
184+ p.cmd.Env = append(envWithout("DISPLAY", "WAYLAND_DISPLAY", "NEFERWL_STATE", "NEFERWL_SLOT"), "DISPLAY="+display)
185+ p.cmd.Stdout = p.out
186+ stderr := &lockedBuffer{}
187+ p.cmd.Stderr = stderr
188+ if err := p.cmd.Start(); err != nil {
189+ t.Fatal(err)
190+ }
191+ var exited sync.Once
192+ go func() { p.waited <- p.cmd.Wait() }()
193+ t.Cleanup(func() {
194+ exited.Do(func() {
195+ _ = p.cmd.Process.Kill()
196+ select {
197+ case <-p.waited:
198+ case <-time.After(2 * time.Second):
199+ }
200+ })
201+ if s := stderr.String(); s != "" {
202+ t.Logf("x11probe stderr: %s", s)
203+ }
204+ })
205+ return p
206+}
207+
208+func envWithout(keys ...string) []string {
209+ var env []string
210+next:
211+ for _, kv := range os.Environ() {
212+ for _, k := range keys {
213+ if strings.HasPrefix(kv, k+"=") {
214+ continue next
215+ }
216+ }
217+ env = append(env, kv)
218+ }
219+ return env
220+}
221+
222+// until polls cond every 20 ms and fails the test after xwaylandTimeout.
223+func until(t *testing.T, what string, cond func() bool) {
224+ t.Helper()
225+ for deadline := time.Now().Add(xwaylandTimeout); !cond(); time.Sleep(20 * time.Millisecond) {
226+ if time.Now().After(deadline) {
227+ t.Fatalf("timeout waiting for %s", what)
228+ }
229+ }
230+}
231+
232+// readState returns the current state file, or an empty state while it does
233+// not exist or is being replaced.
234+func readState(path string) statefile.State {
235+ st, err := statefile.Read(path)
236+ if err != nil {
237+ return statefile.State{}
238+ }
239+ return st
240+}
241+
242+// tileRect finds the rectangle of window id in the tiled columns.
243+func tileRect(st statefile.State, id uint64) (image.Rectangle, bool) {
244+ for _, ws := range st.Workspaces {
245+ for _, c := range ws.Columns {
246+ for _, w := range c.Windows {
247+ if w.ID == id {
248+ return image.Rect(w.X, w.Y, w.X+w.Width, w.Y+w.Height), true
249+ }
250+ }
251+ }
252+ }
253+ return image.Rectangle{}, false
254+}
255+
256+// greenShare is the fraction of pixels of r in the PNG at path that are
257+// clearly green; 0 when the image cannot be read yet.
258+func greenShare(path string, r image.Rectangle) float64 {
259+ f, err := os.Open(path)
260+ if err != nil {
261+ return 0
262+ }
263+ defer f.Close()
264+ img, err := png.Decode(f)
265+ if err != nil {
266+ return 0
267+ }
268+ r = r.Intersect(img.Bounds())
269+ if r.Empty() {
270+ return 0
271+ }
272+ n := 0
273+ for y := r.Min.Y; y < r.Max.Y; y++ {
274+ for x := r.Min.X; x < r.Max.X; x++ {
275+ cr, cg, cb, _ := img.At(x, y).RGBA()
276+ if cg>>8 > 200 && cr>>8 < 60 && cb>>8 < 60 {
277+ n++
278+ }
279+ }
280+ }
281+ return float64(n) / float64(r.Dx()*r.Dy())
282+}

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 25

Tags 10

↑↓ moveenter openesc close