v0.2.0 13c6a90

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

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