app_test.go RawHistoryFolder

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	// Pulse frames would fill the scene tap before the click.
247	cfg.Focus.Animation = ports.FocusAnimationOff
248	cfg.Animations.On = animated
249	// The tap drops a scene it has no room for: a collector keeps up with
250	// the animation frames.
251	scenes := make(chan []ports.Scene, 128)
252	var got [][]ports.Scene
253	stop := make(chan struct{})
254	collected := make(chan struct{})
255	go func() {
256		defer close(collected)
257		for {
258			select {
259			case set := <-scenes:
260				got = append(got, set)
261			case <-stop:
262				return
263			}
264		}
265	}()
266	// Stopped before the scenes are read, or at the latest when the test
267	// ends (an early Fatal or Skip).
268	var halt sync.Once
269	stopCollector := func() { halt.Do(func() { close(stop); <-collected }) }
270	t.Cleanup(stopCollector)
271	// The script ends with the default quit bind: the run stops when it
272	// is done, the timeout is only a backstop.
273	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})
274	if err != nil {
275		if strings.Contains(strings.ToLower(err.Error()), "vulkan") {
276			t.Skipf("Vulkan unavailable: %v", err)
277		}
278		t.Fatal(err)
279	}
280	stopCollector()
281	for len(scenes) > 0 {
282		got = append(got, <-scenes)
283	}
284	two, focused := false, false
285	for _, set := range got {
286		s := set[0]
287		if len(s.Windows) == 2 {
288			two = true
289			for _, w := range s.Windows {
290				if w.ID == 1 && w.Focused {
291					focused = true
292				}
293			}
294		}
295	}
296	if !two || !focused {
297		t.Fatalf("two columns=%v first focused=%v", two, focused)
298	}
299}
300
301func TestBlockedScriptShutdown(t *testing.T) {
302	t.Setenv("XDG_RUNTIME_DIR", t.TempDir())
303	r, w := io.Pipe()
304	defer w.Close()
305	done := make(chan error, 1)
306	go func() {
307		done <- Run(context.Background(), Options{Backend: "headless", NoXwayland: true, Config: config.Defaults(), NoTerminal: true, Script: r, Timeout: 300 * time.Millisecond})
308	}()
309	select {
310	case <-done:
311	case <-time.After(2 * time.Second):
312		t.Fatal("blocked script prevented shutdown")
313	}
314}
315
316func TestRelayConfigKeyboard(t *testing.T) {
317	ctx, cancel := context.WithCancel(context.Background())
318	defer cancel()
319	cur := config.Defaults()
320	in := make(chan ports.ConfigChanged, 1)
321	out := make(chan ports.ConfigChanged, 1)
322	keymaps := make(chan *xkb.Keymap, 1)
323	commands := make(chan ports.ClientCommand, 1)
324	devices := make(chan ports.InputDevicesConfig, 1)
325	go relayConfig(ctx, cur, in, out, keymaps, devices, newCursors(clock.System{}, 0), commands, logging.For(ctx, "config"))
326
327	next := config.Defaults()
328	next.Background.Color = "#000000"
329	in <- ports.ConfigChanged{Config: next}
330	if got := <-out; got.Config.Background.Color != "#000000" || len(keymaps) != 0 || len(commands) != 0 || len(devices) != 0 {
331		t.Fatalf("non-keyboard change rebuilt keymap: %+v", got)
332	}
333
334	// Touchpad changes reach input; the newest replaces one not yet taken.
335	next.Touchpad.NaturalScroll = true
336	in <- ports.ConfigChanged{Config: next}
337	<-out
338	next.Touchpad.NaturalScroll = false
339	in <- ports.ConfigChanged{Config: next}
340	<-out
341	if d := <-devices; d.Touchpad.NaturalScroll || len(devices) != 0 {
342		t.Fatalf("touchpad: %+v", d)
343	}
344
345	// Touchpad and mouse changes travel together.
346	next.Touchpad.Tap = false
347	next.Mouse.LeftHanded = true
348	next.Mouse.AccelSpeed = 0.25
349	in <- ports.ConfigChanged{Config: next}
350	<-out
351	want := ports.InputDevicesConfig{Touchpad: next.Touchpad, Mouse: next.Mouse}
352	if d := <-devices; d != want || len(devices) != 0 {
353		t.Fatalf("devices: %+v, want %+v", d, want)
354	}
355	// A mouse-only change is relayed too, with the touchpad config beside it.
356	// relayConfig sends to devices before out, so it is ready after <-out.
357	next.Mouse.NaturalScroll = true
358	in <- ports.ConfigChanged{Config: next}
359	<-out
360	select {
361	case d := <-devices:
362		if !d.Mouse.NaturalScroll || !d.Mouse.LeftHanded || d.Touchpad != want.Touchpad || len(devices) != 0 {
363			t.Fatalf("mouse-only change: %+v", d)
364		}
365	default:
366		t.Fatal("mouse-only change not relayed")
367	}
368
369	next.Keyboard.Layout, next.Keyboard.RepeatRate = "fr", 40
370	in <- ports.ConfigChanged{Config: next}
371	<-out
372	km := <-keymaps
373	defer km.Close()
374	cmd := (<-commands).(ports.SetKeymap)
375	if cmd.RepeatRate != 40 || !strings.Contains(cmd.Keymap, "xkb_keymap") {
376		t.Fatalf("keymap command: rate=%d", cmd.RepeatRate)
377	}
378	if code, _, ok := km.KeycodeFor("a"); !ok || code != 16 {
379		t.Fatalf("fr keymap: a at %d", code)
380	}
381
382	// Repeat-only changes update clients without a new keymap.
383	next.Keyboard.RepeatDelay = 300
384	in <- ports.ConfigChanged{Config: next}
385	<-out
386	if cmd := (<-commands).(ports.SetKeymap); cmd.Keymap != "" || cmd.RepeatDelay != 300 || len(keymaps) != 0 {
387		t.Fatalf("repeat-only: keymap=%t delay=%d", cmd.Keymap != "", cmd.RepeatDelay)
388	}
389
390	// A bad layout keeps the previous one; its repeat change still applies.
391	bad := next
392	bad.Keyboard.Layout, bad.Keyboard.RepeatRate = "no-such-layout", 50
393	in <- ports.ConfigChanged{Config: bad}
394	if got := <-out; got.Config.Keyboard.Layout != "fr" || got.Config.Keyboard.RepeatRate != 50 || len(keymaps) != 0 {
395		t.Fatalf("bad layout applied: %+v", got.Config.Keyboard)
396	}
397	if cmd := (<-commands).(ports.SetKeymap); cmd.Keymap != "" || cmd.RepeatRate != 50 {
398		t.Fatalf("bad layout command: keymap=%t rate=%d", cmd.Keymap != "", cmd.RepeatRate)
399	}
400}
401
402func TestHeadlessTwoOutputs(t *testing.T) {
403	t.Setenv("XDG_RUNTIME_DIR", t.TempDir())
404	scenes := make(chan []ports.Scene, 64)
405	shots := t.TempDir()
406	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})
407	if err != nil {
408		if strings.Contains(strings.ToLower(err.Error()), "vulkan") {
409			t.Skipf("Vulkan unavailable: %v", err)
410		}
411		t.Fatal(err)
412	}
413	var last []ports.Scene
414	for len(scenes) > 0 {
415		last = <-scenes
416	}
417	if len(last) != 2 || last[0].Output != "HEADLESS-1" || last[1].OutputWidth != 320 {
418		t.Fatalf("%+v", last)
419	}
420	for _, name := range []string{"HEADLESS-1", "HEADLESS-2"} {
421		if _, err := os.Stat(filepath.Join(shots, name, "latest.png")); err != nil {
422			t.Fatal(err)
423		}
424	}
425}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 25

Tags 10

↑↓ moveenter openesc close