v0.1.0 47aaa0f

system_test.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 12

↑↓ moveenter openesc close