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