1//go:build linux
2
3package systemtest
4
5import (
6 "bufio"
7 "context"
8 "encoding/json"
9 "fmt"
10 "image"
11 "image/color"
12 "math"
13 "os"
14 "os/exec"
15 "path/filepath"
16 "regexp"
17 "sort"
18 "strings"
19 "testing"
20 "time"
21
22 "github.com/bnema/nefergui/internal/harness"
23)
24
25const background = "#111111"
26
27var opaque = color.NRGBA{255, 128, 0, 255}
28var transparent = color.NRGBA{128, 64, 0, 128}
29
30func requireHarness(t *testing.T) {
31 t.Helper()
32 if os.Getenv("NEFERGUI_HARNESS") != "1" {
33 t.Skip("set NEFERGUI_HARNESS=1 to run headless Vulkan/Wayland acceptance")
34 }
35}
36
37func binary(t *testing.T) string {
38 t.Helper()
39 path := filepath.Join(t.TempDir(), "rect")
40 cmd := exec.Command("go", "build", "-o", path, "./examples/rect")
41 cmd.Dir = filepath.Join("..", "..", "..")
42 cmd.Env = append(os.Environ(), "GOWORK=off")
43 if data, err := cmd.CombinedOutput(); err != nil {
44 t.Fatalf("build rect: %v\n%s", err, data)
45 }
46 return path
47}
48
49type run struct {
50 root string
51 result harness.Result
52 capture, readback image.Image
53 timing []timing
54 lifecycle []lifecycle
55 summary summary
56}
57type timing struct {
58 Frame, Images, FDCount int
59 Buffer, Generation uint64
60}
61type lifecycle struct {
62 Frame int
63 Buffer, Generation, ReleasePoint uint64
64 Event string
65}
66type summary struct {
67 Frames int `json:"frames"`
68 FDStart int `json:"fd_start"`
69 FDPeak int `json:"fd_peak"`
70 FDAfter int `json:"fd_after"`
71 ImagesAfter int `json:"images_after"`
72}
73
74func jsonLines[T any](t *testing.T, path string) []T {
75 t.Helper()
76 f, err := os.Open(path)
77 if err != nil {
78 t.Fatal(err)
79 }
80 defer f.Close()
81 var rows []T
82 scanner := bufio.NewScanner(f)
83 for scanner.Scan() {
84 var row T
85 if err := json.Unmarshal(scanner.Bytes(), &row); err != nil {
86 t.Fatalf("%s: %v", path, err)
87 }
88 rows = append(rows, row)
89 }
90 if err := scanner.Err(); err != nil {
91 t.Fatal(err)
92 }
93 if len(rows) == 0 {
94 t.Fatalf("empty trace: %s", path)
95 }
96 return rows
97}
98
99func start(t *testing.T, bin, scale string, frames int, alpha bool) run {
100 return startSized(t, bin, scale, frames, alpha, "800x600")
101}
102func startSized(t *testing.T, bin, scale string, frames int, alpha bool, output string) run {
103 return startSizedMode(t, bin, scale, frames, alpha, output, false)
104}
105func startSizedMode(t *testing.T, bin, scale string, frames int, alpha bool, output string, list bool) run {
106 t.Helper()
107 root := filepath.Join(t.TempDir(), "artifacts")
108 args := []string{bin, "--static", "--frames", fmt.Sprint(frames)}
109 if list {
110 args = append(args, "--list")
111 }
112 if alpha {
113 args = append(args, "--transparent")
114 }
115 res, code := harness.Run(context.Background(), harness.Options{Size: output, Scale: scale, Layout: "us", Background: background, Out: root, Timeout: 60 * time.Second, ReadyTimeout: 10 * time.Second, Client: args})
116 t.Logf("artifacts=%s code=%d status=%s", root, code, res.Status)
117 if code != harness.Pass {
118 data, _ := os.ReadFile(filepath.Join(root, "logs", "client.stderr"))
119 t.Fatalf("harness: code=%d error=%s client stderr=%s", code, res.Error, data)
120 }
121 if res.Artifacts["capture"] == "" {
122 t.Fatal("static client did not request a mapped capture")
123 }
124 capture, err := harness.LoadPNG(res.Artifacts["capture"])
125 if err != nil {
126 t.Fatal(err)
127 }
128 files, err := filepath.Glob(filepath.Join(res.Artifacts["debug"], "readback-*.png"))
129 if err != nil || len(files) == 0 {
130 t.Fatalf("readback: %v %v", files, err)
131 }
132 sort.Strings(files)
133 readback, err := harness.LoadPNG(files[len(files)-1])
134 if err != nil {
135 t.Fatal(err)
136 }
137 var s summary
138 data, err := os.ReadFile(filepath.Join(res.Artifacts["debug"], "resource-summary.json"))
139 if err != nil {
140 t.Fatal(err)
141 }
142 if err := json.Unmarshal(data, &s); err != nil {
143 t.Fatal(err)
144 }
145 return run{root, res, capture, readback, jsonLines[timing](t, filepath.Join(res.Artifacts["debug"], "frame-timings.jsonl")), jsonLines[lifecycle](t, filepath.Join(res.Artifacts["debug"], "buffer-lifecycle.jsonl")), s}
146}
147
148func size(img image.Image, w, h int) error {
149 if img.Bounds().Dx() != w || img.Bounds().Dy() != h {
150 return fmt.Errorf("size %v, want %dx%d", img.Bounds(), w, h)
151 }
152 return nil
153}
154func pixel(img image.Image, x, y int, want color.NRGBA) error {
155 return harness.PixelProbe(img, x, y, want, 2)
156}
157func check(t *testing.T, img image.Image, x, y int, want color.NRGBA) {
158 t.Helper()
159 if err := pixel(img, x, y, want); err != nil {
160 t.Fatal(err)
161 }
162}
163
164// NeferWL configures the example's 400x300 toplevel to 800x600 logical.
165func finalSize() (int, int) { return 800, 600 }
166
167func TestOpaque(t *testing.T) {
168 requireHarness(t)
169 r := start(t, binary(t), "1", 24, false)
170 w, h := finalSize()
171 if err := size(r.readback, w, h); err != nil {
172 t.Fatal(err)
173 }
174 check(t, r.readback, 0, 0, color.NRGBA{})
175 check(t, r.readback, w/2, h/2, opaque)
176 if err := size(r.capture, w, h); err != nil {
177 t.Fatal(err)
178 }
179 // The opaque window covers the output: its clear is black. NeferWL's
180 // presentation path applies a color transform to the orange rect.
181 check(t, r.capture, 0, 0, color.NRGBA{0, 0, 0, 255})
182 check(t, r.capture, w/2, h/2, color.NRGBA{251, 158, 65, 255})
183}
184func TestTransparent(t *testing.T) {
185 requireHarness(t)
186 r := start(t, binary(t), "1", 24, true)
187 w, h := finalSize()
188 if err := size(r.readback, w, h); err != nil {
189 t.Fatal(err)
190 }
191 check(t, r.readback, 0, 0, color.NRGBA{})
192 // NRGBA conversion unpremultiplies; inspect the encoded bytes directly.
193 rgba, ok := r.readback.(*image.NRGBA)
194 if !ok {
195 t.Fatalf("readback format %T", r.readback)
196 }
197 got := rgba.NRGBAAt(w/2, h/2)
198 for i, pair := range [][2]uint8{{got.R, transparent.R}, {got.G, transparent.G}, {got.B, transparent.B}, {got.A, transparent.A}} {
199 if math.Abs(float64(pair[0])-float64(pair[1])) > 2 {
200 t.Fatalf("premultiplied channel %d: got %v want %v", i, got, transparent)
201 }
202 }
203}
204func TestResize(t *testing.T) {
205 requireHarness(t)
206 r := start(t, binary(t), "1", 24, false)
207 max := uint64(0)
208 old := map[uint64]bool{}
209 retired := map[uint64]bool{}
210 for _, v := range r.timing {
211 if v.Generation > max {
212 max = v.Generation
213 }
214 if v.Generation == 1 {
215 old[v.Buffer] = true
216 }
217 }
218 if max <= 1 || len(old) == 0 {
219 t.Fatalf("missing configure: generation=%d old=%v", max, old)
220 }
221 for _, v := range r.lifecycle {
222 if old[v.Buffer] && (v.Event == "retired" || v.Event == "destroyed") {
223 retired[v.Buffer] = true
224 }
225 }
226 if len(retired) != len(old) {
227 t.Fatalf("old images not retired: old=%v retired=%v", old, retired)
228 }
229 w, h := finalSize()
230 if err := size(r.readback, w, h); err != nil {
231 t.Fatal(err)
232 }
233}
234func TestScale(t *testing.T) {
235 requireHarness(t)
236 bin := binary(t)
237 for _, tc := range []struct {
238 name string
239 factor float64
240 }{{"1.5", 1.5}, {"2", 2}} {
241 t.Run(tc.name, func(t *testing.T) {
242 // Use a physical output of 800x600 logical pixels at this scale.
243 // The compositor's --size is in physical output pixels.
244 r := startSized(t, bin, tc.name, 24, false, fmt.Sprintf("%dx%d", int(math.Ceil(800*tc.factor)), int(math.Ceil(600*tc.factor))))
245 w, h := int(math.Ceil(800*tc.factor)), int(math.Ceil(600*tc.factor))
246 if err := size(r.readback, w, h); err != nil {
247 t.Fatal(err)
248 }
249 check(t, r.readback, 0, 0, color.NRGBA{})
250 check(t, r.readback, w/2, h/2, opaque)
251 // Two pixels inside/outside the scaled 0.25 x boundary.
252 check(t, r.readback, w/4-3, h/2, color.NRGBA{})
253 check(t, r.readback, w/4+3, h/2, opaque)
254 })
255 }
256}
257func TestResources(t *testing.T) {
258 requireHarness(t)
259 r := start(t, binary(t), "1", 120, false)
260 s := r.summary
261 t.Logf("frames=%d fd_start=%d fd_peak=%d fd_after=%d images_after=%d", s.Frames, s.FDStart, s.FDPeak, s.FDAfter, s.ImagesAfter)
262 if s.Frames != 120 || len(r.timing) != 120 || s.ImagesAfter != 0 || s.FDPeak-s.FDStart > 2 || s.FDAfter > s.FDStart {
263 t.Fatalf("unbounded or leaked: %+v trace=%d", s, len(r.timing))
264 }
265 for _, v := range r.timing {
266 if v.Images > 6 || v.Images < 1 || v.FDCount > s.FDStart+2 {
267 t.Fatalf("frame %+v", v)
268 }
269 }
270}
271func TestValidation(t *testing.T) {
272 requireHarness(t)
273 if os.Getenv("NEFERGUI_VULKAN_VALIDATION") != "1" {
274 t.Skip("set NEFERGUI_VULKAN_VALIDATION=1 for Vulkan layer acceptance")
275 }
276 // vkconfig/vulkaninfo are not required: the loader resolves installed layers.
277 // A missing layer is a skip, not an untested successful run.
278 if !validationLayerAvailable() {
279 t.Skip("VK_LAYER_KHRONOS_validation not installed")
280 }
281 r := start(t, binary(t), "1", 30, false)
282 if r.summary.ImagesAfter != 0 || r.summary.FDAfter > r.summary.FDStart {
283 t.Fatalf("leaked objects: %+v", r.summary)
284 }
285 data, err := os.ReadFile(r.result.Artifacts["client_stderr"])
286 if err != nil {
287 t.Fatal(err)
288 }
289 if regexp.MustCompile(`(?i)(NEFERGUI_VULKAN_VALIDATION_(ERROR|WARNING)|validation (error|warning))`).Match(data) {
290 t.Fatalf("validation messages: %s", data)
291 }
292}
293func validationLayerAvailable() bool {
294 paths := []string{"/usr/share/vulkan/explicit_layer.d", "/etc/vulkan/explicit_layer.d"}
295 if v := os.Getenv("VK_LAYER_PATH"); v != "" {
296 paths = append(strings.Split(v, string(os.PathListSeparator)), paths...)
297 }
298 for _, dir := range paths {
299 files, _ := filepath.Glob(filepath.Join(dir, "*validation*.json"))
300 if len(files) > 0 {
301 return true
302 }
303 }
304 return false
305}
306
307func TestCheckersNonVacuous(t *testing.T) {
308 img := image.NewNRGBA(image.Rect(0, 0, 4, 4))
309 img.SetNRGBA(2, 2, opaque)
310 path := filepath.Join(t.TempDir(), "synthetic.png")
311 if err := harness.SavePNG(path, img); err != nil {
312 t.Fatal(err)
313 }
314 decoded, err := harness.LoadPNG(path)
315 if err != nil {
316 t.Fatal(err)
317 }
318 if err := pixel(decoded, 2, 2, opaque); err != nil {
319 t.Fatal(err)
320 }
321 if err := pixel(decoded, 2, 2, color.NRGBA{0, 0, 255, 255}); err == nil {
322 t.Fatal("wrong color accepted")
323 }
324 if err := size(decoded, 4, 4); err != nil {
325 t.Fatal(err)
326 }
327 if err := size(decoded, 5, 4); err == nil {
328 t.Fatal("wrong size accepted")
329 }
330}
331
332func TestListQuads(t *testing.T) {
333 requireHarness(t)
334 r := startSizedMode(t, binary(t), "1", 24, false, "800x600", true)
335 img := r.readback
336 probes := []struct {
337 x, y int
338 want color.NRGBA
339 }{
340 {100, 100, color.NRGBA{}}, // rounded corner
341 {120, 120, color.NRGBA{255, 0, 0, 255}}, // inside round
342 {340, 160, color.NRGBA{}}, // outside green clip
343 {250, 160, color.NRGBA{0, 255, 0, 255}}, // clipped green fill
344 {160, 200, color.NRGBA{128, 0, 128, 255}}, // half-blue over red
345 }
346 for _, p := range probes {
347 got := color.NRGBAModel.Convert(img.At(p.x, p.y)).(color.NRGBA)
348 t.Logf("pixel (%d,%d)=%v want=%v", p.x, p.y, got, p.want)
349 for _, pair := range [][2]uint8{{got.R, p.want.R}, {got.G, p.want.G}, {got.B, p.want.B}, {got.A, p.want.A}} {
350 if math.Abs(float64(pair[0])-float64(pair[1])) > 3 {
351 t.Fatalf("pixel (%d,%d)=%v want=%v", p.x, p.y, got, p.want)
352 }
353 }
354 }
355}
356
357func TestListStyles(t *testing.T) {
358 requireHarness(t)
359 bin := binary(t)
360 for _, scale := range []int{1, 2} {
361 t.Run(fmt.Sprint(scale), func(t *testing.T) {
362 root := filepath.Join(t.TempDir(), "artifacts")
363 res, code := harness.Run(context.Background(), harness.Options{Size: "800x600", Scale: "1", Layout: "us", Background: background, Out: root, Timeout: 60 * time.Second, ReadyTimeout: 10 * time.Second, Client: []string{bin, "--static", "--styles", "--scale", fmt.Sprint(scale), "--frames", "24"}})
364 t.Logf("artifacts=%s code=%d status=%s", root, code, res.Status)
365 if code != harness.Pass {
366 data, _ := os.ReadFile(filepath.Join(root, "logs", "client.stderr"))
367 t.Fatalf("harness: %s %s", res.Error, data)
368 }
369 files, _ := filepath.Glob(filepath.Join(res.Artifacts["debug"], "readback-*.png"))
370 sort.Strings(files)
371 if len(files) == 0 {
372 t.Fatal("missing readback")
373 }
374 img, err := harness.LoadPNG(files[len(files)-1])
375 if err != nil {
376 t.Fatal(err)
377 }
378 bg := color.NRGBA{17, 17, 17, 255}
379 probes := []struct {
380 name string
381 x, y int
382 want color.NRGBA
383 }{
384 {"top border", 200 * scale, 123 * scale, color.NRGBA{255, 0, 0, 255}},
385 {"right border", 276 * scale, 180 * scale, color.NRGBA{0, 255, 0, 255}},
386 {"bottom border", 200 * scale, 236 * scale, color.NRGBA{0, 0, 255, 255}},
387 {"left border", 123 * scale, 180 * scale, color.NRGBA{255, 255, 0, 255}},
388 {"interior", 200 * scale, 180 * scale, color.NRGBA{255, 255, 255, 255}},
389 {"rounded corner", 113 * scale, 113 * scale, bg},
390 {"outline", 200 * scale, 114 * scale, color.NRGBA{255, 0, 255, 255}},
391 {"far", 200 * scale, 285 * scale, bg},
392 {"single-side left corner", 300 * scale, 43 * scale, color.NRGBA{255, 0, 0, 255}},
393 {"single-side right corner", 379 * scale, 43 * scale, color.NRGBA{255, 0, 0, 255}},
394 {"unequal corner top", 301 * scale, 242 * scale, color.NRGBA{255, 0, 0, 255}},
395 {"unequal corner left", 300 * scale, 246 * scale, color.NRGBA{0, 0, 255, 255}},
396 }
397 for _, p := range probes {
398 got := color.NRGBAModel.Convert(img.At(p.x, p.y)).(color.NRGBA)
399 t.Logf("scale=%d %s (%d,%d)=%v want=%v", scale, p.name, p.x, p.y, got, p.want)
400 if err := harness.PixelProbe(img, p.x, p.y, p.want, 3); err != nil {
401 t.Error(err)
402 }
403 }
404 x, y := 200*scale, 252*scale
405 shadow := color.NRGBAModel.Convert(img.At(x, y)).(color.NRGBA)
406 t.Logf("scale=%d shadow (%d,%d)=%v background=%v", scale, x, y, shadow, bg)
407 if shadow.R > bg.R-3 || shadow.G > bg.G-3 || shadow.B > bg.B-3 {
408 t.Errorf("shadow not darker: %v", shadow)
409 }
410 if os.Getenv("NEFERGUI_VULKAN_VALIDATION") == "1" {
411 data, err := os.ReadFile(res.Artifacts["client_stderr"])
412 if err != nil {
413 t.Fatal(err)
414 }
415 if regexp.MustCompile(`(?i)(NEFERGUI_VULKAN_VALIDATION_(ERROR|WARNING)|validation (error|warning))`).Match(data) {
416 t.Errorf("validation: %s", data)
417 }
418 }
419 })
420 }
421}