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