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