1package harness
2
3import (
4 "context"
5 "image"
6 "image/color"
7 "net"
8 "os"
9 "path/filepath"
10 "strings"
11 "syscall"
12 "testing"
13 "time"
14)
15
16func TestAssertions(t *testing.T) {
17 img := image.NewNRGBA(image.Rect(4, 5, 6, 7))
18 c := color.NRGBA{51, 102, 153, 255}
19 for y := 5; y < 7; y++ {
20 for x := 4; x < 6; x++ {
21 img.Set(x, y, c)
22 }
23 }
24 for _, tc := range []struct {
25 name string
26 err error
27 pass bool
28 }{
29 {"probe", PixelProbe(img, 4, 5, c, 0), true}, {"outside", PixelProbe(img, 0, 0, c, 0), false},
30 {"tolerated", PixelProbe(img, 4, 5, color.NRGBA{52, 102, 153, 255}, 1), true},
31 {"color", UniformRegion(img, Rect{4, 5, 2, 2}, c, 0), true},
32 {"bounds", BoundsCheck(img, Rect{4, 5, 3, 2}), false}, {"empty", BoundsCheck(img, Rect{4, 5, 0, 2}), false},
33 } {
34 t.Run(tc.name, func(t *testing.T) {
35 if (tc.err == nil) != tc.pass {
36 t.Fatalf("error %v, want pass %v", tc.err, tc.pass)
37 }
38 })
39 }
40 changed := image.NewNRGBA(img.Bounds())
41 for y := 5; y < 7; y++ {
42 for x := 4; x < 6; x++ {
43 changed.Set(x, y, c)
44 }
45 }
46 changed.Set(4, 5, color.NRGBA{54, 102, 153, 255})
47 for _, tc := range []struct {
48 tolerance uint8
49 max float64
50 pass bool
51 ratio float64
52 }{{0, .24, false, .25}, {0, .25, true, .25}, {3, 0, true, 0}} {
53 pass, ratio, diff, err := CompareGolden(changed, img, tc.tolerance, tc.max)
54 if err != nil || pass != tc.pass || ratio != tc.ratio || diff == nil {
55 t.Fatalf("%+v: %v %v %v", tc, pass, ratio, err)
56 }
57 }
58 alpha := image.NewNRGBA(img.Bounds())
59 for y := 5; y < 7; y++ {
60 for x := 4; x < 6; x++ {
61 alpha.SetNRGBA(x, y, c)
62 }
63 }
64 alpha.SetNRGBA(4, 5, color.NRGBA{51, 102, 153, 254})
65 pass, ratio, diff, err := CompareGolden(alpha, img, 0, 0)
66 if err != nil || pass || ratio != .25 || diff == nil || diff.NRGBAAt(4, 5) == (color.NRGBA{0, 0, 0, 255}) {
67 t.Fatalf("alpha diff: %v %v %v %v", pass, ratio, diff, err)
68 }
69 if _, _, _, err := CompareGolden(img, image.NewNRGBA(image.Rect(0, 0, 1, 1)), 0, 0); err == nil {
70 t.Fatal("different bounds accepted")
71 }
72 if _, _, _, err := CompareGolden(img, img, 0, 1.1); err == nil {
73 t.Fatal("invalid ratio accepted")
74 }
75 for _, s := range []string{"red", "#GG0000", "#12345"} {
76 if _, err := ParseColor(s); err == nil {
77 t.Fatalf("accepted %q", s)
78 }
79 }
80 dir := t.TempDir()
81 path := filepath.Join(dir, "img.png")
82 if err := SavePNG(path, img); err != nil {
83 t.Fatal(err)
84 }
85 decoded, err := LoadPNG(path)
86 if err != nil || decoded.Bounds().Dx() != img.Bounds().Dx() || decoded.Bounds().Dy() != img.Bounds().Dy() {
87 t.Fatalf("png: %v", err)
88 }
89}
90func TestConfig(t *testing.T) {
91 cfg, err := Config("800x600", "1.5", "fr", "#336699")
92 if err != nil {
93 t.Fatal(err)
94 }
95 for _, s := range []string{"output.HEADLESS-1 = 800x600", "output.HEADLESS-1.scale = 1.5", "keyboard.layout = fr", "background = #336699", "log.debug = wayland"} {
96 if !strings.Contains(cfg, s) {
97 t.Fatalf("missing %s", s)
98 }
99 }
100 multi, err := Config("800x600,320x200", "1", "us", "#000000")
101 if err != nil || !strings.Contains(multi, "output.HEADLESS-1 = 800x600\n") || !strings.Contains(multi, "output.HEADLESS-2 = 320x200\noutput.HEADLESS-2.scale = 1\n") {
102 t.Fatalf("multi-output config: %v\n%s", err, multi)
103 }
104 if FramesDir("f", "800x600") != "f" || FramesDir("f", "800x600,320x200") != filepath.Join("f", "HEADLESS-1") {
105 t.Fatal("frames dir")
106 }
107 for _, tc := range [][4]string{{"0x600", "1", "fr", "#336699"}, {"800x600,", "1", "fr", "#336699"}, {"800x600", "5", "fr", "#336699"}, {"800x600", "1", "fr\nstartup = bad", "#336699"}, {"800x600", "1", "fr", "red"}} {
108 if _, err := Config(tc[0], tc[1], tc[2], tc[3]); err == nil {
109 t.Fatalf("accepted %v", tc)
110 }
111 }
112}
113func TestDiscoverSocket(t *testing.T) {
114 dir := t.TempDir()
115 if name, err := DiscoverSocket(context.Background(), dir); err != nil || name != "" {
116 t.Fatalf("empty: %q %v", name, err)
117 }
118 if err := os.Mkdir(filepath.Join(dir, "neferwl"), 0700); err != nil {
119 t.Fatal(err)
120 }
121 if err := os.WriteFile(filepath.Join(dir, "neferwl", "wayland-1.json"), []byte("{}"), 0600); err != nil {
122 t.Fatal(err)
123 }
124 if name, _ := DiscoverSocket(context.Background(), dir); name != "" {
125 t.Fatal("state without socket")
126 }
127 ln, err := net.Listen("unix", filepath.Join(dir, "wayland-1"))
128 if err != nil {
129 t.Fatal(err)
130 }
131 defer ln.Close()
132 if name, err := DiscoverSocket(context.Background(), dir); err != nil || name != "wayland-1" {
133 t.Fatalf("socket: %q %v", name, err)
134 }
135 if err := os.WriteFile(filepath.Join(dir, "neferwl", "wayland-2.json"), []byte("{}"), 0600); err != nil {
136 t.Fatal(err)
137 }
138 ln2, err := net.Listen("unix", filepath.Join(dir, "wayland-2"))
139 if err != nil {
140 t.Fatal(err)
141 }
142 defer ln2.Close()
143 if _, err := DiscoverSocket(context.Background(), dir); err == nil {
144 t.Fatal("multiple sockets accepted")
145 }
146}
147func TestHeadlessBackground(t *testing.T) {
148 if os.Getenv("NEFERGUI_HARNESS") != "1" {
149 t.Skip("set NEFERGUI_HARNESS=1")
150 }
151 out := filepath.Join(t.TempDir(), "run")
152 res, code := Run(context.Background(), Options{Size: "128x96", Scale: "1.5", Layout: "fr", Background: "#336699", Out: out, Timeout: 60 * time.Second, ReadyTimeout: 20 * time.Second, Expect: Expectations{Probes: []Probe{{Name: "background", X: 64, Y: 48, Color: "#336699", Tolerance: 1}}}})
153 t.Logf("code=%d result=%+v", code, res)
154 if code != Pass {
155 data, _ := os.ReadFile(filepath.Join(out, "logs", "compositor.log"))
156 t.Fatalf("run: %s\ncompositor:\n%s", res.Error, data)
157 }
158 if len(res.Checks) != 1 || !res.Checks[0].Pass {
159 t.Fatal(res.Checks)
160 }
161 if _, err := os.Stat(filepath.Join(out, "result.json")); err != nil {
162 t.Fatal(err)
163 }
164}
165
166func TestClientFailureAndCleanup(t *testing.T) {
167 if os.Getenv("NEFERGUI_HARNESS") != "1" {
168 t.Skip("set NEFERGUI_HARNESS=1")
169 }
170 isolateSessionTemp(t)
171 out := filepath.Join(t.TempDir(), "failure")
172 res, code := Run(context.Background(), Options{Size: "128x96", Scale: "1", Layout: "us", Background: "#336699", Out: out, Timeout: 10 * time.Second, ReadyTimeout: 5 * time.Second, Client: []string{"/bin/false"}})
173 if code != ProcessFailure || res.Status != "error" {
174 t.Fatalf("code=%d result=%+v", code, res)
175 }
176 if _, err := os.Stat(filepath.Join(out, "result.json")); err != nil {
177 t.Fatal(err)
178 }
179 checkCleanup(t, res)
180}
181
182func checkCleanup(t *testing.T, res Result) {
183 t.Helper()
184 if _, ok := res.Artifacts["session"]; ok {
185 t.Fatal("temporary session retained")
186 }
187 matches, err := filepath.Glob(filepath.Join(os.Getenv("TMPDIR"), "nefergui-harness-*"))
188 if err != nil || len(matches) != 0 {
189 t.Fatalf("temporary sessions remain: %v %v", matches, err)
190 }
191 if res.Compositor.PID <= 0 {
192 t.Fatalf("missing compositor pid: %+v", res)
193 }
194 if err := syscall.Kill(res.Compositor.PID, 0); err != syscall.ESRCH {
195 t.Fatalf("compositor pid %d not reaped: %v", res.Compositor.PID, err)
196 }
197}
198
199func isolateSessionTemp(t *testing.T) { t.Helper(); t.Setenv("TMPDIR", t.TempDir()) }
200
201func TestCancelAndCleanup(t *testing.T) {
202 if os.Getenv("NEFERGUI_HARNESS") != "1" {
203 t.Skip("set NEFERGUI_HARNESS=1")
204 }
205 isolateSessionTemp(t)
206 ctx, cancel := context.WithCancel(context.Background())
207 defer cancel()
208 out := filepath.Join(t.TempDir(), "cancel")
209 ready := filepath.Join(t.TempDir(), "ready")
210 result := make(chan struct {
211 res Result
212 code int
213 }, 1)
214 go func() {
215 res, code := Run(ctx, Options{Size: "128x96", Scale: "1", Layout: "us", Background: "#336699", Out: out, Timeout: 20 * time.Second, ReadyTimeout: 8 * time.Second, Client: []string{"sh", "-c", "touch \"$1\"; sleep 30", "sh", ready}})
216 result <- struct {
217 res Result
218 code int
219 }{res, code}
220 }()
221 deadline := time.After(12 * time.Second)
222 for {
223 if _, err := os.Stat(ready); err == nil {
224 break
225 }
226 select {
227 case <-deadline:
228 cancel()
229 t.Fatal("client never started")
230 case <-time.After(25 * time.Millisecond):
231 }
232 }
233 cancel()
234 r := <-result
235 if r.code != ProcessFailure || !strings.Contains(r.res.Error, "context canceled") {
236 t.Fatalf("cancel: %d %+v", r.code, r.res)
237 }
238 checkCleanup(t, r.res)
239 if _, err := os.Stat(filepath.Join(out, "result.json")); err != nil {
240 t.Fatal(err)
241 }
242}
243
244func TestClientEnvironment(t *testing.T) {
245 if os.Getenv("NEFERGUI_HARNESS") != "1" {
246 t.Skip("set NEFERGUI_HARNESS=1")
247 }
248 isolateSessionTemp(t)
249 t.Setenv("DISPLAY", ":99")
250 t.Setenv("WAYLAND_DISPLAY", "host-socket")
251 t.Setenv("WAYLAND_SOCKET", "42")
252 out := filepath.Join(t.TempDir(), "env")
253 envFile := filepath.Join(t.TempDir(), "client.env")
254 res, code := Run(context.Background(), Options{Size: "128x96", Scale: "1", Layout: "us", Background: "#336699", Out: out, Timeout: 20 * time.Second, ReadyTimeout: 8 * time.Second, Client: []string{"sh", "-c", "env > \"$1\"", "sh", envFile}})
255 if code != Pass {
256 t.Fatalf("run: %d %+v", code, res)
257 }
258 b, err := os.ReadFile(envFile)
259 if err != nil {
260 t.Fatal(err)
261 }
262 env := map[string]string{}
263 for _, line := range strings.Split(strings.TrimSpace(string(b)), "\n") {
264 k, v, ok := strings.Cut(line, "=")
265 if ok {
266 env[k] = v
267 }
268 }
269 for _, k := range []string{"DISPLAY", "WAYLAND_SOCKET"} {
270 if _, ok := env[k]; ok {
271 t.Fatalf("host %s leaked: %s", k, b)
272 }
273 }
274 if env["WAYLAND_DISPLAY"] == "" || env["WAYLAND_DISPLAY"] == "host-socket" {
275 t.Fatalf("wrong socket: %s", b)
276 }
277 if runtime := env["XDG_RUNTIME_DIR"]; !strings.HasPrefix(runtime, filepath.Join(os.TempDir(), "nefergui-harness-")) || filepath.Base(runtime) != "runtime" {
278 t.Fatalf("wrong runtime: %s", b)
279 }
280 checkCleanup(t, res)
281}
282func TestUnpinnedOverride(t *testing.T) {
283 t.Setenv("NEFERGUI_NEFERWL", "/bin/true")
284 options := Options{Size: "128x96", Scale: "1", Layout: "us", Background: "#336699", Out: filepath.Join(t.TempDir(), "denied"), Timeout: time.Second, ReadyTimeout: 500 * time.Millisecond}
285 res, code := Run(context.Background(), options)
286 if code != Usage || res.Compositor.Pinned || res.Compositor.Binary != "/bin/true" {
287 t.Fatalf("denied: %d %+v", code, res)
288 }
289 data, err := os.ReadFile(filepath.Join(options.Out, "result.json"))
290 if err != nil {
291 t.Fatal(err)
292 }
293 if !strings.Contains(string(data), `"pinned": false`) || !strings.Contains(string(data), `"binary": "/bin/true"`) {
294 t.Fatalf("result: %s", data)
295 }
296 options.Out = filepath.Join(t.TempDir(), "allowed")
297 options.AllowUnpinned = true
298 res, code = Run(context.Background(), options)
299 if code == Usage || res.Compositor.Pinned || res.Compositor.Binary != "/bin/true" {
300 t.Fatalf("allowed: %d %+v", code, res)
301 }
302}
303
304func TestResultWriteFailure(t *testing.T) {
305 out := filepath.Join(t.TempDir(), "run")
306 binary := filepath.Join(t.TempDir(), "fake-neferwl")
307 if err := os.WriteFile(binary, []byte("#!/bin/sh\nmkdir \""+filepath.Join(out, "result.json")+"\"\n"), 0700); err != nil {
308 t.Fatal(err)
309 }
310 t.Setenv("NEFERGUI_NEFERWL", binary)
311 res, code := Run(context.Background(), Options{Size: "128x96", Scale: "1", Layout: "us", Background: "#336699", Out: out, AllowUnpinned: true, Timeout: time.Second, ReadyTimeout: 500 * time.Millisecond})
312 if code != ProcessFailure || !strings.Contains(res.Error, "write result.json") {
313 t.Fatalf("write failure: %d %+v", code, res)
314 }
315}
316
317func TestCopyRunLog(t *testing.T) {
318 dir := t.TempDir()
319 dst := filepath.Join(dir, "out.log")
320 // Older NeferWL writes no run log: no artifact, no error.
321 if ok, err := copyRunLog(filepath.Join(dir, "missing.log"), dst); ok || err != nil {
322 t.Fatalf("missing: %v %v", ok, err)
323 }
324 src := filepath.Join(dir, "latest.log")
325 if err := os.WriteFile(src, []byte("{}\n"), 0600); err != nil {
326 t.Fatal(err)
327 }
328 if ok, err := copyRunLog(src, dst); !ok || err != nil {
329 t.Fatalf("copy: %v %v", ok, err)
330 }
331 if data, err := os.ReadFile(dst); err != nil || string(data) != "{}\n" {
332 t.Fatalf("copied %q %v", data, err)
333 }
334 // A failed write is reported, not hidden.
335 if ok, err := copyRunLog(src, filepath.Join(dir, "no-such-dir", "out.log")); ok || err == nil {
336 t.Fatalf("write failure: %v %v", ok, err)
337 }
338}