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	"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}

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