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
178// waitButton waits until a screenshot shows the layer example's button: it is
179// 200x40 logical pixels at (10, 40) of a surface placed at the output's
180// top-left corner, filled with #2060c0. It samples near the bottom-right
181// corner, away from the label, and looks through every screenshot because the
182// program may already have exited.
183func waitButton(t *testing.T, c *compositor, detail func() string) {
184 t.Helper()
185 deadline := time.Now().Add(10 * time.Second)
186 for {
187 shots, _ := filepath.Glob(filepath.Join(c.shots, "*.png"))
188 for _, p := range shots {
189 if hasColor(t, p, 200, 75, 0x20, 0x60, 0xc0) {
190 return
191 }
192 }
193 if time.Now().After(deadline) {
194 t.Fatalf("no screenshot (of %d) has the button colour at (200, 75): %s", len(shots), detail())
195 }
196 time.Sleep(50 * time.Millisecond)
197 }
198}
199
200// lineReader reads the program's stdout line by line, copying it into all when
201// non-nil. expect fails the test unless the next line is want before ctx ends.
202func lineReader(ctx context.Context, t *testing.T, stdout io.Reader, all *syncBuffer, stderr *syncBuffer) (expect func(want string), lines <-chan string) {
203 ch := make(chan string, 8)
204 go func() {
205 defer close(ch)
206 sc := bufio.NewScanner(stdout)
207 for sc.Scan() {
208 if all != nil {
209 _, _ = all.Write(append(sc.Bytes(), '\n'))
210 }
211 select {
212 case ch <- sc.Text():
213 case <-ctx.Done(): // the test is over: do not block
214 return
215 }
216 }
217 }()
218 expect = func(want string) {
219 t.Helper()
220 select {
221 case l := <-ch:
222 if l != want {
223 t.Fatalf("got %q, want %q: %s", l, want, stderr.String())
224 }
225 case <-ctx.Done():
226 t.Fatalf("timeout waiting for %q: %s", want, stderr.String())
227 }
228 }
229 return expect, ch
230}
231
232func TestHeadlessLayer(t *testing.T) {
233 exe := build(t, "./layer")
234 c := startHeadless(t)
235 ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
236 defer cancel()
237 cmd := c.command(ctx, exe, "-frames", "20")
238 out, err := cmd.CombinedOutput()
239 must(t, err, string(out))
240 waitButton(t, c, func() string { return string(out) })
241}
242
243// TestHeadlessLayerInputRegion checks that the layer example forwards the
244// view's input rectangle to the compositor: NeferWL only delivers pointer
245// events to the surface inside the button, not over the label above it.
246func TestHeadlessLayerInputRegion(t *testing.T) {
247 exe := build(t, "./layer")
248 c := startHeadless(t)
249 ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
250 defer cancel()
251 cmd := c.command(ctx, exe, "-log-pointer") // runs until killed
252 stdout, err := cmd.StdoutPipe()
253 must(t, err)
254 var stderr syncBuffer
255 cmd.Stderr = &stderr
256 must(t, cmd.Start())
257 t.Cleanup(func() { cancel(); _ = cmd.Wait() })
258 expect, _ := lineReader(ctx, t, stdout, nil, &stderr)
259
260 // The region is committed with the first buffer, so once the button is
261 // visible in a screenshot the compositor knows it.
262 waitButton(t, c, stderr.String)
263
264 // (50, 20) is over the label, outside the input region: the surface sees
265 // nothing. (100, 60) is over the button: the first event the program
266 // prints is its enter, so the label position did not enter earlier.
267 // Back on the label, the pointer leaves the surface again.
268 _, err = io.WriteString(c.input, "move 50 20\nsleep 300ms\nmove 100 60\nsleep 300ms\nmove 50 20\n")
269 must(t, err)
270 expect("pointer enter 100 60")
271 expect("pointer leave")
272}
273
274func TestHeadlessLock(t *testing.T) {
275 exe := build(t, "./lock")
276 c := startHeadless(t)
277 ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
278 defer cancel()
279 cmd := c.command(ctx, exe)
280 stdout, err := cmd.StdoutPipe()
281 must(t, err)
282 var stderr syncBuffer
283 cmd.Stderr = &stderr
284 must(t, cmd.Start())
285 // Wait runs once: here, after the program is done, or from the cleanup if
286 // the test fails first (the context kill ends the process).
287 var waitOnce sync.Once
288 var waitErr error
289 wait := func() error {
290 waitOnce.Do(func() { waitErr = cmd.Wait() })
291 return waitErr
292 }
293 t.Cleanup(func() { cancel(); _ = wait() })
294 var all syncBuffer // everything the program printed on stdout
295 expect, lines := lineReader(ctx, t, stdout, &all, &stderr)
296 expect("locked")
297 expect("focused") // the keyboard reached a lock surface: typing is safe
298 // Only the nested compositor receives this input; it types into the
299 // password field (shown masked) and presses Enter.
300 _, err = io.WriteString(c.input, "type abc\nsleep 200ms\nkey Return\n")
301 must(t, err)
302 expect("unlocked")
303 for range lines { // drain until the program closes stdout
304 }
305 must(t, wait(), stderr.String())
306 // The typed text must not leak to the program's output.
307 if strings.Contains(all.String(), "abc") || strings.Contains(stderr.String(), "abc") {
308 t.Fatal("the typed text appears in the program output")
309 }
310}