1package app
2
3import (
4 "context"
5 "image/color"
6 "image/png"
7 "io"
8 "os"
9 "os/exec"
10 "path/filepath"
11 "strings"
12 "sync"
13 "testing"
14 "time"
15
16 "github.com/bnema/neferwl/internal/adapters/clock"
17 "github.com/bnema/neferwl/internal/adapters/config"
18 "github.com/bnema/neferwl/internal/adapters/logging"
19 "github.com/bnema/neferwl/internal/adapters/xkb"
20 "github.com/bnema/neferwl/internal/ports"
21)
22
23func TestHeadlessConfigReload(t *testing.T) {
24 t.Setenv("XDG_RUNTIME_DIR", t.TempDir())
25 dir := t.TempDir()
26 path := filepath.Join(dir, "config")
27 if err := os.WriteFile(path, []byte("background = #000000\n"), 0600); err != nil {
28 t.Fatal(err)
29 }
30 cfg, _, err := config.Load(path)
31 if err != nil {
32 t.Fatal(err)
33 }
34 shots := t.TempDir()
35 ctx, cancel := context.WithCancel(context.Background())
36 done := make(chan error, 1)
37 go func() {
38 done <- Run(ctx, Options{Backend: "headless", NoXwayland: true, Config: cfg, ConfigPath: path, NoTerminal: true, ScreenshotDir: shots})
39 }()
40 defer func() {
41 cancel()
42 select {
43 case err := <-done:
44 if err != nil {
45 t.Errorf("Run: %v", err)
46 }
47 case <-time.After(3 * time.Second):
48 t.Error("Run did not stop")
49 }
50 }()
51 latest := filepath.Join(shots, "latest.png")
52 waitColor := func(want color.RGBA, deadline time.Time) bool {
53 for time.Now().Before(deadline) {
54 f, err := os.Open(latest)
55 if err == nil {
56 img, decodeErr := png.Decode(f)
57 f.Close()
58 if decodeErr == nil {
59 r, g, b, _ := img.At(1900, 10).RGBA()
60 if uint8(r>>8) == want.R && uint8(g>>8) == want.G && uint8(b>>8) == want.B {
61 return true
62 }
63 }
64 }
65 select {
66 case err := <-done:
67 if err != nil && strings.Contains(strings.ToLower(err.Error()), "vulkan") {
68 t.Skipf("Vulkan unavailable: %v", err)
69 }
70 t.Fatalf("app exited early: %v", err)
71 default:
72 }
73 time.Sleep(20 * time.Millisecond)
74 }
75 return false
76 }
77 if !waitColor(color.RGBA{A: 255}, time.Now().Add(10*time.Second)) {
78 t.Fatal("first black frame missing")
79 }
80 if err := os.WriteFile(path, []byte("background = #ff0000\nkeyboard.layout = fr\n"), 0600); err != nil {
81 t.Fatal(err)
82 }
83 if !waitColor(color.RGBA{R: 255, A: 255}, time.Now().Add(10*time.Second)) {
84 t.Fatal("red frame missing after reload")
85 }
86}
87
88func TestQuitJoinsWorkers(t *testing.T) {
89 done := make(chan error, 1)
90 go func() {
91 done <- run(context.Background(), Options{Backend: "headless", NoXwayland: true, Config: config.Defaults(), NoTerminal: true}, func(input chan<- ports.InputEvent) {
92 input <- ports.SecurityInput{State: ports.SecurityState{}, Event: ports.KeyEvent{Keysym: "BackSpace", Mods: ports.ModCtrl | ports.ModAlt, Pressed: true}}
93 })
94 }()
95 select {
96 case err := <-done:
97 if err != nil {
98 t.Fatal(err)
99 }
100 case <-time.After(2 * time.Second):
101 t.Fatal("quit did not join workers")
102 }
103}
104
105// Animations off: the lone window is at its final rect in the first scene.
106func TestHeadlessSpawnClose(t *testing.T) { headlessSpawnClose(t, false) }
107
108// Animations on: the window appears (faded, 90 %), then settles at 1920.
109func TestHeadlessSpawnCloseAnimated(t *testing.T) { headlessSpawnClose(t, true) }
110
111func headlessSpawnClose(t *testing.T, animated bool) {
112 if _, err := exec.LookPath("weston-simple-shm"); err != nil {
113 t.Skip("weston-simple-shm not installed")
114 }
115 t.Setenv("XDG_RUNTIME_DIR", t.TempDir())
116 t.Setenv("XDG_STATE_HOME", t.TempDir())
117 cfg := config.Defaults()
118 cfg.Terminal.Command = []string{"weston-simple-shm"}
119 cfg.Animations.On = animated
120 scenes := make(chan []ports.Scene, 4096)
121 ctx, cancel := context.WithCancel(context.Background())
122 defer cancel()
123 inputReady := make(chan chan<- ports.InputEvent, 1)
124 done := make(chan error, 1)
125 go func() {
126 done <- run(ctx, Options{Backend: "headless", NoXwayland: true, NoTerminal: true, Config: cfg, testScenes: scenes}, func(input chan<- ports.InputEvent) { inputReady <- input })
127 }()
128 var input chan<- ports.InputEvent
129 select {
130 case input = <-inputReady:
131 case <-time.After(3 * time.Second):
132 t.Fatal("app did not start")
133 }
134 defer func() {
135 cancel()
136 select {
137 case err := <-done:
138 if err != nil {
139 t.Errorf("Run: %v", err)
140 }
141 case <-time.After(3 * time.Second):
142 t.Error("Run did not stop")
143 }
144 }()
145 input <- ports.SecurityInput{State: ports.SecurityState{}, Event: ports.KeyEvent{Keysym: "Return", Mods: ports.ModSuper, Pressed: true}}
146 waitScene := func(count int, timeout time.Duration) {
147 t.Helper()
148 timer := time.NewTimer(timeout)
149 defer timer.Stop()
150 for {
151 select {
152 case set := <-scenes:
153 s := set[0]
154 if len(s.Windows) == count {
155 if count == 1 && animated {
156 // The entrance runs first: wait for the settled scene.
157 if w := s.Windows[0]; w.Fade != 0 || w.Rect.W != 1920 {
158 continue
159 }
160 }
161 // A lone column fills the 1920 output (default zero gaps).
162 if count == 1 && s.Windows[0].Rect.W != 1920 {
163 t.Errorf("width = %d, want 1920", s.Windows[0].Rect.W)
164 }
165 return
166 }
167 case <-timer.C:
168 t.Fatalf("no scene with %d windows", count)
169 case err := <-done:
170 t.Fatalf("app exited early: %v", err)
171 }
172 }
173 }
174 waitScene(1, 10*time.Second)
175 input <- ports.SecurityInput{State: ports.SecurityState{}, Event: ports.KeyEvent{Keysym: "q", Mods: ports.ModSuper, Pressed: true}}
176 waitScene(0, 5*time.Second)
177}
178
179func TestHeadlessTyping(t *testing.T) {
180 if _, err := exec.LookPath("foot"); err != nil {
181 t.Skip("foot unavailable")
182 }
183 t.Setenv("HOME", t.TempDir())
184 t.Setenv("XDG_RUNTIME_DIR", t.TempDir())
185 t.Setenv("XDG_STATE_HOME", t.TempDir())
186 cfg := config.Defaults()
187 cfg.Terminal.Command = []string{"foot", "-c", "/dev/null", "sh"}
188 dir := t.TempDir()
189 err := Run(context.Background(), Options{Backend: "headless", NoXwayland: true, Config: cfg, ScreenshotDir: dir, Script: io.NopCloser(strings.NewReader("sleep 1.5s\ntype echo neferwl-ok\nkey Return\nsleep 1s\n")), Timeout: 6 * time.Second})
190 if err != nil {
191 if strings.Contains(err.Error(), "Vulkan") || strings.Contains(err.Error(), "vulkan") {
192 t.Skipf("Vulkan unavailable: %v", err)
193 }
194 t.Fatal(err)
195 }
196 path := filepath.Join(dir, "latest.png")
197 f, err := os.Open(path)
198 if err != nil {
199 t.Fatal(err)
200 }
201 defer f.Close()
202 img, err := png.Decode(f)
203 if err != nil {
204 t.Fatal(err)
205 }
206 // The lone column fills the output; the typed text sits in its top-left area.
207 first := img.At(1500, 600)
208 varied := false
209 for y := 0; y < 400 && !varied; y += 2 {
210 for x := 0; x < 900; x += 2 {
211 if img.At(x, y) != first {
212 varied = true
213 break
214 }
215 }
216 }
217 if !varied {
218 t.Fatal("window image is uniform")
219 }
220 if os.Getenv("NEFERWL_KEEP_SHOTS") == "1" {
221 data, err := os.ReadFile(path)
222 if err != nil {
223 t.Fatal(err)
224 }
225 if err := os.WriteFile("/tmp/neferwl-typing.png", data, 0600); err != nil {
226 t.Fatal(err)
227 }
228 }
229}
230
231func TestHeadlessPointerClickFocus(t *testing.T) { headlessPointerClickFocus(t, false) }
232
233// Animations on: the maps' frames and the settled scenes are all tapped; the
234// click still lands on window 1 once the second column settled.
235func TestHeadlessPointerClickFocusAnimated(t *testing.T) { headlessPointerClickFocus(t, true) }
236
237func headlessPointerClickFocus(t *testing.T, animated bool) {
238 if _, err := exec.LookPath("foot"); err != nil {
239 t.Skip("foot unavailable")
240 }
241 t.Setenv("HOME", t.TempDir())
242 t.Setenv("XDG_RUNTIME_DIR", t.TempDir())
243 t.Setenv("XDG_STATE_HOME", t.TempDir())
244 cfg := config.Defaults()
245 cfg.Terminal.Command = []string{"foot", "-c", "/dev/null", "sh"}
246 cfg.Animations.On = animated
247 // The tap drops a scene it has no room for: a collector keeps up with
248 // the animation frames.
249 scenes := make(chan []ports.Scene, 128)
250 var got [][]ports.Scene
251 stop := make(chan struct{})
252 collected := make(chan struct{})
253 go func() {
254 defer close(collected)
255 for {
256 select {
257 case set := <-scenes:
258 got = append(got, set)
259 case <-stop:
260 return
261 }
262 }
263 }()
264 // Stopped before the scenes are read, or at the latest when the test
265 // ends (an early Fatal or Skip).
266 var halt sync.Once
267 stopCollector := func() { halt.Do(func() { close(stop); <-collected }) }
268 t.Cleanup(stopCollector)
269 // The script ends with the default quit bind: the run stops when it
270 // is done, the timeout is only a backstop.
271 err := Run(context.Background(), Options{Backend: "headless", NoXwayland: true, Config: cfg, Script: io.NopCloser(strings.NewReader("sleep 1s\nkey Super+Return\nsleep 1s\nmove 600 300\nclick\nsleep 500ms\nkey Ctrl+Alt+BackSpace\n")), Timeout: 5 * time.Second, testScenes: scenes})
272 if err != nil {
273 if strings.Contains(strings.ToLower(err.Error()), "vulkan") {
274 t.Skipf("Vulkan unavailable: %v", err)
275 }
276 t.Fatal(err)
277 }
278 stopCollector()
279 for len(scenes) > 0 {
280 got = append(got, <-scenes)
281 }
282 two, focused := false, false
283 for _, set := range got {
284 s := set[0]
285 if len(s.Windows) == 2 {
286 two = true
287 for _, w := range s.Windows {
288 if w.ID == 1 && w.Focused {
289 focused = true
290 }
291 }
292 }
293 }
294 if !two || !focused {
295 t.Fatalf("two columns=%v first focused=%v", two, focused)
296 }
297}
298
299func TestBlockedScriptShutdown(t *testing.T) {
300 t.Setenv("XDG_RUNTIME_DIR", t.TempDir())
301 r, w := io.Pipe()
302 defer w.Close()
303 done := make(chan error, 1)
304 go func() {
305 done <- Run(context.Background(), Options{Backend: "headless", NoXwayland: true, Config: config.Defaults(), NoTerminal: true, Script: r, Timeout: 300 * time.Millisecond})
306 }()
307 select {
308 case <-done:
309 case <-time.After(2 * time.Second):
310 t.Fatal("blocked script prevented shutdown")
311 }
312}
313
314func TestRelayConfigKeyboard(t *testing.T) {
315 ctx, cancel := context.WithCancel(context.Background())
316 defer cancel()
317 cur := config.Defaults()
318 in := make(chan ports.ConfigChanged, 1)
319 out := make(chan ports.ConfigChanged, 1)
320 keymaps := make(chan *xkb.Keymap, 1)
321 commands := make(chan ports.ClientCommand, 1)
322 devices := make(chan ports.InputDevicesConfig, 1)
323 go relayConfig(ctx, cur, in, out, keymaps, devices, newCursors(clock.System{}, 0), commands, logging.For(ctx, "config"))
324
325 next := config.Defaults()
326 next.Background.Color = "#000000"
327 in <- ports.ConfigChanged{Config: next}
328 if got := <-out; got.Config.Background.Color != "#000000" || len(keymaps) != 0 || len(commands) != 0 || len(devices) != 0 {
329 t.Fatalf("non-keyboard change rebuilt keymap: %+v", got)
330 }
331
332 // Touchpad changes reach input; the newest replaces one not yet taken.
333 next.Touchpad.NaturalScroll = true
334 in <- ports.ConfigChanged{Config: next}
335 <-out
336 next.Touchpad.NaturalScroll = false
337 in <- ports.ConfigChanged{Config: next}
338 <-out
339 if d := <-devices; d.Touchpad.NaturalScroll || len(devices) != 0 {
340 t.Fatalf("touchpad: %+v", d)
341 }
342
343 // Touchpad and mouse changes travel together.
344 next.Touchpad.Tap = false
345 next.Mouse.LeftHanded = true
346 next.Mouse.AccelSpeed = 0.25
347 in <- ports.ConfigChanged{Config: next}
348 <-out
349 want := ports.InputDevicesConfig{Touchpad: next.Touchpad, Mouse: next.Mouse}
350 if d := <-devices; d != want || len(devices) != 0 {
351 t.Fatalf("devices: %+v, want %+v", d, want)
352 }
353 // A mouse-only change is relayed too, with the touchpad config beside it.
354 // relayConfig sends to devices before out, so it is ready after <-out.
355 next.Mouse.NaturalScroll = true
356 in <- ports.ConfigChanged{Config: next}
357 <-out
358 select {
359 case d := <-devices:
360 if !d.Mouse.NaturalScroll || !d.Mouse.LeftHanded || d.Touchpad != want.Touchpad || len(devices) != 0 {
361 t.Fatalf("mouse-only change: %+v", d)
362 }
363 default:
364 t.Fatal("mouse-only change not relayed")
365 }
366
367 next.Keyboard.Layout, next.Keyboard.RepeatRate = "fr", 40
368 in <- ports.ConfigChanged{Config: next}
369 <-out
370 km := <-keymaps
371 defer km.Close()
372 cmd := (<-commands).(ports.SetKeymap)
373 if cmd.RepeatRate != 40 || !strings.Contains(cmd.Keymap, "xkb_keymap") {
374 t.Fatalf("keymap command: rate=%d", cmd.RepeatRate)
375 }
376 if code, _, ok := km.KeycodeFor("a"); !ok || code != 16 {
377 t.Fatalf("fr keymap: a at %d", code)
378 }
379
380 // Repeat-only changes update clients without a new keymap.
381 next.Keyboard.RepeatDelay = 300
382 in <- ports.ConfigChanged{Config: next}
383 <-out
384 if cmd := (<-commands).(ports.SetKeymap); cmd.Keymap != "" || cmd.RepeatDelay != 300 || len(keymaps) != 0 {
385 t.Fatalf("repeat-only: keymap=%t delay=%d", cmd.Keymap != "", cmd.RepeatDelay)
386 }
387
388 // A bad layout keeps the previous one; its repeat change still applies.
389 bad := next
390 bad.Keyboard.Layout, bad.Keyboard.RepeatRate = "no-such-layout", 50
391 in <- ports.ConfigChanged{Config: bad}
392 if got := <-out; got.Config.Keyboard.Layout != "fr" || got.Config.Keyboard.RepeatRate != 50 || len(keymaps) != 0 {
393 t.Fatalf("bad layout applied: %+v", got.Config.Keyboard)
394 }
395 if cmd := (<-commands).(ports.SetKeymap); cmd.Keymap != "" || cmd.RepeatRate != 50 {
396 t.Fatalf("bad layout command: keymap=%t rate=%d", cmd.Keymap != "", cmd.RepeatRate)
397 }
398}
399
400func TestHeadlessTwoOutputs(t *testing.T) {
401 t.Setenv("XDG_RUNTIME_DIR", t.TempDir())
402 scenes := make(chan []ports.Scene, 64)
403 shots := t.TempDir()
404 err := Run(context.Background(), Options{Backend: "headless", NoXwayland: true, Config: config.Defaults(), NoTerminal: true, ScreenshotDir: shots, Sizes: [][2]int{{640, 480}, {320, 240}}, Timeout: 2 * time.Second, testScenes: scenes})
405 if err != nil {
406 if strings.Contains(strings.ToLower(err.Error()), "vulkan") {
407 t.Skipf("Vulkan unavailable: %v", err)
408 }
409 t.Fatal(err)
410 }
411 var last []ports.Scene
412 for len(scenes) > 0 {
413 last = <-scenes
414 }
415 if len(last) != 2 || last[0].Output != "HEADLESS-1" || last[1].OutputWidth != 320 {
416 t.Fatalf("%+v", last)
417 }
418 for _, name := range []string{"HEADLESS-1", "HEADLESS-2"} {
419 if _, err := os.Stat(filepath.Join(shots, name, "latest.png")); err != nil {
420 t.Fatal(err)
421 }
422 }
423}