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}