detached 543bef2

test(examples): headless NeferWL tests for the layer and lock examples

commit
543bef230b5ba331758bb347c1f017c2ada7b943
author
brice · · 2026-10-01 19:13 UTC
parent
b46ec2f
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
1 file · +221 -0
A examples/headless_test.go +221 -0
@@ -0,0 +1,221 @@
1+package examples_test
2+
3+import (
4+ "bufio"
5+ "context"
6+ "fmt"
7+ "image"
8+ "image/png"
9+ "io"
10+ "os"
11+ "os/exec"
12+ "path/filepath"
13+ "strings"
14+ "syscall"
15+ "testing"
16+ "time"
17+)
18+
19+// The tests run the example programs against a NeferWL compositor started
20+// headless in an isolated runtime directory, and are skipped unless
21+// NEFERCLIENT_HEADLESS names its binary. The lock example only ever talks to
22+// that nested compositor: the child environment is stripped of the caller's
23+// session variables, and the socket is checked to live in the temporary
24+// directory. Never point these tests at a real session.
25+
26+var envDrop = []string{"DISPLAY=", "WAYLAND_DISPLAY=", "WAYLAND_SOCKET=", "NOTIFY_SOCKET=", "DBUS_SESSION_BUS_ADDRESS=",
27+ "XDG_RUNTIME_DIR=", "XDG_CONFIG_HOME=", "XDG_DATA_HOME=", "XDG_STATE_HOME=", "XDG_SESSION_", "XDG_VTNR=", "XDG_SEAT=",
28+ "XDG_CURRENT_DESKTOP=", "NIRI_SOCKET=", "SWAYSOCK=", "HYPRLAND_INSTANCE_SIGNATURE=", "NEFERWL_"}
29+
30+func cleanEnv() []string {
31+ var out []string
32+next:
33+ for _, e := range os.Environ() {
34+ for _, p := range envDrop {
35+ if strings.HasPrefix(e, p) {
36+ continue next
37+ }
38+ }
39+ out = append(out, e)
40+ }
41+ return out
42+}
43+
44+func must(t *testing.T, err error, what ...any) {
45+ t.Helper()
46+ if err != nil {
47+ t.Fatalf("%v: %v", fmt.Sprint(what...), err)
48+ }
49+}
50+
51+type compositor struct {
52+ socket, shots string
53+ env []string
54+ input io.WriteCloser // NeferWL input script (stdin)
55+}
56+
57+// startHeadless runs NeferWL and returns once its socket exists.
58+func startHeadless(t *testing.T) *compositor {
59+ t.Helper()
60+ bin := os.Getenv("NEFERCLIENT_HEADLESS")
61+ if bin == "" {
62+ t.Skip("set NEFERCLIENT_HEADLESS to a NeferWL binary")
63+ }
64+ bin, err := filepath.Abs(bin)
65+ must(t, err)
66+ root := t.TempDir()
67+ dirs := map[string]string{}
68+ for _, name := range []string{"run", "config", "data", "state", "shots"} {
69+ dirs[name] = filepath.Join(root, name)
70+ must(t, os.Mkdir(dirs[name], 0o700))
71+ }
72+ logf, err := os.Create(filepath.Join(root, "neferwl.log"))
73+ must(t, err)
74+ t.Cleanup(func() { _ = logf.Close() })
75+ env := append(cleanEnv(), "XDG_RUNTIME_DIR="+dirs["run"], "XDG_CONFIG_HOME="+dirs["config"],
76+ "XDG_DATA_HOME="+dirs["data"], "XDG_STATE_HOME="+dirs["state"])
77+ cmd := exec.Command(bin, "--backend=headless", "--no-terminal", "--no-xwayland", "--size", "640x480",
78+ "--timeout", "60s", "--screenshot", dirs["shots"], "--input", "-")
79+ cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
80+ cmd.Env = env
81+ cmd.Stdout, cmd.Stderr = logf, logf
82+ stdin, err := cmd.StdinPipe()
83+ must(t, err)
84+ must(t, cmd.Start())
85+ exited := make(chan struct{})
86+ go func() { _ = cmd.Wait(); close(exited) }()
87+ t.Cleanup(func() {
88+ _ = stdin.Close()
89+ _ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
90+ <-exited
91+ })
92+ deadline := time.Now().Add(15 * time.Second)
93+ for {
94+ paths, _ := filepath.Glob(filepath.Join(dirs["run"], "wayland-*"))
95+ for _, p := range paths {
96+ if info, err := os.Stat(p); err == nil && info.Mode()&os.ModeSocket != 0 {
97+ if !strings.HasPrefix(p, root) {
98+ t.Fatalf("socket %s is outside the test directory", p)
99+ }
100+ return &compositor{socket: p, shots: dirs["shots"], env: env, input: stdin}
101+ }
102+ }
103+ select {
104+ case <-exited:
105+ b, _ := os.ReadFile(logf.Name())
106+ t.Fatalf("neferwl exited before serving:\n%s", b)
107+ case <-time.After(5 * time.Millisecond):
108+ }
109+ if time.Now().After(deadline) {
110+ b, _ := os.ReadFile(logf.Name())
111+ t.Fatalf("headless socket timeout:\n%s", b)
112+ }
113+ }
114+}
115+
116+// build compiles one example into a temporary directory.
117+func build(t *testing.T, pkg string) string {
118+ t.Helper()
119+ exe := filepath.Join(t.TempDir(), filepath.Base(pkg))
120+ out, err := exec.Command("go", "build", "-o", exe, pkg).CombinedOutput()
121+ must(t, err, string(out))
122+ return exe
123+}
124+
125+// command prepares an example to run against c, and only against c.
126+func (c *compositor) command(ctx context.Context, exe string, args ...string) *exec.Cmd {
127+ cmd := exec.CommandContext(ctx, exe, args...)
128+ cmd.Env = append(c.env, "WAYLAND_DISPLAY="+c.socket)
129+ return cmd
130+}
131+
132+func near(a, b uint32) bool {
133+ d := int(a>>8) - int(b>>8)
134+ return d >= -2 && d <= 2
135+}
136+
137+// hasColor reports whether the PNG has the (r, g, b) colour, tolerance 2 per
138+// channel, at (x, y).
139+func hasColor(t *testing.T, path string, x, y int, r, g, b uint8) bool {
140+ t.Helper()
141+ f, err := os.Open(path)
142+ must(t, err)
143+ defer f.Close()
144+ img, err := png.Decode(f)
145+ if err != nil { // a frame being written when we look
146+ return false
147+ }
148+ if !image.Pt(x, y).In(img.Bounds()) {
149+ return false
150+ }
151+ pr, pg, pb, _ := img.At(x, y).RGBA()
152+ return near(pr, uint32(r)<<8) && near(pg, uint32(g)<<8) && near(pb, uint32(b)<<8)
153+}
154+
155+func TestHeadlessLayer(t *testing.T) {
156+ c := startHeadless(t)
157+ exe := build(t, "./layer")
158+ ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
159+ defer cancel()
160+ cmd := c.command(ctx, exe, "-frames", "20")
161+ out, err := cmd.CombinedOutput()
162+ must(t, err, string(out))
163+
164+ // The button is 200x40 logical pixels at (10, 40) of a surface placed at
165+ // the output's top-left corner, filled with #2060c0. Sample near its
166+ // bottom-right corner, away from the label. The program may already have
167+ // exited, so look through every screenshot taken while it was mapped.
168+ deadline := time.Now().Add(10 * time.Second)
169+ for {
170+ shots, _ := filepath.Glob(filepath.Join(c.shots, "*.png"))
171+ for _, p := range shots {
172+ if hasColor(t, p, 200, 75, 0x20, 0x60, 0xc0) {
173+ t.Logf("button colour found in %s", filepath.Base(p))
174+ return
175+ }
176+ }
177+ if time.Now().After(deadline) {
178+ t.Fatalf("no screenshot (of %d) has the button colour at (200, 75)", len(shots))
179+ }
180+ time.Sleep(50 * time.Millisecond)
181+ }
182+}
183+
184+func TestHeadlessLock(t *testing.T) {
185+ c := startHeadless(t)
186+ exe := build(t, "./lock")
187+ ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
188+ defer cancel()
189+ cmd := c.command(ctx, exe)
190+ stdout, err := cmd.StdoutPipe()
191+ must(t, err)
192+ var stderr strings.Builder
193+ cmd.Stderr = &stderr
194+ must(t, cmd.Start())
195+ lines := make(chan string, 8)
196+ go func() {
197+ sc := bufio.NewScanner(stdout)
198+ for sc.Scan() {
199+ lines <- sc.Text()
200+ }
201+ close(lines)
202+ }()
203+ expect := func(want string) {
204+ t.Helper()
205+ select {
206+ case l := <-lines:
207+ if l != want {
208+ t.Fatalf("got %q, want %q: %s", l, want, stderr.String())
209+ }
210+ case <-ctx.Done():
211+ t.Fatalf("timeout waiting for %q: %s", want, stderr.String())
212+ }
213+ }
214+ expect("locked")
215+ // Only the nested compositor receives this input; it types into the
216+ // password field (shown masked) and presses Enter.
217+ _, err = io.WriteString(c.input, "type abc\nsleep 200ms\nkey Return\n")
218+ must(t, err)
219+ expect("unlocked")
220+ must(t, cmd.Wait(), stderr.String())
221+}
@@ -0,0 +1,221 @@
1+package examples_test
2+
3+import (
4+ "bufio"
5+ "context"
6+ "fmt"
7+ "image"
8+ "image/png"
9+ "io"
10+ "os"
11+ "os/exec"
12+ "path/filepath"
13+ "strings"
14+ "syscall"
15+ "testing"
16+ "time"
17+)
18+
19+// The tests run the example programs against a NeferWL compositor started
20+// headless in an isolated runtime directory, and are skipped unless
21+// NEFERCLIENT_HEADLESS names its binary. The lock example only ever talks to
22+// that nested compositor: the child environment is stripped of the caller's
23+// session variables, and the socket is checked to live in the temporary
24+// directory. Never point these tests at a real session.
25+
26+var envDrop = []string{"DISPLAY=", "WAYLAND_DISPLAY=", "WAYLAND_SOCKET=", "NOTIFY_SOCKET=", "DBUS_SESSION_BUS_ADDRESS=",
27+ "XDG_RUNTIME_DIR=", "XDG_CONFIG_HOME=", "XDG_DATA_HOME=", "XDG_STATE_HOME=", "XDG_SESSION_", "XDG_VTNR=", "XDG_SEAT=",
28+ "XDG_CURRENT_DESKTOP=", "NIRI_SOCKET=", "SWAYSOCK=", "HYPRLAND_INSTANCE_SIGNATURE=", "NEFERWL_"}
29+
30+func cleanEnv() []string {
31+ var out []string
32+next:
33+ for _, e := range os.Environ() {
34+ for _, p := range envDrop {
35+ if strings.HasPrefix(e, p) {
36+ continue next
37+ }
38+ }
39+ out = append(out, e)
40+ }
41+ return out
42+}
43+
44+func must(t *testing.T, err error, what ...any) {
45+ t.Helper()
46+ if err != nil {
47+ t.Fatalf("%v: %v", fmt.Sprint(what...), err)
48+ }
49+}
50+
51+type compositor struct {
52+ socket, shots string
53+ env []string
54+ input io.WriteCloser // NeferWL input script (stdin)
55+}
56+
57+// startHeadless runs NeferWL and returns once its socket exists.
58+func startHeadless(t *testing.T) *compositor {
59+ t.Helper()
60+ bin := os.Getenv("NEFERCLIENT_HEADLESS")
61+ if bin == "" {
62+ t.Skip("set NEFERCLIENT_HEADLESS to a NeferWL binary")
63+ }
64+ bin, err := filepath.Abs(bin)
65+ must(t, err)
66+ root := t.TempDir()
67+ dirs := map[string]string{}
68+ for _, name := range []string{"run", "config", "data", "state", "shots"} {
69+ dirs[name] = filepath.Join(root, name)
70+ must(t, os.Mkdir(dirs[name], 0o700))
71+ }
72+ logf, err := os.Create(filepath.Join(root, "neferwl.log"))
73+ must(t, err)
74+ t.Cleanup(func() { _ = logf.Close() })
75+ env := append(cleanEnv(), "XDG_RUNTIME_DIR="+dirs["run"], "XDG_CONFIG_HOME="+dirs["config"],
76+ "XDG_DATA_HOME="+dirs["data"], "XDG_STATE_HOME="+dirs["state"])
77+ cmd := exec.Command(bin, "--backend=headless", "--no-terminal", "--no-xwayland", "--size", "640x480",
78+ "--timeout", "60s", "--screenshot", dirs["shots"], "--input", "-")
79+ cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
80+ cmd.Env = env
81+ cmd.Stdout, cmd.Stderr = logf, logf
82+ stdin, err := cmd.StdinPipe()
83+ must(t, err)
84+ must(t, cmd.Start())
85+ exited := make(chan struct{})
86+ go func() { _ = cmd.Wait(); close(exited) }()
87+ t.Cleanup(func() {
88+ _ = stdin.Close()
89+ _ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
90+ <-exited
91+ })
92+ deadline := time.Now().Add(15 * time.Second)
93+ for {
94+ paths, _ := filepath.Glob(filepath.Join(dirs["run"], "wayland-*"))
95+ for _, p := range paths {
96+ if info, err := os.Stat(p); err == nil && info.Mode()&os.ModeSocket != 0 {
97+ if !strings.HasPrefix(p, root) {
98+ t.Fatalf("socket %s is outside the test directory", p)
99+ }
100+ return &compositor{socket: p, shots: dirs["shots"], env: env, input: stdin}
101+ }
102+ }
103+ select {
104+ case <-exited:
105+ b, _ := os.ReadFile(logf.Name())
106+ t.Fatalf("neferwl exited before serving:\n%s", b)
107+ case <-time.After(5 * time.Millisecond):
108+ }
109+ if time.Now().After(deadline) {
110+ b, _ := os.ReadFile(logf.Name())
111+ t.Fatalf("headless socket timeout:\n%s", b)
112+ }
113+ }
114+}
115+
116+// build compiles one example into a temporary directory.
117+func build(t *testing.T, pkg string) string {
118+ t.Helper()
119+ exe := filepath.Join(t.TempDir(), filepath.Base(pkg))
120+ out, err := exec.Command("go", "build", "-o", exe, pkg).CombinedOutput()
121+ must(t, err, string(out))
122+ return exe
123+}
124+
125+// command prepares an example to run against c, and only against c.
126+func (c *compositor) command(ctx context.Context, exe string, args ...string) *exec.Cmd {
127+ cmd := exec.CommandContext(ctx, exe, args...)
128+ cmd.Env = append(c.env, "WAYLAND_DISPLAY="+c.socket)
129+ return cmd
130+}
131+
132+func near(a, b uint32) bool {
133+ d := int(a>>8) - int(b>>8)
134+ return d >= -2 && d <= 2
135+}
136+
137+// hasColor reports whether the PNG has the (r, g, b) colour, tolerance 2 per
138+// channel, at (x, y).
139+func hasColor(t *testing.T, path string, x, y int, r, g, b uint8) bool {
140+ t.Helper()
141+ f, err := os.Open(path)
142+ must(t, err)
143+ defer f.Close()
144+ img, err := png.Decode(f)
145+ if err != nil { // a frame being written when we look
146+ return false
147+ }
148+ if !image.Pt(x, y).In(img.Bounds()) {
149+ return false
150+ }
151+ pr, pg, pb, _ := img.At(x, y).RGBA()
152+ return near(pr, uint32(r)<<8) && near(pg, uint32(g)<<8) && near(pb, uint32(b)<<8)
153+}
154+
155+func TestHeadlessLayer(t *testing.T) {
156+ c := startHeadless(t)
157+ exe := build(t, "./layer")
158+ ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
159+ defer cancel()
160+ cmd := c.command(ctx, exe, "-frames", "20")
161+ out, err := cmd.CombinedOutput()
162+ must(t, err, string(out))
163+
164+ // The button is 200x40 logical pixels at (10, 40) of a surface placed at
165+ // the output's top-left corner, filled with #2060c0. Sample near its
166+ // bottom-right corner, away from the label. The program may already have
167+ // exited, so look through every screenshot taken while it was mapped.
168+ deadline := time.Now().Add(10 * time.Second)
169+ for {
170+ shots, _ := filepath.Glob(filepath.Join(c.shots, "*.png"))
171+ for _, p := range shots {
172+ if hasColor(t, p, 200, 75, 0x20, 0x60, 0xc0) {
173+ t.Logf("button colour found in %s", filepath.Base(p))
174+ return
175+ }
176+ }
177+ if time.Now().After(deadline) {
178+ t.Fatalf("no screenshot (of %d) has the button colour at (200, 75)", len(shots))
179+ }
180+ time.Sleep(50 * time.Millisecond)
181+ }
182+}
183+
184+func TestHeadlessLock(t *testing.T) {
185+ c := startHeadless(t)
186+ exe := build(t, "./lock")
187+ ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
188+ defer cancel()
189+ cmd := c.command(ctx, exe)
190+ stdout, err := cmd.StdoutPipe()
191+ must(t, err)
192+ var stderr strings.Builder
193+ cmd.Stderr = &stderr
194+ must(t, cmd.Start())
195+ lines := make(chan string, 8)
196+ go func() {
197+ sc := bufio.NewScanner(stdout)
198+ for sc.Scan() {
199+ lines <- sc.Text()
200+ }
201+ close(lines)
202+ }()
203+ expect := func(want string) {
204+ t.Helper()
205+ select {
206+ case l := <-lines:
207+ if l != want {
208+ t.Fatalf("got %q, want %q: %s", l, want, stderr.String())
209+ }
210+ case <-ctx.Done():
211+ t.Fatalf("timeout waiting for %q: %s", want, stderr.String())
212+ }
213+ }
214+ expect("locked")
215+ // Only the nested compositor receives this input; it types into the
216+ // password field (shown masked) and presses Enter.
217+ _, err = io.WriteString(c.input, "type abc\nsleep 200ms\nkey Return\n")
218+ must(t, err)
219+ expect("unlocked")
220+ must(t, cmd.Wait(), stderr.String())
221+}

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 1

Tags 4

↑↓ moveenter openesc close