v0.4.0 2de4e1d

headless_test.go RawHistoryFolder

1package ui
2
3import (
4	"context"
5	"errors"
6	"fmt"
7	"os"
8	"path/filepath"
9	"strings"
10	"sync"
11	"testing"
12	"time"
13
14	"github.com/bnema/nefergui/internal/harness"
15)
16
17// These tests drive the real Run loop against a headless NeferWL. They run only
18// when NEFERGUI_NEFERWL points at a compositor build; the client is this test
19// binary re-executed, since Run needs a compositor socket in its environment.
20
21const headlessClientEnv = "NEFERGUI_UI_HEADLESS_SCENARIO"
22
23func requireCompositor(t *testing.T) {
24	t.Helper()
25	if os.Getenv("NEFERGUI_NEFERWL") == "" {
26		t.Skip("NEFERGUI_NEFERWL not set")
27	}
28}
29
30func runHeadless(t *testing.T, scenario, script string, timeout time.Duration) []string {
31	t.Helper()
32	return runHeadlessSize(t, "640x480", scenario, script, timeout)
33}
34
35func runHeadlessSize(t *testing.T, size, scenario, script string, timeout time.Duration) []string {
36	t.Helper()
37	requireCompositor(t)
38	dir := t.TempDir()
39	events := filepath.Join(dir, "events.txt")
40	input := ""
41	if script != "" {
42		input = filepath.Join(dir, "input")
43		if err := os.WriteFile(input, []byte(script), 0600); err != nil {
44			t.Fatal(err)
45		}
46	}
47	t.Setenv(headlessClientEnv, scenario)
48	t.Setenv("NEFERGUI_UI_HEADLESS_OUT", events)
49	res, code := harness.Run(context.Background(), harness.Options{
50		Size: size, Scale: "1", Layout: "us", Background: "#000000",
51		Input: input, Out: filepath.Join(dir, "out"), AllowUnpinned: true,
52		Timeout: timeout, ReadyTimeout: 5 * time.Second,
53		Client: []string{os.Args[0], "-test.run=^TestHeadlessClientProcess$", "-test.count=1"},
54	})
55	data, _ := os.ReadFile(events)
56	lines := strings.Split(strings.TrimSpace(string(data)), "\n")
57	if code != harness.Pass {
58		stderr, _ := os.ReadFile(res.Artifacts["client_stderr"])
59		t.Fatalf("harness code %d: %s\nevents:\n%s\nstderr:\n%s", code, res.Error, data, stderr)
60	}
61	return lines
62}
63
64func indexOf(lines []string, prefix string) int {
65	for i, l := range lines {
66		if strings.HasPrefix(l, prefix) {
67			return i
68		}
69	}
70	return -1
71}
72
73func TestHeadlessLayerHooks(t *testing.T) {
74	lines := runHeadless(t, "layer", "sleep 3s\nmove 5 5\nsleep 200ms\nclick left\nkey a\nkey Escape\nsleep 500ms\n", 30*time.Second)
75	surface, view := indexOf(lines, "surface"), indexOf(lines, "view ")
76	if surface != 0 || view < 0 || surface > view {
77		t.Fatalf("OnSurface must run once, before the first view:\n%s", strings.Join(lines, "\n"))
78	}
79	count := 0
80	for _, l := range lines {
81		if l == "surface" {
82			count++
83		}
84	}
85	joined := "\n" + strings.Join(lines, "\n") + "\n"
86	if count != 1 || strings.Count(joined, "\nresize ") != 1 || !strings.Contains(joined, "\nresize 640 480 1\n") {
87		t.Fatalf("surface %d, resize must fire once with 640x480:\n%s", count, joined)
88	}
89	if w := indexOf(lines, "wake-sent"); w < 0 || indexOf(lines[w:], "view ") < 0 || indexOf(lines[w:], "view ") > indexOf(lines[w:], "input press") {
90		t.Errorf("wake must produce a view before input starts:\n%s", joined)
91	}
92	for _, want := range []string{"\nview size 640x480", "\ninput press", "\ninput key Escape", "\nclicks 1\n", "\nexit context canceled\n"} {
93		if !strings.Contains(joined, want) {
94			t.Errorf("missing %q in:\n%s", want, joined)
95		}
96	}
97}
98
99// Every output gets a surface laid out at its own size; the wake redraws them all.
100func TestHeadlessLayerAllOutputs(t *testing.T) {
101	lines := runHeadlessSize(t, "640x480,320x200", "all-outputs", "", 30*time.Second)
102	joined := "\n" + strings.Join(lines, "\n") + "\n"
103	w := indexOf(lines, "wake-sent")
104	if w < 0 {
105		t.Fatalf("no wake:\n%s", joined)
106	}
107	after := "\n" + strings.Join(lines[w:], "\n") + "\n"
108	for _, want := range []string{"\nview size 640x480\n", "\nview size 320x200\n"} {
109		if !strings.Contains(after, want) {
110			t.Errorf("missing %q after the wake in:\n%s", strings.TrimSpace(want), joined)
111		}
112	}
113	if !strings.Contains(joined, "\nexit context deadline exceeded\n") {
114		t.Errorf("Run must end only with its context:\n%s", joined)
115	}
116}
117
118func TestHeadlessSurfaceHookErrorAborts(t *testing.T) {
119	lines := runHeadless(t, "hook-error", "", 20*time.Second)
120	joined := strings.Join(lines, "\n")
121	if !strings.Contains(joined, "exit nefergui: surface hook: hook failed") || strings.Contains(joined, "view") {
122		t.Fatalf("hook error must abort before any view:\n%s", joined)
123	}
124}
125
126func TestHeadlessSurfaceHookCancellation(t *testing.T) {
127	lines := runHeadless(t, "hook-cancel", "", 20*time.Second)
128	joined := strings.Join(lines, "\n")
129	if !strings.Contains(joined, "exit context canceled") || strings.Contains(joined, "view") {
130		t.Fatalf("cancellation during the hook must return ctx.Err before any view:\n%s", joined)
131	}
132}
133
134// TestHeadlessClientProcess is the client half; it does nothing under go test.
135func TestHeadlessClientProcess(t *testing.T) {
136	scenario := os.Getenv(headlessClientEnv)
137	if scenario == "" {
138		t.Skip("client process only")
139	}
140	out, err := os.Create(os.Getenv("NEFERGUI_UI_HEADLESS_OUT"))
141	if err != nil {
142		t.Fatal(err)
143	}
144	var mu sync.Mutex
145	logf := func(format string, args ...any) {
146		mu.Lock()
147		defer mu.Unlock()
148		fmt.Fprintf(out, format+"\n", args...)
149	}
150	type model struct{ clicks int }
151	m := &model{}
152	ctx, cancel := context.WithTimeout(context.Background(), 12*time.Second)
153	defer cancel()
154	view := func(f *Frame, m *model) {
155		w, h := f.Size()
156		logf("view size %vx%v", w, h)
157		st := f.Root().Stack()
158		st.Box(Key("box")).Rect(10, 20, 50, 40)
159		if st.Button("Go", Key("go"), Inline("width:60px;height:20px")).Activated() {
160			m.clicks++
161			logf("clicks %d", m.clicks)
162		}
163	}
164	options := []WindowOption{Size(300, 200), Transparent()}
165	switch scenario {
166	case "layer":
167		wake := make(chan struct{}, 1)
168		// Nothing else redraws an idle window before input starts at 3s.
169		go func() { time.Sleep(1500 * time.Millisecond); logf("wake-sent"); wake <- struct{}{} }()
170		options = append(options,
171			Layer(LayerConfig{Level: LayerOverlay, Anchors: AnchorTop | AnchorBottom | AnchorLeft | AnchorRight, Keyboard: KeyboardExclusive}),
172			Wake(wake),
173			OnSurface(func(context.Context, WaylandSurface) error { logf("surface"); return nil }),
174			OnResize(func(w, h int, s float64) { logf("resize %d %d %v", w, h, s) }),
175			OnInput(func(ev InputEvent) bool {
176				switch {
177				case ev.Kind == InputPointerPress:
178					logf("input press")
179					return true
180				case ev.Kind == InputKey && ev.Pressed && ev.KeyName == "Escape":
181					logf("input key Escape")
182					cancel()
183				}
184				return false
185			}),
186		)
187	case "all-outputs":
188		ctx, cancel = context.WithTimeout(context.Background(), 4*time.Second)
189		defer cancel()
190		wake := make(chan struct{}, 1)
191		go func() { time.Sleep(2 * time.Second); logf("wake-sent"); wake <- struct{}{} }()
192		options = append(options, Wake(wake), Layer(LayerConfig{AllOutputs: true, Level: LayerOverlay,
193			Anchors: AnchorTop | AnchorBottom | AnchorLeft | AnchorRight, ExclusiveZone: -1, InputRects: []Rect{}}))
194	case "hook-error":
195		options = append(options, Layer(LayerConfig{Level: LayerOverlay}),
196			OnSurface(func(context.Context, WaylandSurface) error { return errors.New("hook failed") }))
197	case "hook-cancel":
198		options = append(options, Layer(LayerConfig{Level: LayerOverlay}),
199			OnSurface(func(ctx context.Context, _ WaylandSurface) error {
200				go func() { time.Sleep(200 * time.Millisecond); cancel() }()
201				<-ctx.Done()
202				return nil
203			}))
204	}
205	err = Run(ctx, m, view, options...)
206	logf("exit %v", err)
207	out.Close()
208	os.Exit(0)
209}

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 1

Tags 12

↑↓ moveenter openesc close