1package atspi
2
3import (
4 "context"
5 "errors"
6 "fmt"
7 "runtime"
8 "testing"
9 "time"
10
11 "github.com/stretchr/testify/mock"
12 "github.com/stretchr/testify/require"
13
14 "github.com/bnema/neferwl/internal/ports"
15)
16
17const (
18 stActive = 1 << stateActive
19 stShowing = 1 << stateShowing
20)
21
22// tree answers calls like an app: registry with one app (:1.5, pid 77) with
23// one showing frame whose links sit at window-relative extents.
24func answerTree(frameExt [4]int32, matches []ref, ext map[string][4]int32, roles map[string]uint32, noCollection bool) func(time.Time, time.Duration, []call) []reply {
25 return func(_ time.Time, _ time.Duration, calls []call) []reply {
26 out := make([]reply, len(calls))
27 for i, c := range calls {
28 switch {
29 case c.op == opChildren && c.name == registryName:
30 out[i].refs = []ref{{":1.5", "/root"}, {":1.9", "/root"}}
31 case c.op == opPID:
32 out[i].u = map[string]uint32{":1.5": 77, ":1.9": 99}[c.name]
33 case c.op == opChildren && c.path == "/root":
34 out[i].refs = []ref{{":1.5", "/frame"}}
35 case c.op == opRole && c.path == "/frame":
36 out[i].u = roleFrame
37 case c.op == opState && c.path == "/frame":
38 out[i].state = stShowing | stActive
39 case c.op == opExtents && c.path == "/frame":
40 out[i].rect = frameExt
41 case c.op == opMatches:
42 if noCollection {
43 out[i].err = &busError{name: "org.freedesktop.DBus.Error.UnknownMethod"}
44 } else {
45 out[i].refs = matches
46 }
47 case c.op == opChildren && c.path == "/frame":
48 out[i].refs = matches
49 case c.op == opChildren:
50 case c.op == opRole:
51 out[i].u = roles[c.path]
52 case c.op == opState:
53 out[i].state = stShowing | 1<<stateVisible
54 case c.op == opExtents:
55 out[i].rect = ext[c.path]
56 }
57 }
58 return out
59 }
60}
61
62func TestTargetsCoversWindowAndConverts(t *testing.T) {
63 tr := newMocktransport(t)
64 matches := []ref{{":1.5", "/a"}, {":1.5", "/b"}, {":1.5", "/zero"}}
65 tr.EXPECT().Do(mock.Anything, mock.Anything, mock.Anything).RunAndReturn(answerTree(
66 [4]int32{4, 30, 800, 600}, matches,
67 map[string][4]int32{"/a": {14, 130, 50, 16}, "/b": {104, 230, 20, 20}, "/zero": {5, 5, 0, 0}},
68 map[string]uint32{"/a": roleLink, "/b": rolePushButton, "/zero": roleLink}, false))
69 s := &Source{tr: tr}
70
71 windows := []ports.HintWindow{
72 {ID: 1, PID: 77, Rect: ports.Rect{X: 100, Y: 200, W: 800, H: 600}, Focused: true},
73 {ID: 2, PID: 5, Rect: ports.Rect{X: 0, Y: 0, W: 50, H: 50}},
74 }
75 targets, covered, err := s.Targets(context.Background(), windows, false)
76 require.NoError(t, err)
77 require.Equal(t, map[ports.WindowID]bool{1: true}, covered)
78 require.Equal(t, []ports.HintTarget{
79 {Rect: ports.Rect{X: 110, Y: 300, W: 50, H: 16}, Kind: ports.HintLink, Window: 1},
80 {Rect: ports.Rect{X: 200, Y: 400, W: 20, H: 20}, Kind: ports.HintButton, Window: 1},
81 }, targets)
82}
83
84func TestTargetsLinksOnlyDropsButtons(t *testing.T) {
85 tr := newMocktransport(t)
86 matches := []ref{{":1.5", "/a"}, {":1.5", "/b"}}
87 tr.EXPECT().Do(mock.Anything, mock.Anything, mock.Anything).RunAndReturn(answerTree(
88 [4]int32{0, 0, 800, 600}, matches,
89 map[string][4]int32{"/a": {14, 130, 50, 16}, "/b": {104, 230, 20, 20}},
90 map[string]uint32{"/a": roleLink, "/b": rolePushButton}, false))
91 s := &Source{tr: tr}
92 targets, _, err := s.Targets(context.Background(), []ports.HintWindow{{ID: 1, PID: 77, Rect: ports.Rect{W: 800, H: 600}}}, true)
93 require.NoError(t, err)
94 require.Len(t, targets, 1)
95 require.Equal(t, ports.HintLink, targets[0].Kind)
96}
97
98func TestTargetsWalkWithoutCollection(t *testing.T) {
99 tr := newMocktransport(t)
100 matches := []ref{{":1.5", "/a"}}
101 tr.EXPECT().Do(mock.Anything, mock.Anything, mock.Anything).RunAndReturn(answerTree(
102 [4]int32{0, 0, 800, 600}, matches,
103 map[string][4]int32{"/a": {14, 130, 50, 16}},
104 map[string]uint32{"/a": roleLink}, true))
105 s := &Source{tr: tr}
106 targets, covered, err := s.Targets(context.Background(), []ports.HintWindow{{ID: 1, PID: 77, Rect: ports.Rect{W: 800, H: 600}}}, false)
107 require.NoError(t, err)
108 require.Len(t, targets, 1)
109 require.True(t, covered[1])
110}
111
112func TestTargetsNoPIDMatchCoversNothing(t *testing.T) {
113 tr := newMocktransport(t)
114 tr.EXPECT().Do(mock.Anything, mock.Anything, mock.Anything).RunAndReturn(answerTree([4]int32{}, nil, nil, nil, false))
115 s := &Source{tr: tr}
116 targets, covered, err := s.Targets(context.Background(), []ports.HintWindow{{ID: 1, PID: 1234, Rect: ports.Rect{W: 8, H: 6}}}, false)
117 require.NoError(t, err)
118 require.Empty(t, targets)
119 require.Empty(t, covered)
120}
121
122func TestTargetsWithoutPIDsAsksNothing(t *testing.T) {
123 s := &Source{tr: newMocktransport(t)}
124 _, covered, err := s.Targets(context.Background(), []ports.HintWindow{{ID: 1, Rect: ports.Rect{W: 8, H: 6}}}, false)
125 require.NoError(t, err)
126 require.Empty(t, covered)
127}
128
129func TestCoverageRequiresLinkOrDocument(t *testing.T) {
130 for _, collection := range []bool{true, false} {
131 for _, role := range []uint32{rolePushButton, roleDocumentFrame, roleDocumentWeb, roleLink} {
132 t.Run(fmt.Sprintf("collection=%t/role=%d", collection, role), func(t *testing.T) {
133 tr := newMocktransport(t)
134 tr.EXPECT().Do(mock.Anything, mock.Anything, mock.Anything).RunAndReturn(answerTree(
135 [4]int32{0, 0, 800, 600}, []ref{{":1.5", "/a"}}, map[string][4]int32{"/a": {10, 20, 30, 40}}, map[string]uint32{"/a": role}, !collection))
136 s := &Source{tr: tr}
137 targets, covered, err := s.Targets(context.Background(), []ports.HintWindow{{ID: 1, PID: 77, Rect: ports.Rect{W: 800, H: 600}}}, false)
138 require.NoError(t, err)
139 require.Equal(t, role != rolePushButton, covered[1])
140 if isDocument(role) {
141 require.Empty(t, targets)
142 } else {
143 require.Len(t, targets, 1)
144 }
145 })
146 }
147 }
148}
149
150var oneWindow = []ports.HintWindow{{ID: 1, PID: 77, Rect: ports.Rect{W: 800, H: 600}}}
151
152// New does not touch the bus; the first Targets call connects, once.
153func TestConnectsLazilyOnFirstTargets(t *testing.T) {
154 tr := newMocktransport(t)
155 tr.EXPECT().Do(mock.Anything, mock.Anything, mock.Anything).RunAndReturn(answerTree(
156 [4]int32{0, 0, 800, 600}, []ref{{":1.5", "/a"}}, map[string][4]int32{"/a": {1, 1, 9, 9}}, map[string]uint32{"/a": roleLink}, false))
157 c := newMockconnector(t)
158 c.EXPECT().Connect(mock.Anything).Return(tr, nil).Once()
159 s := &Source{dial: c}
160 for range 2 {
161 targets, _, err := s.Targets(context.Background(), oneWindow, false)
162 require.NoError(t, err)
163 require.Len(t, targets, 1)
164 }
165}
166
167// Without PIDs nothing is asked and nothing is connected.
168func TestNoConnectWithoutWindows(t *testing.T) {
169 s := &Source{dial: newMockconnector(t)}
170 _, _, err := s.Targets(context.Background(), []ports.HintWindow{{ID: 1}}, false)
171 require.NoError(t, err)
172}
173
174// A failed connection is retried no sooner than retryEvery; meanwhile no
175// targets are returned.
176func TestConnectRetriesWithBackoff(t *testing.T) {
177 c := newMockconnector(t)
178 c.EXPECT().Connect(mock.Anything).Return(nil, errors.New("down")).Once()
179 s := &Source{dial: c}
180 for range 3 {
181 targets, covered, err := s.Targets(context.Background(), oneWindow, false)
182 require.NoError(t, err)
183 require.Empty(t, targets)
184 require.Empty(t, covered)
185 }
186 tr := newMocktransport(t)
187 tr.EXPECT().Do(mock.Anything, mock.Anything, mock.Anything).RunAndReturn(answerTree(
188 [4]int32{0, 0, 800, 600}, []ref{{":1.5", "/a"}}, map[string][4]int32{"/a": {1, 1, 9, 9}}, map[string]uint32{"/a": roleLink}, false))
189 c.EXPECT().Connect(mock.Anything).Return(tr, nil).Once()
190 s.retryAt = time.Now().Add(-time.Second)
191 targets, _, err := s.Targets(context.Background(), oneWindow, false)
192 require.NoError(t, err)
193 require.Len(t, targets, 1)
194}
195
196// A broken connection is closed and dropped; the next attempt reconnects.
197func TestBrokenConnectionIsDropped(t *testing.T) {
198 tr := newMocktransport(t)
199 tr.EXPECT().Do(mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
200 func(_ time.Time, _ time.Duration, calls []call) []reply {
201 out := make([]reply, len(calls))
202 for i := range out {
203 out[i].err = ErrBroken
204 }
205 return out
206 }).Once()
207 tr.EXPECT().Close().Return(nil).Once()
208 c := newMockconnector(t)
209 c.EXPECT().Connect(mock.Anything).Return(tr, nil).Once()
210 s := &Source{dial: c}
211 _, _, err := s.Targets(context.Background(), oneWindow, false)
212 require.Error(t, err)
213 require.Nil(t, s.tr)
214 targets, _, err := s.Targets(context.Background(), oneWindow, false)
215 require.NoError(t, err)
216 require.Empty(t, targets) // backoff: no new attempt yet
217}
218
219const testWait = 20 * time.Millisecond
220
221// blockedConnector returns a connector whose Connect blocks until release is
222func blockedConnector(t *testing.T, release <-chan struct{}, tr transport, err error, times int) *mockconnector {
223 t.Helper()
224 c := newMockconnector(t)
225 c.EXPECT().Connect(mock.Anything).RunAndReturn(func(context.Context) (transport, error) {
226 <-release
227 return tr, err
228 }).Times(times)
229 return c
230}
231
232// A silent service cannot hold Targets beyond the wait, is not doubled while
233// in flight, and its late transport is used.
234func TestConnectIsBoundedAndNotDoubled(t *testing.T) {
235 release := make(chan struct{})
236 tr := newMocktransport(t)
237 tr.EXPECT().Do(mock.Anything, mock.Anything, mock.Anything).RunAndReturn(answerTree(
238 [4]int32{0, 0, 800, 600}, []ref{{":1.5", "/a"}}, map[string][4]int32{"/a": {1, 1, 9, 9}}, map[string]uint32{"/a": roleLink}, false))
239 c := blockedConnector(t, release, tr, nil, 1)
240 s := &Source{dial: c, wait: testWait}
241 t0 := time.Now()
242 targets, _, err := s.Targets(context.Background(), oneWindow, false)
243 require.NoError(t, err)
244 require.Empty(t, targets)
245 require.Less(t, time.Since(t0), testWait+200*time.Millisecond)
246 require.NotNil(t, s.attempt)
247 // Still in flight: no second Connect (Times(1)), and no blocking.
248 targets, _, err = s.Targets(context.Background(), oneWindow, false)
249 require.NoError(t, err)
250 require.Empty(t, targets)
251 // It connects late: the next call uses it.
252 close(release)
253 require.Eventually(t, func() bool { return len(s.attempt) == 1 }, time.Second, time.Millisecond)
254 targets, _, err = s.Targets(context.Background(), oneWindow, false)
255 require.NoError(t, err)
256 require.Len(t, targets, 1)
257 require.Nil(t, s.attempt)
258}
259
260// An abandoned attempt that fails applies the backoff, then one new attempt.
261func TestAbandonedAttemptErrorBacksOff(t *testing.T) {
262 release := make(chan struct{})
263 tr := newMocktransport(t)
264 tr.EXPECT().Do(mock.Anything, mock.Anything, mock.Anything).RunAndReturn(answerTree(
265 [4]int32{0, 0, 800, 600}, []ref{{":1.5", "/a"}}, map[string][4]int32{"/a": {1, 1, 9, 9}}, map[string]uint32{"/a": roleLink}, false))
266 c := newMockconnector(t)
267 c.EXPECT().Connect(mock.Anything).RunAndReturn(func(context.Context) (transport, error) {
268 <-release
269 return nil, errors.New("down")
270 }).Once()
271 c.EXPECT().Connect(mock.Anything).Return(tr, nil).Once()
272 s := &Source{dial: c, wait: testWait}
273 targets, _, err := s.Targets(context.Background(), oneWindow, false)
274 require.NoError(t, err)
275 require.Empty(t, targets)
276 close(release)
277 require.Eventually(t, func() bool { return len(s.attempt) == 1 }, time.Second, time.Millisecond)
278 // The error is collected; the backoff runs from its finish time.
279 targets, _, err = s.Targets(context.Background(), oneWindow, false)
280 require.NoError(t, err)
281 require.Empty(t, targets)
282 require.Nil(t, s.attempt)
283 require.WithinDuration(t, time.Now().Add(retryEvery), s.retryAt, time.Second)
284 targets, _, err = s.Targets(context.Background(), oneWindow, false) // backoff: no Connect
285 require.NoError(t, err)
286 require.Empty(t, targets)
287 s.retryAt = time.Now().Add(-time.Second)
288 targets, _, err = s.Targets(context.Background(), oneWindow, false)
289 require.NoError(t, err)
290 require.Len(t, targets, 1)
291}
292
293// Close with an attempt in flight closes the late transport; afterwards
294// Targets finds nothing and never connects.
295func TestCloseReapsLateTransport(t *testing.T) {
296 release := make(chan struct{})
297 closed := make(chan struct{})
298 tr := newMocktransport(t)
299 tr.EXPECT().Close().RunAndReturn(func() error { close(closed); return nil }).Once()
300 c := blockedConnector(t, release, tr, nil, 1)
301 s := &Source{dial: c, wait: testWait}
302 _, _, err := s.Targets(context.Background(), oneWindow, false)
303 require.NoError(t, err)
304 require.NoError(t, s.Close())
305 close(release)
306 select {
307 case <-closed:
308 case <-time.After(5 * time.Second):
309 t.Fatal("late transport not closed")
310 }
311 targets, covered, err := s.Targets(context.Background(), oneWindow, false)
312 require.NoError(t, err)
313 require.Empty(t, targets)
314 require.Empty(t, covered)
315 require.NoError(t, s.Close()) // idempotent
316}
317
318func TestWalkWideTreeStaysWithinBudget(t *testing.T) {
319 tr := newMocktransport(t)
320 children := make([]ref, 4096)
321 for i := range children {
322 children[i] = ref{":1.5", fmt.Sprintf("/node%d", i)}
323 }
324 visited := 0
325 tr.EXPECT().Do(mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
326 func(_ time.Time, _ time.Duration, calls []call) []reply {
327 visited += len(calls) / 3
328 require.LessOrEqual(t, visited, maxWalkNodes)
329 rep := make([]reply, len(calls))
330 for i, c := range calls {
331 switch c.op {
332 case opChildren:
333 rep[i].refs = children
334 case opRole:
335 rep[i].u = roleLink
336 case opState:
337 rep[i].state = stShowing | 1<<stateVisible
338 }
339 }
340 return rep
341 }).Twice()
342 s := &Source{tr: tr}
343 // A full second level replies with another 4096 children each. There is
344 // no remaining budget, so none of those millions of refs may be copied.
345 var out []ref
346 var before, after runtime.MemStats
347 runtime.ReadMemStats(&before)
348 out = s.walk(func(calls []call) []reply { return tr.Do(time.Time{}, time.Second, calls) }, ref{":1.5", "/frame"}, false)
349 runtime.ReadMemStats(&after)
350 require.Len(t, out, maxWalkNodes)
351 require.Equal(t, maxWalkNodes, visited)
352 // Normal replies and call batches take only a few MB; the old append-
353 // then-truncate path copied hundreds of MB for this tree.
354 require.Less(t, after.TotalAlloc-before.TotalAlloc, uint64(32<<20))
355}