main f2d166b

hints_test.go RawHistoryFolder

1package hints
2
3import (
4	"context"
5	"encoding/json"
6	"errors"
7	"fmt"
8	"image"
9	"image/draw"
10	_ "image/png"
11	"os"
12	"os/exec"
13	"path/filepath"
14	"strings"
15	"testing"
16	"time"
17
18	"github.com/stretchr/testify/mock"
19	"github.com/stretchr/testify/require"
20
21	"github.com/bnema/neferwl/internal/ports"
22)
23
24type harness struct {
25	requests chan ports.HintScanRequest
26	captures chan ports.CaptureRequest
27	captured chan ports.CaptureDone
28	results  chan ports.HintScanResult
29}
30
31func start(t *testing.T, timeout time.Duration) *harness {
32	return startWith(t, timeout, Options{})
33}
34
35// startCapturing starts a worker that always captures: debug dumps are on.
36func startCapturing(t *testing.T, timeout time.Duration) *harness {
37	return startWith(t, timeout, Options{DumpDir: filepath.Join(t.TempDir(), "hints")})
38}
39
40func startWith(t *testing.T, timeout time.Duration, opts Options) *harness {
41	t.Helper()
42	h := &harness{
43		requests: make(chan ports.HintScanRequest, 1),
44		captures: make(chan ports.CaptureRequest, 4),
45		captured: make(chan ports.CaptureDone, 1),
46		results:  make(chan ports.HintScanResult, 4),
47	}
48	ctx, cancel := context.WithCancel(context.Background())
49	exited := make(chan struct{})
50	go func() {
51		defer close(exited)
52		_ = run(ctx, h.requests, h.captures, h.captured, h.results, timeout, opts)
53	}()
54	t.Cleanup(func() { cancel(); <-exited })
55	return h
56}
57
58func loadBGRA(t *testing.T) (pix []byte, w, hgt int) {
59	t.Helper()
60	f, err := os.Open("testdata/browser.png")
61	require.NoError(t, err)
62	defer f.Close()
63	img, _, err := image.Decode(f)
64	require.NoError(t, err)
65	b := img.Bounds()
66	rgba := image.NewRGBA(image.Rect(0, 0, b.Dx(), b.Dy()))
67	draw.Draw(rgba, rgba.Bounds(), img, b.Min, draw.Src)
68	for i := 0; i < len(rgba.Pix); i += 4 {
69		rgba.Pix[i], rgba.Pix[i+2] = rgba.Pix[i+2], rgba.Pix[i]
70		rgba.Pix[i+3] = 0xff
71	}
72	return rgba.Pix, b.Dx(), b.Dy()
73}
74
75func TestHintScan(t *testing.T) {
76	if _, err := exec.LookPath("tesseract"); err != nil {
77		t.Skip("tesseract not on PATH")
78	}
79	pix, w, hgt := loadBGRA(t)
80	h := start(t, 5*time.Second)
81
82	// Plays the output owner.
83	go func() {
84		creq := <-h.captures
85		copy(creq.Mapped, pix)
86		h.captured <- ports.CaptureDone{ID: creq.ID, Output: creq.Output}
87	}()
88
89	h.requests <- ports.HintScanRequest{Seq: 7, Output: "HEADLESS-1", Width: w, Height: hgt, Scale: 1, Windows: []ports.HintWindow{{ID: 1, Rect: ports.Rect{W: w, H: hgt}}}}
90	res := <-h.results
91	require.NoError(t, res.Err)
92	require.Equal(t, uint64(7), res.Seq)
93	require.Equal(t, "HEADLESS-1", res.Output)
94	var links int
95	for _, tg := range res.Targets {
96		require.Positive(t, tg.Rect.W)
97		require.Positive(t, tg.Rect.H)
98		if tg.Kind == ports.HintLink {
99			links++
100		}
101	}
102	require.GreaterOrEqual(t, links, 1)
103}
104
105func TestHintRequestShape(t *testing.T) {
106	h := startCapturing(t, 5*time.Second)
107	got := make(chan ports.CaptureRequest, 1)
108	go func() {
109		creq := <-h.captures
110		got <- creq
111		h.captured <- ports.CaptureDone{ID: creq.ID + 1}                  // another ID: ignored
112		h.captured <- ports.CaptureDone{ID: creq.ID, Output: creq.Output} // blank frame
113	}()
114	h.requests <- ports.HintScanRequest{Seq: 1, Output: "o", Width: 64, Height: 32, Scale: 1}
115	res := <-h.results
116	require.NoError(t, res.Err)
117	creq := <-got
118	require.NotZero(t, creq.ID&ports.CaptureIDInternal)
119	require.False(t, creq.Indicate)
120	require.Equal(t, image.Rect(0, 0, 64, 32), creq.Region)
121	require.Len(t, creq.Mapped, 64*32*4)
122	require.Equal(t, 64*4, creq.Stride)
123	require.Equal(t, uint32(1), creq.Format)
124}
125
126func TestHintCaptureError(t *testing.T) {
127	h := startCapturing(t, 5*time.Second)
128	boom := errors.New("boom")
129	go func() {
130		creq := <-h.captures
131		h.captured <- ports.CaptureDone{ID: creq.ID, Err: boom}
132	}()
133	h.requests <- ports.HintScanRequest{Seq: 3, Output: "o", Width: 64, Height: 32, Scale: 1}
134	res := <-h.results
135	require.ErrorIs(t, res.Err, boom)
136	require.Equal(t, uint64(3), res.Seq)
137	require.Empty(t, res.Targets)
138}
139
140func TestHintTimeout(t *testing.T) {
141	h := startCapturing(t, 50*time.Millisecond)
142	h.requests <- ports.HintScanRequest{Seq: 4, Output: "o", Width: 64, Height: 32, Scale: 1}
143	res := <-h.results
144	require.ErrorIs(t, res.Err, ErrTimeout)
145	require.Equal(t, uint64(4), res.Seq)
146}
147
148func TestHintBusy(t *testing.T) {
149	h := startCapturing(t, 5*time.Second)
150	h.requests <- ports.HintScanRequest{Seq: 1, Output: "o", Width: 64, Height: 32, Scale: 1}
151	creq := <-h.captures
152	h.requests <- ports.HintScanRequest{Seq: 2, Output: "o", Width: 64, Height: 32, Scale: 1}
153	res := <-h.results
154	require.ErrorIs(t, res.Err, ErrBusy)
155	require.Equal(t, uint64(2), res.Seq)
156	h.captured <- ports.CaptureDone{ID: creq.ID}
157	res = <-h.results
158	require.NoError(t, res.Err)
159	require.Equal(t, uint64(1), res.Seq)
160}
161
162func TestHintBadSize(t *testing.T) {
163	h := start(t, time.Second)
164	h.requests <- ports.HintScanRequest{Seq: 5, Output: "o"}
165	res := <-h.results
166	require.ErrorIs(t, res.Err, ErrBadRequest)
167}
168
169func scanOnce(t *testing.T, h *harness, pix []byte, w, hgt int, seq uint64) ports.HintScanResult {
170	t.Helper()
171	go func() {
172		creq := <-h.captures
173		copy(creq.Mapped, pix)
174		h.captured <- ports.CaptureDone{ID: creq.ID, Output: creq.Output}
175	}()
176	h.requests <- ports.HintScanRequest{Seq: seq, Output: "HEADLESS-1", Width: w, Height: hgt, Scale: 1, Windows: []ports.HintWindow{{ID: 1, Rect: ports.Rect{W: w, H: hgt}}}}
177	res := <-h.results
178	require.NoError(t, res.Err)
179	return res
180}
181
182// waitFiles waits for the dump the worker writes after sending the result.
183func waitFiles(t *testing.T, dir string, n int) []os.DirEntry {
184	t.Helper()
185	var entries []os.DirEntry
186	require.Eventually(t, func() bool {
187		entries, _ = os.ReadDir(dir)
188		return len(entries) >= n
189	}, 5*time.Second, 10*time.Millisecond)
190	return entries
191}
192
193func TestHintDump(t *testing.T) {
194	if _, err := exec.LookPath("tesseract"); err != nil {
195		t.Skip("tesseract not on PATH")
196	}
197	pix, w, hgt := loadBGRA(t)
198	dir := filepath.Join(t.TempDir(), "hints")
199	h := startWith(t, 5*time.Second, Options{DumpDir: dir})
200	res := scanOnce(t, h, pix, w, hgt, 1)
201	require.NotEmpty(t, res.Targets)
202
203	entries := waitFiles(t, dir, 3)
204	// A second request is only served once the dump is written.
205	_ = scanOnceDone(t, h)
206	var png, ann, js string
207	for _, e := range entries {
208		info, err := e.Info()
209		require.NoError(t, err)
210		require.Equal(t, os.FileMode(0o600), info.Mode().Perm())
211		switch {
212		case strings.HasSuffix(e.Name(), ".annotated.png"):
213			ann = e.Name()
214		case strings.HasSuffix(e.Name(), ".png"):
215			png = e.Name()
216		case strings.HasSuffix(e.Name(), ".json"):
217			js = e.Name()
218		}
219	}
220	require.NotEmpty(t, png)
221	require.NotEmpty(t, ann)
222	require.Regexp(t, `^\d{8}-\d{6}\.\d{3}-HEADLESS-1\.json$`, js)
223
224	st, err := os.Stat(dir)
225	require.NoError(t, err)
226	require.Equal(t, os.FileMode(0o700), st.Mode().Perm())
227
228	data, err := os.ReadFile(filepath.Join(dir, js))
229	require.NoError(t, err)
230	var meta struct {
231		Output    string  `json:"output"`
232		Width     int     `json:"width"`
233		Height    int     `json:"height"`
234		Stride    int     `json:"stride"`
235		Scale     float64 `json:"scale"`
236		LinksOnly bool    `json:"links_only"`
237		Targets   []struct {
238			Kind string `json:"kind"`
239			Rect [4]int `json:"rect"`
240		} `json:"targets"`
241	}
242	require.NoError(t, json.Unmarshal(data, &meta))
243	require.Equal(t, "HEADLESS-1", meta.Output)
244	require.Equal(t, w, meta.Width)
245	require.Equal(t, hgt, meta.Height)
246	require.Equal(t, w*4, meta.Stride)
247	require.Equal(t, 1.0, meta.Scale)
248	require.Len(t, meta.Targets, len(res.Targets))
249	for i, tg := range res.Targets {
250		require.Equal(t, [4]int{tg.Rect.X, tg.Rect.Y, tg.Rect.X + tg.Rect.W, tg.Rect.Y + tg.Rect.H}, meta.Targets[i].Rect)
251	}
252
253	f, err := os.Open(filepath.Join(dir, png))
254	require.NoError(t, err)
255	defer f.Close()
256	img, _, err := image.Decode(f)
257	require.NoError(t, err)
258	require.Equal(t, image.Rect(0, 0, w, hgt), img.Bounds())
259	r, g, b, _ := img.At(0, 0).RGBA()
260	require.Equal(t, uint32(pix[2])*0x101, r)
261	require.Equal(t, uint32(pix[1])*0x101, g)
262	require.Equal(t, uint32(pix[0])*0x101, b)
263}
264
265// scanOnceDone sends a request that fails fast on a blank capture and waits
266// for its result, which proves the previous dump finished.
267func scanOnceDone(t *testing.T, h *harness) ports.HintScanResult {
268	t.Helper()
269	go func() {
270		creq := <-h.captures
271		h.captured <- ports.CaptureDone{ID: creq.ID, Err: errors.New("stop")}
272	}()
273	h.requests <- ports.HintScanRequest{Seq: 99, Output: "x", Width: 8, Height: 8, Scale: 1, Windows: []ports.HintWindow{{ID: 1, Rect: ports.Rect{W: 8, H: 8}}}}
274	return <-h.results
275}
276
277func TestHintDumpDisabled(t *testing.T) {
278	if _, err := exec.LookPath("tesseract"); err != nil {
279		t.Skip("tesseract not on PATH")
280	}
281	pix, w, hgt := loadBGRA(t)
282	dir := filepath.Join(t.TempDir(), "hints")
283	h := start(t, 5*time.Second)
284	scanOnce(t, h, pix, w, hgt, 1)
285	scanOnceDone(t, h)
286	_, err := os.Stat(dir)
287	require.ErrorIs(t, err, os.ErrNotExist)
288}
289
290func TestHintDumpRetention(t *testing.T) {
291	dir := t.TempDir()
292	for i := 0; i < 60; i++ {
293		for _, ext := range []string{".png", ".annotated.png", ".json"} {
294			name := fmt.Sprintf("20260101-000000.%03d-o%s", i, ext)
295			require.NoError(t, os.WriteFile(filepath.Join(dir, name), nil, 0o600))
296		}
297	}
298	require.NoError(t, prune(dir, maxDumps))
299	entries, err := os.ReadDir(dir)
300	require.NoError(t, err)
301	require.Len(t, entries, maxDumps*3)
302	require.Equal(t, "20260101-000000.010-o.annotated.png", entries[0].Name())
303}
304
305func TestScanMergesSource(t *testing.T) {
306	pix, w, hgt := loadBGRA(t)
307	src := newMocktargetSource(t)
308	src.EXPECT().Close().Return(nil).Once() // the worker closes its source on return
309	wins := []ports.HintWindow{{ID: 4, PID: 9, Rect: ports.Rect{X: 0, Y: 0, W: w, H: hgt}}}
310	at := []ports.HintTarget{{Rect: ports.Rect{X: 5, Y: 5, W: 30, H: 10}, Kind: ports.HintLink}}
311	src.EXPECT().Targets(mock.Anything, wins, false).Return(at, map[ports.WindowID]bool{4: true}, nil).Once()
312	h := startWith(t, 5*time.Second, Options{Source: src})
313	go func() {
314		creq := <-h.captures
315		copy(creq.Mapped, pix)
316		h.captured <- ports.CaptureDone{ID: creq.ID, Output: creq.Output}
317	}()
318	h.requests <- ports.HintScanRequest{Seq: 1, Output: "HEADLESS-1", Width: w, Height: hgt, Scale: 1, Windows: wins}
319	res := <-h.results
320	require.NoError(t, res.Err)
321	// The whole frame is covered: only the accessibility target remains.
322	require.Equal(t, at, res.Targets)
323}
324
325func TestScanSourceErrorUsesOCR(t *testing.T) {
326	if _, err := exec.LookPath("tesseract"); err != nil {
327		t.Skip("tesseract not on PATH")
328	}
329	pix, w, hgt := loadBGRA(t)
330	src := newMocktargetSource(t)
331	src.EXPECT().Close().Return(nil).Once() // the worker closes its source on return
332	wins := []ports.HintWindow{{ID: 4, PID: 9, Rect: ports.Rect{W: w, H: hgt}}}
333	src.EXPECT().Targets(mock.Anything, wins, false).Return(nil, nil, errors.New("bus gone")).Once()
334	h := startWith(t, 5*time.Second, Options{Source: src})
335	go func() {
336		creq := <-h.captures
337		copy(creq.Mapped, pix)
338		h.captured <- ports.CaptureDone{ID: creq.ID, Output: creq.Output}
339	}()
340	h.requests <- ports.HintScanRequest{Seq: 1, Output: "HEADLESS-1", Width: w, Height: hgt, Scale: 1, Windows: wins}
341	res := <-h.results
342	require.NoError(t, res.Err)
343	require.NotEmpty(t, res.Targets)
344}
345
346// The worker keeps no frame: the mapped buffer belongs to the scan, and to the
347// dump job until it is written; every mapping is released afterwards.
348func TestWorkerHoldsNoFrameAfterScan(t *testing.T) {
349	for _, dumpDir := range []string{"", filepath.Join(t.TempDir(), "hints")} {
350		// OCR is "installed" but its binary cannot run: only the memory matters.
351		w := &worker{dumpDir: dumpDir, ocr: ocrState{done: true, binary: "/nonexistent/tesseract", lang: "eng"}}
352		requests := make(chan ports.HintScanRequest)
353		captures := make(chan ports.CaptureRequest, 1)
354		captured := make(chan ports.CaptureDone, 1)
355		results := make(chan ports.HintScanResult, 1)
356		go func() {
357			creq := <-captures
358			captured <- ports.CaptureDone{ID: creq.ID, Output: creq.Output}
359		}()
360		req := ports.HintScanRequest{Output: "o", Width: 64, Height: 32, Scale: 1, Windows: []ports.HintWindow{{ID: 1, Rect: ports.Rect{W: 64, H: 32}}}}
361		res := w.serve(context.Background(), req, requests, captures, captured, results, 5*time.Second)
362		require.NoError(t, res.Err)
363		if dumpDir == "" {
364			require.Nil(t, w.pending)
365			require.Zero(t, w.live, "frame and crops are released before the result")
366		} else {
367			require.NotNil(t, w.pending)
368			require.Equal(t, 1, w.live, "only the frame, kept for the dump")
369		}
370		w.dump(context.Background())
371		require.Nil(t, w.pending)
372		require.Zero(t, w.live)
373		require.Empty(t, w.cache) // OCR failed: nothing cached
374	}
375}
376
377// A capture that timed out stays mapped until the output answers it.
378func TestTimedOutCaptureIsReleasedWhenDone(t *testing.T) {
379	w := &worker{dumpDir: t.TempDir()}
380	captures := make(chan ports.CaptureRequest, 1)
381	captured := make(chan ports.CaptureDone, 1)
382	req := ports.HintScanRequest{Output: "o", Width: 64, Height: 32, Scale: 1}
383	res := w.serve(context.Background(), req, make(chan ports.HintScanRequest), captures, captured, make(chan ports.HintScanResult, 1), 20*time.Millisecond)
384	require.ErrorIs(t, res.Err, ErrTimeout)
385	creq := <-captures
386	require.Equal(t, 1, w.live)
387	creq.Mapped[0] = 1 // still valid: the output may write until CaptureDone
388	w.reap(creq.ID)
389	require.Zero(t, w.live)
390}
391
392// Without tesseract OCR is off for good: no capture, only accessibility targets.
393func TestNoTesseractSkipsCapture(t *testing.T) {
394	t.Setenv("PATH", t.TempDir())
395	button := ports.HintTarget{Kind: ports.HintButton, Rect: ports.Rect{W: 5, H: 5}}
396	covered := ports.HintTarget{Kind: ports.HintLink, Rect: ports.Rect{X: 30, W: 5, H: 5}}
397	wins := []ports.HintWindow{{ID: 1, Rect: ports.Rect{W: 20, H: 20}}, {ID: 2, Rect: ports.Rect{X: 20, W: 20, H: 20}}}
398	src := newMocktargetSource(t)
399	src.EXPECT().Close().Return(nil).Once() // the worker closes its source on return
400	src.EXPECT().Targets(mock.Anything, wins, false).Return([]ports.HintTarget{button, covered}, map[ports.WindowID]bool{2: true}, nil).Times(2)
401	h := startWith(t, time.Second, Options{Source: src})
402	for seq := uint64(1); seq <= 2; seq++ {
403		h.requests <- ports.HintScanRequest{Seq: seq, Output: "o", Width: 40, Height: 20, Scale: 1, Windows: wins}
404		res := <-h.results
405		require.NoError(t, res.Err)
406		require.Equal(t, []ports.HintTarget{button, covered}, res.Targets)
407	}
408	require.Empty(t, h.captures)
409}
410
411// With debug dumps on, the frame is still captured (for the dump only).
412func TestNoTesseractDumpStillCaptures(t *testing.T) {
413	t.Setenv("PATH", t.TempDir())
414	dir := filepath.Join(t.TempDir(), "hints")
415	h := startWith(t, 5*time.Second, Options{DumpDir: dir})
416	go func() {
417		creq := <-h.captures
418		h.captured <- ports.CaptureDone{ID: creq.ID, Output: creq.Output}
419	}()
420	h.requests <- ports.HintScanRequest{Seq: 1, Output: "o", Width: 40, Height: 20, Scale: 1, Windows: []ports.HintWindow{{ID: 1, Rect: ports.Rect{W: 40, H: 20}}}}
421	require.NoError(t, (<-h.results).Err)
422	waitFiles(t, dir, 3)
423}
424
425// A capture that completes after its scan timed out is released while the
426// worker is idle, without another scan, and its reply does not linger.
427func TestHintLateCaptureReaped(t *testing.T) {
428	h := startCapturing(t, 50*time.Millisecond)
429	h.requests <- ports.HintScanRequest{Seq: 1, Output: "o", Width: 64, Height: 32, Scale: 1}
430	creq := <-h.captures
431	res := <-h.results
432	require.ErrorIs(t, res.Err, ErrTimeout)
433	// The worker is idle; the output finally answers.
434	h.captured <- ports.CaptureDone{ID: creq.ID, Output: creq.Output}
435	require.Eventually(t, func() bool { return len(h.captured) == 0 }, time.Second, time.Millisecond)
436}

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 25

Tags 10

↑↓ moveenter openesc close