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 for _, tc := range [][4]string{{"0x600", "1", "fr", "#336699"}, {"800x600", "5", "fr", "#336699"}, {"800x600", "1", "fr\nstartup = bad", "#336699"}, {"800x600", "1", "fr", "red"}} {
101 if _, err := Config(tc[0], tc[1], tc[2], tc[3]); err == nil {
102 t.Fatalf("accepted %v", tc)
103 }
104 }
105}
106func TestDiscoverSocket(t *testing.T) {
107 dir := t.TempDir()
108 if name, err := DiscoverSocket(context.Background(), dir); err != nil || name != "" {
109 t.Fatalf("empty: %q %v", name, err)
110 }
111 if err := os.Mkdir(filepath.Join(dir, "neferwl"), 0700); err != nil {
112 t.Fatal(err)
113 }
114 if err := os.WriteFile(filepath.Join(dir, "neferwl", "wayland-1.json"), []byte("{}"), 0600); err != nil {
115 t.Fatal(err)
116 }
117 if name, _ := DiscoverSocket(context.Background(), dir); name != "" {
118 t.Fatal("state without socket")
119 }
120 ln, err := net.Listen("unix", filepath.Join(dir, "wayland-1"))
121 if err != nil {
122 t.Fatal(err)
123 }
124 defer ln.Close()
125 if name, err := DiscoverSocket(context.Background(), dir); err != nil || name != "wayland-1" {
126 t.Fatalf("socket: %q %v", name, err)
127 }
128 if err := os.WriteFile(filepath.Join(dir, "neferwl", "wayland-2.json"), []byte("{}"), 0600); err != nil {
129 t.Fatal(err)
130 }
131 ln2, err := net.Listen("unix", filepath.Join(dir, "wayland-2"))
132 if err != nil {
133 t.Fatal(err)
134 }
135 defer ln2.Close()
136 if _, err := DiscoverSocket(context.Background(), dir); err == nil {
137 t.Fatal("multiple sockets accepted")
138 }
139}
140func TestHeadlessBackground(t *testing.T) {
141 if os.Getenv("NEFERGUI_HARNESS") != "1" {
142 t.Skip("set NEFERGUI_HARNESS=1")
143 }
144 out := filepath.Join(t.TempDir(), "run")
145 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}}}})
146 t.Logf("code=%d result=%+v", code, res)
147 if code != Pass {
148 data, _ := os.ReadFile(filepath.Join(out, "logs", "compositor.log"))
149 t.Fatalf("run: %s\ncompositor:\n%s", res.Error, data)
150 }
151 if len(res.Checks) != 1 || !res.Checks[0].Pass {
152 t.Fatal(res.Checks)
153 }
154 if _, err := os.Stat(filepath.Join(out, "result.json")); err != nil {
155 t.Fatal(err)
156 }
157}
158
159func TestClientFailureAndCleanup(t *testing.T) {
160 if os.Getenv("NEFERGUI_HARNESS") != "1" {
161 t.Skip("set NEFERGUI_HARNESS=1")
162 }
163 isolateSessionTemp(t)
164 out := filepath.Join(t.TempDir(), "failure")
165 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"}})
166 if code != ProcessFailure || res.Status != "error" {
167 t.Fatalf("code=%d result=%+v", code, res)
168 }
169 if _, err := os.Stat(filepath.Join(out, "result.json")); err != nil {
170 t.Fatal(err)
171 }
172 checkCleanup(t, res)
173}
174
175func checkCleanup(t *testing.T, res Result) {
176 t.Helper()
177 if _, ok := res.Artifacts["session"]; ok {
178 t.Fatal("temporary session retained")
179 }
180 matches, err := filepath.Glob(filepath.Join(os.Getenv("TMPDIR"), "nefergui-harness-*"))
181 if err != nil || len(matches) != 0 {
182 t.Fatalf("temporary sessions remain: %v %v", matches, err)
183 }
184 if res.Compositor.PID <= 0 {
185 t.Fatalf("missing compositor pid: %+v", res)
186 }
187 if err := syscall.Kill(res.Compositor.PID, 0); err != syscall.ESRCH {
188 t.Fatalf("compositor pid %d not reaped: %v", res.Compositor.PID, err)
189 }
190}
191
192func isolateSessionTemp(t *testing.T) { t.Helper(); t.Setenv("TMPDIR", t.TempDir()) }
193
194func TestCancelAndCleanup(t *testing.T) {
195 if os.Getenv("NEFERGUI_HARNESS") != "1" {
196 t.Skip("set NEFERGUI_HARNESS=1")
197 }
198 isolateSessionTemp(t)
199 ctx, cancel := context.WithCancel(context.Background())
200 defer cancel()
201 out := filepath.Join(t.TempDir(), "cancel")
202 ready := filepath.Join(t.TempDir(), "ready")
203 result := make(chan struct {
204 res Result
205 code int
206 }, 1)
207 go func() {
208 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}})
209 result <- struct {
210 res Result
211 code int
212 }{res, code}
213 }()
214 deadline := time.After(12 * time.Second)
215 for {
216 if _, err := os.Stat(ready); err == nil {
217 break
218 }
219 select {
220 case <-deadline:
221 cancel()
222 t.Fatal("client never started")
223 case <-time.After(25 * time.Millisecond):
224 }
225 }
226 cancel()
227 r := <-result
228 if r.code != ProcessFailure || !strings.Contains(r.res.Error, "context canceled") {
229 t.Fatalf("cancel: %d %+v", r.code, r.res)
230 }
231 checkCleanup(t, r.res)
232 if _, err := os.Stat(filepath.Join(out, "result.json")); err != nil {
233 t.Fatal(err)
234 }
235}
236
237func TestClientEnvironment(t *testing.T) {
238 if os.Getenv("NEFERGUI_HARNESS") != "1" {
239 t.Skip("set NEFERGUI_HARNESS=1")
240 }
241 isolateSessionTemp(t)
242 t.Setenv("DISPLAY", ":99")
243 t.Setenv("WAYLAND_DISPLAY", "host-socket")
244 t.Setenv("WAYLAND_SOCKET", "42")
245 out := filepath.Join(t.TempDir(), "env")
246 envFile := filepath.Join(t.TempDir(), "client.env")
247 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}})
248 if code != Pass {
249 t.Fatalf("run: %d %+v", code, res)
250 }
251 b, err := os.ReadFile(envFile)
252 if err != nil {
253 t.Fatal(err)
254 }
255 env := map[string]string{}
256 for _, line := range strings.Split(strings.TrimSpace(string(b)), "\n") {
257 k, v, ok := strings.Cut(line, "=")
258 if ok {
259 env[k] = v
260 }
261 }
262 for _, k := range []string{"DISPLAY", "WAYLAND_SOCKET"} {
263 if _, ok := env[k]; ok {
264 t.Fatalf("host %s leaked: %s", k, b)
265 }
266 }
267 if env["WAYLAND_DISPLAY"] == "" || env["WAYLAND_DISPLAY"] == "host-socket" {
268 t.Fatalf("wrong socket: %s", b)
269 }
270 if runtime := env["XDG_RUNTIME_DIR"]; !strings.HasPrefix(runtime, filepath.Join(os.TempDir(), "nefergui-harness-")) || filepath.Base(runtime) != "runtime" {
271 t.Fatalf("wrong runtime: %s", b)
272 }
273 checkCleanup(t, res)
274}
275func TestUnpinnedOverride(t *testing.T) {
276 t.Setenv("NEFERGUI_NEFERWL", "/bin/true")
277 options := Options{Size: "128x96", Scale: "1", Layout: "us", Background: "#336699", Out: filepath.Join(t.TempDir(), "denied"), Timeout: time.Second, ReadyTimeout: 500 * time.Millisecond}
278 res, code := Run(context.Background(), options)
279 if code != Usage || res.Compositor.Pinned || res.Compositor.Binary != "/bin/true" {
280 t.Fatalf("denied: %d %+v", code, res)
281 }
282 data, err := os.ReadFile(filepath.Join(options.Out, "result.json"))
283 if err != nil {
284 t.Fatal(err)
285 }
286 if !strings.Contains(string(data), `"pinned": false`) || !strings.Contains(string(data), `"binary": "/bin/true"`) {
287 t.Fatalf("result: %s", data)
288 }
289 options.Out = filepath.Join(t.TempDir(), "allowed")
290 options.AllowUnpinned = true
291 res, code = Run(context.Background(), options)
292 if code == Usage || res.Compositor.Pinned || res.Compositor.Binary != "/bin/true" {
293 t.Fatalf("allowed: %d %+v", code, res)
294 }
295}
296
297func TestResultWriteFailure(t *testing.T) {
298 out := filepath.Join(t.TempDir(), "run")
299 binary := filepath.Join(t.TempDir(), "fake-neferwl")
300 if err := os.WriteFile(binary, []byte("#!/bin/sh\nmkdir \""+filepath.Join(out, "result.json")+"\"\n"), 0700); err != nil {
301 t.Fatal(err)
302 }
303 t.Setenv("NEFERGUI_NEFERWL", binary)
304 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})
305 if code != ProcessFailure || !strings.Contains(res.Error, "write result.json") {
306 t.Fatalf("write failure: %d %+v", code, res)
307 }
308}
309
310func TestCopyRunLog(t *testing.T) {
311 dir := t.TempDir()
312 dst := filepath.Join(dir, "out.log")
313 // Older NeferWL writes no run log: no artifact, no error.
314 if ok, err := copyRunLog(filepath.Join(dir, "missing.log"), dst); ok || err != nil {
315 t.Fatalf("missing: %v %v", ok, err)
316 }
317 src := filepath.Join(dir, "latest.log")
318 if err := os.WriteFile(src, []byte("{}\n"), 0600); err != nil {
319 t.Fatal(err)
320 }
321 if ok, err := copyRunLog(src, dst); !ok || err != nil {
322 t.Fatalf("copy: %v %v", ok, err)
323 }
324 if data, err := os.ReadFile(dst); err != nil || string(data) != "{}\n" {
325 t.Fatalf("copied %q %v", data, err)
326 }
327 // A failed write is reported, not hidden.
328 if ok, err := copyRunLog(src, filepath.Join(dir, "no-such-dir", "out.log")); ok || err == nil {
329 t.Fatalf("write failure: %v %v", ok, err)
330 }
331}