1package wayland
2
3import (
4 "bytes"
5 "context"
6 "github.com/bnema/neferwl/internal/ports"
7 "os"
8 "os/exec"
9 "path/filepath"
10 "slices"
11 "strings"
12 "sync"
13 "testing"
14 "time"
15
16 "github.com/bnema/neferwl/internal/logging"
17)
18
19func TestWaylandInfo(t *testing.T) {
20 tool, err := exec.LookPath("wayland-info")
21 if err != nil {
22 t.Skip("wayland-info not installed")
23 }
24 dir := t.TempDir()
25 s, err := New(Options{RuntimeDir: dir, Outputs: testOutputs}, Channels{}, logging.For(context.Background(), "wayland"))
26 if err != nil {
27 t.Fatal(err)
28 }
29 ctx, cancel := context.WithCancel(context.Background())
30 done := make(chan error, 1)
31 go func() { done <- s.Run(ctx) }()
32 defer func() {
33 cancel()
34 select {
35 case err := <-done:
36 if err != nil {
37 t.Errorf("Run: %v", err)
38 }
39 case <-time.After(2 * time.Second):
40 t.Error("Run did not stop")
41 }
42 if _, err := os.Stat(filepath.Join(dir, s.SocketName())); !os.IsNotExist(err) {
43 t.Errorf("socket remains: %v", err)
44 }
45 }()
46 cmdCtx, stop := context.WithTimeout(context.Background(), 5*time.Second)
47 defer stop()
48 cmd := exec.CommandContext(cmdCtx, tool)
49 cmd.Env = append(os.Environ(), "XDG_RUNTIME_DIR="+dir, "WAYLAND_DISPLAY="+s.SocketName())
50 out, err := cmd.CombinedOutput()
51 if err != nil {
52 t.Fatalf("wayland-info: %v\n%s", err, out)
53 }
54 for _, want := range []string{"wl_compositor", "wl_shm", "wl_output", "wl_seat", "wl_data_device_manager", "HEADLESS-1"} {
55 if !strings.Contains(string(out), want) {
56 t.Errorf("missing %q in:\n%s", want, out)
57 }
58 }
59}
60
61func TestWestonFrames(t *testing.T) {
62 tool, err := exec.LookPath("weston-simple-shm")
63 if err != nil {
64 t.Skip("weston-simple-shm not installed")
65 }
66 dir := t.TempDir()
67 s, err := New(Options{RuntimeDir: dir, Outputs: testOutputs}, Channels{}, logging.For(context.Background(), "wayland"))
68 if err != nil {
69 t.Fatal(err)
70 }
71 ctx, cancel := context.WithCancel(context.Background())
72 done := make(chan error, 1)
73 go func() { done <- s.Run(ctx) }()
74 t.Cleanup(func() {
75 cancel()
76 select {
77 case err := <-done:
78 if err != nil {
79 t.Errorf("Run: %v", err)
80 }
81 case <-time.After(2 * time.Second):
82 t.Error("Run hung")
83 }
84 })
85 clientCtx, stop := context.WithCancel(context.Background())
86 cmd := exec.CommandContext(clientCtx, tool)
87 cmd.Env = append(os.Environ(), "XDG_RUNTIME_DIR="+dir, "WAYLAND_DISPLAY="+s.SocketName())
88 var stderr strings.Builder
89 cmd.Stderr = &stderr
90 if err := cmd.Start(); err != nil {
91 stop()
92 t.Fatal(err)
93 }
94 exited := make(chan error, 1)
95 go func() { exited <- cmd.Wait() }()
96 t.Cleanup(func() {
97 stop()
98 select {
99 case <-exited:
100 case <-time.After(2 * time.Second):
101 t.Error("client did not exit")
102 }
103 })
104 deadline := time.After(2 * time.Second)
105 tick := time.NewTicker(25 * time.Millisecond)
106 defer tick.Stop()
107 for {
108 select {
109 case err := <-exited:
110 t.Fatalf("client exited: %v: %s", err, stderr.String())
111 case <-deadline:
112 t.Fatalf("only %d frames; stderr: %s", s.frames, stderr.String())
113 case <-tick.C:
114 var n uint64
115 s.display.Do(func() { n = s.frames })
116 if n >= 10 {
117 return
118 }
119 }
120 }
121}
122
123// lifecycleServer starts a display with buffered command and event channels.
124func lifecycleServer(t *testing.T) (*Server, chan ports.ClientEvent, chan ports.ClientCommand, string) {
125 t.Helper()
126 dir := t.TempDir()
127 events := make(chan ports.ClientEvent, 16)
128 commands := make(chan ports.ClientCommand, 16)
129 s, err := New(Options{RuntimeDir: dir, Outputs: testOutputs}, Channels{Events: events, Commands: commands}, logging.For(context.Background(), "wayland"))
130 if err != nil {
131 t.Fatal(err)
132 }
133 ctx, cancel := context.WithCancel(context.Background())
134 done := make(chan error, 1)
135 go func() { done <- s.Run(ctx) }()
136 t.Cleanup(func() {
137 cancel()
138 select {
139 case err := <-done:
140 if err != nil {
141 t.Errorf("Run: %v", err)
142 }
143 case <-time.After(2 * time.Second):
144 t.Error("Run hung")
145 }
146 })
147 return s, events, commands, dir
148}
149
150func lifecycleClient(t *testing.T, tool string, s *Server, dir string, extra []string, args ...string) (*exec.Cmd, <-chan error, *lockedBuffer) {
151 t.Helper()
152 ctx, cancel := context.WithCancel(context.Background())
153 cmd := exec.CommandContext(ctx, tool, args...)
154 cmd.Env = append(append(os.Environ(), "XDG_RUNTIME_DIR="+dir, "WAYLAND_DISPLAY="+s.SocketName()), extra...)
155 output := &lockedBuffer{}
156 cmd.Stderr = output
157 if err := cmd.Start(); err != nil {
158 cancel()
159 t.Fatal(err)
160 }
161 done := make(chan error, 1)
162 finished := make(chan struct{})
163 go func() { done <- cmd.Wait(); close(finished) }()
164 t.Cleanup(func() {
165 cancel()
166 _ = cmd.Process.Kill()
167 select {
168 case <-finished:
169 case <-time.After(2 * time.Second):
170 t.Error("client hung")
171 }
172 })
173 return cmd, done, output
174}
175
176type lockedBuffer struct {
177 mu sync.Mutex
178 b bytes.Buffer
179}
180
181func (b *lockedBuffer) Write(p []byte) (int, error) {
182 b.mu.Lock()
183 defer b.mu.Unlock()
184 return b.b.Write(p)
185}
186func (b *lockedBuffer) String() string { b.mu.Lock(); defer b.mu.Unlock(); return b.b.String() }
187
188func waitEvent(t *testing.T, events <-chan ports.ClientEvent, duration time.Duration) ports.ClientEvent {
189 t.Helper()
190 select {
191 case ev := <-events:
192 return ev
193 case <-time.After(duration):
194 t.Fatal("event timeout")
195 return nil
196 }
197}
198func mapped(t *testing.T, events <-chan ports.ClientEvent, duration time.Duration) ports.WindowMapped {
199 t.Helper()
200 ev := waitEvent(t, events, duration)
201 w, ok := ev.(ports.WindowMapped)
202 if !ok {
203 t.Fatalf("expected mapped, got %T", ev)
204 }
205 return w
206}
207func unmapped(t *testing.T, events <-chan ports.ClientEvent, id ports.WindowID) {
208 t.Helper()
209 ev := waitEvent(t, events, 3*time.Second)
210 w, ok := ev.(ports.WindowUnmapped)
211 if !ok || w.ID != id {
212 t.Fatalf("expected unmapped %d, got %#v", id, ev)
213 }
214}
215
216func TestWindowClose(t *testing.T) {
217 tool, err := exec.LookPath("weston-simple-shm")
218 if err != nil {
219 t.Skip("weston-simple-shm not installed")
220 }
221 s, events, commands, dir := lifecycleServer(t)
222 _, done, output := lifecycleClient(t, tool, s, dir, nil)
223 w := mapped(t, events, 5*time.Second)
224 commands <- ports.CloseWindow{ID: w.ID}
225 select {
226 case err := <-done:
227 if err != nil {
228 t.Errorf("client: %v: %s", err, output.String())
229 }
230 case <-time.After(5 * time.Second):
231 t.Fatal("client did not close")
232 }
233 unmapped(t, events, w.ID)
234}
235func TestWindowConfigure(t *testing.T) {
236 tool, err := exec.LookPath("weston-simple-shm")
237 if err != nil {
238 t.Skip("weston-simple-shm not installed")
239 }
240 s, events, commands, dir := lifecycleServer(t)
241 _, _, output := lifecycleClient(t, tool, s, dir, []string{"WAYLAND_DEBUG=client"})
242 w := mapped(t, events, 5*time.Second)
243 commands <- ports.ConfigureWindow{ID: w.ID, Width: 640, Height: 480, Activated: true}
244 deadline := time.After(3 * time.Second)
245 tick := time.NewTicker(10 * time.Millisecond)
246 defer tick.Stop()
247 for {
248 select {
249 case <-deadline:
250 t.Fatalf("missing configure(640, 480: %s", output.String())
251 case <-tick.C:
252 if strings.Contains(output.String(), "configure(640, 480") {
253 return
254 }
255 }
256 }
257}
258func TestWindowKilled(t *testing.T) {
259 tool, err := exec.LookPath("weston-simple-shm")
260 if err != nil {
261 t.Skip("weston-simple-shm not installed")
262 }
263 s, events, _, dir := lifecycleServer(t)
264 cmd, _, _ := lifecycleClient(t, tool, s, dir, nil)
265 w := mapped(t, events, 5*time.Second)
266 if err := cmd.Process.Kill(); err != nil {
267 t.Fatal(err)
268 }
269 unmapped(t, events, w.ID)
270}
271func TestFootWindow(t *testing.T) {
272 tool, err := exec.LookPath("foot")
273 if err != nil {
274 t.Skip("foot not installed")
275 }
276 s, events, _, dir := lifecycleServer(t)
277 _, done, output := lifecycleClient(t, tool, s, dir, []string{"HOME=" + t.TempDir(), "PATH=" + os.Getenv("PATH")}, "-c", "/dev/null", "sh", "-c", "sleep 3")
278 select {
279 case ev := <-events:
280 w, ok := ev.(ports.WindowMapped)
281 if !ok {
282 t.Fatalf("expected mapped, got %T", ev)
283 }
284 if w.AppID != "foot" {
285 t.Fatalf("app ID: %q", w.AppID)
286 }
287 case err := <-done:
288 t.Fatalf("foot exited: %v: %s", err, output.String())
289 case <-time.After(10 * time.Second):
290 t.Fatalf("foot did not map: %s", output.String())
291 }
292}
293
294func TestNewMissingRuntimeDirClosesDisplay(t *testing.T) {
295 before, err := os.ReadDir("/proc/self/fd")
296 if err != nil {
297 t.Skip(err)
298 }
299 dir := filepath.Join(t.TempDir(), "missing")
300 if _, err := New(Options{RuntimeDir: dir}, Channels{}, logging.For(context.Background(), "wayland")); err == nil {
301 t.Fatal("expected error")
302 }
303 after, err := os.ReadDir("/proc/self/fd")
304 if err != nil {
305 t.Fatal(err)
306 }
307 if len(after) != len(before) {
308 t.Fatalf("fd leak: %d -> %d", len(before), len(after))
309 }
310 if _, err := os.Stat(dir); !os.IsNotExist(err) {
311 t.Fatalf("unexpected directory: %v", err)
312 }
313}
314
315func TestUnbufferedEventsDoNotBlockDisplay(t *testing.T) {
316 tool, err := exec.LookPath("weston-simple-shm")
317 if err != nil {
318 t.Skip("weston-simple-shm not installed")
319 }
320 dir := t.TempDir()
321 events := make(chan ports.ClientEvent)
322 s, err := New(Options{RuntimeDir: dir, Outputs: testOutputs}, Channels{Events: events}, logging.For(context.Background(), "wayland"))
323 if err != nil {
324 t.Fatal(err)
325 }
326 ctx, cancel := context.WithCancel(context.Background())
327 done := make(chan error, 1)
328 go func() { done <- s.Run(ctx) }()
329 t.Cleanup(func() {
330 cancel()
331 select {
332 case err := <-done:
333 if err != nil {
334 t.Error(err)
335 }
336 case <-time.After(2 * time.Second):
337 t.Error("Run hung")
338 }
339 })
340 lifecycleClient(t, tool, s, dir, nil)
341 deadline := time.After(5 * time.Second)
342 for {
343 ready := make(chan bool, 1)
344 go func() { ready <- s.display.Do(func() {}) }()
345 select {
346 case ok := <-ready:
347 if !ok {
348 t.Fatal("display stopped")
349 }
350 case <-time.After(time.Second):
351 t.Fatal("display blocked on event")
352 }
353 mappedOnDisplay := false
354 if !s.display.Do(func() { mappedOnDisplay = len(s.windows) > 0 }) {
355 t.Fatal("display stopped")
356 }
357 if mappedOnDisplay {
358 break
359 }
360 select {
361 case <-deadline:
362 t.Fatal("no mapping event")
363 case <-time.After(10 * time.Millisecond):
364 }
365 }
366 mapped(t, events, time.Second)
367}
368
369func TestWestonContents(t *testing.T) {
370 tool, err := exec.LookPath("weston-simple-shm")
371 if err != nil {
372 t.Skip("weston-simple-shm not installed")
373 }
374 dir := t.TempDir()
375 events := make(chan ports.ClientEvent, 16)
376 contents := make(chan ports.SurfaceContent, 16)
377 s, err := New(Options{RuntimeDir: dir, Outputs: testOutputs}, Channels{Events: events, Contents: contents}, logging.For(context.Background(), "wayland"))
378 if err != nil {
379 t.Fatal(err)
380 }
381 ctx, cancel := context.WithCancel(context.Background())
382 done := make(chan error, 1)
383 go func() { done <- s.Run(ctx) }()
384 t.Cleanup(func() {
385 cancel()
386 select {
387 case err := <-done:
388 if err != nil {
389 t.Errorf("Run: %v", err)
390 }
391 case <-time.After(2 * time.Second):
392 t.Error("Run hung")
393 }
394 })
395 _, _, output := lifecycleClient(t, tool, s, dir, nil)
396 w := mapped(t, events, 5*time.Second)
397 first := time.After(5 * time.Second)
398 count := 0
399 for count < 2 {
400 select {
401 case c := <-contents:
402 if c.ID != w.ID || c.Width <= 0 || c.Height <= 0 || c.SHM == nil || c.SHM.Stride < c.Width*4 || c.SHM.File == nil {
403 t.Fatalf("invalid content: %+v", c)
404 }
405 // The pool holds the client's pixels: read them like a renderer.
406 px := make([]byte, c.SHM.Stride)
407 if _, err := c.SHM.File.ReadAt(px, int64(c.SHM.Offset+c.Height/2*c.SHM.Stride)); err != nil {
408 t.Fatal(err)
409 }
410 if !slices.ContainsFunc(px, func(b byte) bool { return b != 0 }) {
411 t.Fatal("empty pixels")
412 }
413 if count == 0 {
414 first = time.After(2 * time.Second)
415 }
416 count++
417 case <-first:
418 t.Fatalf("only %d contents: %s", count, output.String())
419 }
420 }
421}
422
423// testOutputs is the 1920x1080 output tests start with.
424var testOutputs = ports.Layout{{Info: ports.OutputInfo{Name: "HEADLESS-1", Width: 1920, Height: 1080}, Width: 1920, Height: 1080, Scale: 1}}