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