v0.3.0 6800c6a

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	requireCompositor(t)
33	dir := t.TempDir()
34	events := filepath.Join(dir, "events.txt")
35	input := ""
36	if script != "" {
37		input = filepath.Join(dir, "input")
38		if err := os.WriteFile(input, []byte(script), 0600); err != nil {
39			t.Fatal(err)
40		}
41	}
42	t.Setenv(headlessClientEnv, scenario)
43	t.Setenv("NEFERGUI_UI_HEADLESS_OUT", events)
44	res, code := harness.Run(context.Background(), harness.Options{
45		Size: "640x480", Scale: "1", Layout: "us", Background: "#000000",
46		Input: input, Out: filepath.Join(dir, "out"), AllowUnpinned: true,
47		Timeout: timeout, ReadyTimeout: 5 * time.Second,
48		Client: []string{os.Args[0], "-test.run=^TestHeadlessClientProcess$", "-test.count=1"},
49	})
50	data, _ := os.ReadFile(events)
51	lines := strings.Split(strings.TrimSpace(string(data)), "\n")
52	if code != harness.Pass {
53		stderr, _ := os.ReadFile(res.Artifacts["client_stderr"])
54		t.Fatalf("harness code %d: %s\nevents:\n%s\nstderr:\n%s", code, res.Error, data, stderr)
55	}
56	return lines
57}
58
59func indexOf(lines []string, prefix string) int {
60	for i, l := range lines {
61		if strings.HasPrefix(l, prefix) {
62			return i
63		}
64	}
65	return -1
66}
67
68func TestHeadlessLayerHooks(t *testing.T) {
69	lines := runHeadless(t, "layer", "sleep 3s\nmove 5 5\nsleep 200ms\nclick left\nkey a\nkey Escape\nsleep 500ms\n", 30*time.Second)
70	surface, view := indexOf(lines, "surface"), indexOf(lines, "view ")
71	if surface != 0 || view < 0 || surface > view {
72		t.Fatalf("OnSurface must run once, before the first view:\n%s", strings.Join(lines, "\n"))
73	}
74	count := 0
75	for _, l := range lines {
76		if l == "surface" {
77			count++
78		}
79	}
80	joined := "\n" + strings.Join(lines, "\n") + "\n"
81	if count != 1 || strings.Count(joined, "\nresize ") != 1 || !strings.Contains(joined, "\nresize 640 480 1\n") {
82		t.Fatalf("surface %d, resize must fire once with 640x480:\n%s", count, joined)
83	}
84	if w := indexOf(lines, "wake-sent"); w < 0 || indexOf(lines[w:], "view ") < 0 || indexOf(lines[w:], "view ") > indexOf(lines[w:], "input press") {
85		t.Errorf("wake must produce a view before input starts:\n%s", joined)
86	}
87	for _, want := range []string{"\nview size 640x480", "\ninput press", "\ninput key Escape", "\nclicks 1\n", "\nexit context canceled\n"} {
88		if !strings.Contains(joined, want) {
89			t.Errorf("missing %q in:\n%s", want, joined)
90		}
91	}
92}
93
94func TestHeadlessSurfaceHookErrorAborts(t *testing.T) {
95	lines := runHeadless(t, "hook-error", "", 20*time.Second)
96	joined := strings.Join(lines, "\n")
97	if !strings.Contains(joined, "exit nefergui: surface hook: hook failed") || strings.Contains(joined, "view") {
98		t.Fatalf("hook error must abort before any view:\n%s", joined)
99	}
100}
101
102func TestHeadlessSurfaceHookCancellation(t *testing.T) {
103	lines := runHeadless(t, "hook-cancel", "", 20*time.Second)
104	joined := strings.Join(lines, "\n")
105	if !strings.Contains(joined, "exit context canceled") || strings.Contains(joined, "view") {
106		t.Fatalf("cancellation during the hook must return ctx.Err before any view:\n%s", joined)
107	}
108}
109
110// TestHeadlessClientProcess is the client half; it does nothing under go test.
111func TestHeadlessClientProcess(t *testing.T) {
112	scenario := os.Getenv(headlessClientEnv)
113	if scenario == "" {
114		t.Skip("client process only")
115	}
116	out, err := os.Create(os.Getenv("NEFERGUI_UI_HEADLESS_OUT"))
117	if err != nil {
118		t.Fatal(err)
119	}
120	var mu sync.Mutex
121	logf := func(format string, args ...any) {
122		mu.Lock()
123		defer mu.Unlock()
124		fmt.Fprintf(out, format+"\n", args...)
125	}
126	type model struct{ clicks int }
127	m := &model{}
128	ctx, cancel := context.WithTimeout(context.Background(), 12*time.Second)
129	defer cancel()
130	view := func(f *Frame, m *model) {
131		w, h := f.Size()
132		logf("view size %vx%v", w, h)
133		st := f.Root().Stack()
134		st.Box(Key("box")).Rect(10, 20, 50, 40)
135		if st.Button("Go", Key("go"), Inline("width:60px;height:20px")).Activated() {
136			m.clicks++
137			logf("clicks %d", m.clicks)
138		}
139	}
140	options := []WindowOption{Size(300, 200), Transparent()}
141	switch scenario {
142	case "layer":
143		wake := make(chan struct{}, 1)
144		// Nothing else redraws an idle window before input starts at 3s.
145		go func() { time.Sleep(1500 * time.Millisecond); logf("wake-sent"); wake <- struct{}{} }()
146		options = append(options,
147			Layer(LayerConfig{Level: LayerOverlay, Anchors: AnchorTop | AnchorBottom | AnchorLeft | AnchorRight, Keyboard: KeyboardExclusive}),
148			Wake(wake),
149			OnSurface(func(context.Context, WaylandSurface) error { logf("surface"); return nil }),
150			OnResize(func(w, h int, s float64) { logf("resize %d %d %v", w, h, s) }),
151			OnInput(func(ev InputEvent) bool {
152				switch {
153				case ev.Kind == InputPointerPress:
154					logf("input press")
155					return true
156				case ev.Kind == InputKey && ev.Pressed && ev.KeyName == "Escape":
157					logf("input key Escape")
158					cancel()
159				}
160				return false
161			}),
162		)
163	case "hook-error":
164		options = append(options, Layer(LayerConfig{Level: LayerOverlay}),
165			OnSurface(func(context.Context, WaylandSurface) error { return errors.New("hook failed") }))
166	case "hook-cancel":
167		options = append(options, Layer(LayerConfig{Level: LayerOverlay}),
168			OnSurface(func(ctx context.Context, _ WaylandSurface) error {
169				go func() { time.Sleep(200 * time.Millisecond); cancel() }()
170				<-ctx.Done()
171				return nil
172			}))
173	}
174	err = Run(ctx, m, view, options...)
175	logf("exit %v", err)
176	out.Close()
177	os.Exit(0)
178}

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