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}