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