v0.1.0 47aaa0f

harness_test.go RawHistoryFolder

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}

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