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}