1package cef
2
3import (
4 "bytes"
5 "context"
6 "encoding/json"
7 "os"
8 "path/filepath"
9 "sync"
10 "sync/atomic"
11 "testing"
12 "time"
13
14 "github.com/bnema/dumber/internal/application/port"
15 "github.com/bnema/dumber/internal/logging"
16 "github.com/bnema/dumber/internal/shared/syncdispatch"
17 purecef "github.com/bnema/purego-cef/cef"
18 "github.com/rs/zerolog"
19 "github.com/stretchr/testify/assert"
20 "github.com/stretchr/testify/require"
21)
22
23func TestAccessibilityCapture_WritesNumberedJSONWithSecurePerms(t *testing.T) {
24 dir := t.TempDir()
25 capture, err := newAccessibilityCapture(dir)
26 require.NoError(t, err)
27
28 require.NoError(t, capture.Write(accessibilityPayload{Kind: accessibilityCaptureKindTree, JSON: `{"a":1}`}))
29 require.NoError(t, capture.Write(accessibilityPayload{Kind: accessibilityCaptureKindLocation, JSON: `{"b":2}`}))
30 require.NoError(t, capture.Write(accessibilityPayload{Kind: accessibilityCaptureKindTree, JSON: `{"c":3}`}))
31
32 want := []string{"000001-tree.json", "000002-location.json", "000003-tree.json"}
33 entries, err := os.ReadDir(dir)
34 require.NoError(t, err)
35 require.Len(t, entries, 3)
36 for i, name := range want {
37 assert.Equal(t, name, entries[i].Name())
38 info, err := entries[i].Info()
39 require.NoError(t, err)
40 assert.Equal(t, os.FileMode(0o600), info.Mode().Perm())
41 }
42}
43
44func TestAccessibilityCapture_IgnoresEmptyPayload(t *testing.T) {
45 dir := t.TempDir()
46 capture, err := newAccessibilityCapture(dir)
47 require.NoError(t, err)
48 require.NoError(t, capture.Write(accessibilityPayload{Kind: accessibilityCaptureKindTree, JSON: ""}))
49 entries, err := os.ReadDir(dir)
50 require.NoError(t, err)
51 assert.Empty(t, entries)
52}
53
54func TestAccessibilityCapture_RejectsUnsafePaths(t *testing.T) {
55 for _, dir := range []string{"", ".", "foo/../bar", "../escape", "a/../../b"} {
56 _, err := newAccessibilityCapture(dir)
57 require.Error(t, err, "dir=%q", dir)
58 }
59}
60
61func TestAccessibilityCapture_RejectsPathLikeAndUnknownKinds(t *testing.T) {
62 dir := t.TempDir()
63 capture, err := newAccessibilityCapture(dir)
64 require.NoError(t, err)
65
66 for _, kind := range []string{
67 "../escape",
68 "tree/../../etc/passwd",
69 "tree/foo",
70 "/tmp/x",
71 "unknown",
72 "TREE",
73 "",
74 "tree\x00",
75 } {
76 writeErr := capture.Write(accessibilityPayload{Kind: kind, JSON: `{"x":1}`})
77 require.Error(t, writeErr, "kind=%q", kind)
78 }
79
80 entries, err := os.ReadDir(dir)
81 require.NoError(t, err)
82 assert.Empty(t, entries)
83}
84
85func TestAccessibilityCapture_NeverOverwritesExistingNumberedFile(t *testing.T) {
86 dir := t.TempDir()
87 preexisting := filepath.Join(dir, "000001-tree.json")
88 const original = `{"keep":true}`
89 require.NoError(t, os.WriteFile(preexisting, []byte(original), 0o600))
90
91 capture, err := newAccessibilityCapture(dir)
92 require.NoError(t, err)
93 require.NoError(t, capture.Write(accessibilityPayload{Kind: accessibilityCaptureKindTree, JSON: `{"append":true}`}))
94
95 got, err := os.ReadFile(preexisting)
96 require.NoError(t, err)
97 assert.Equal(t, original, string(got))
98
99 appended, err := os.ReadFile(filepath.Join(dir, "000002-tree.json"))
100 require.NoError(t, err)
101 assert.JSONEq(t, `{"append":true}`, string(appended))
102
103 entries, err := os.ReadDir(dir)
104 require.NoError(t, err)
105 require.Len(t, entries, 2)
106}
107
108func TestAccessibilityCapture_ReusedDirInitializesMaxSequence(t *testing.T) {
109 dir := t.TempDir()
110 require.NoError(t, os.WriteFile(filepath.Join(dir, "000001-tree.json"), []byte(`{"a":1}`), 0o600))
111 require.NoError(t, os.WriteFile(filepath.Join(dir, "000003-location.json"), []byte(`{"b":2}`), 0o600))
112 require.NoError(t, os.WriteFile(filepath.Join(dir, "readme.txt"), []byte("ignore"), 0o600))
113 require.NoError(t, os.WriteFile(filepath.Join(dir, "000002-other.json"), []byte(`{}`), 0o600))
114
115 capture, err := newAccessibilityCapture(dir)
116 require.NoError(t, err)
117 require.Equal(t, uint64(3), capture.seq)
118 require.Equal(t, 2, capture.fileCount)
119 require.Equal(t, int64(len(`{"a":1}`)+len(`{"b":2}`)), capture.totalBytes)
120
121 require.NoError(t, capture.Write(accessibilityPayload{Kind: accessibilityCaptureKindTree, JSON: `{"c":3}`}))
122 _, err = os.Stat(filepath.Join(dir, "000004-tree.json"))
123 require.NoError(t, err)
124 _, err = os.Stat(filepath.Join(dir, "000002-tree.json"))
125 require.Error(t, err)
126}
127
128func TestAccessibilityCapture_BudgetsIncludeExistingAndRejectBeforeCreate(t *testing.T) {
129 dir := t.TempDir()
130 existing := []byte(`{"keep":true}`)
131 require.NoError(t, os.WriteFile(filepath.Join(dir, "000001-tree.json"), existing, 0o600))
132
133 capture, err := newAccessibilityCapture(dir)
134 require.NoError(t, err)
135 capture.maxFiles = 2
136 capture.maxBytes = int64(len(existing) + 8)
137
138 require.NoError(t, capture.Write(accessibilityPayload{Kind: accessibilityCaptureKindLocation, JSON: `{"ok":1}`}))
139 require.Equal(t, 2, capture.fileCount)
140 require.FileExists(t, filepath.Join(dir, "000002-location.json"))
141
142 before, err := os.ReadDir(dir)
143 require.NoError(t, err)
144 require.Len(t, before, 2)
145
146 require.Error(t, capture.Write(accessibilityPayload{Kind: accessibilityCaptureKindTree, JSON: `x`}))
147 afterFiles, err := os.ReadDir(dir)
148 require.NoError(t, err)
149 require.Len(t, afterFiles, 2, "file-budget reject must not create a file")
150 require.Equal(t, uint64(2), capture.seq, "file-budget reject must not advance sequence")
151
152 capture.maxFiles = 10
153 require.Error(t, capture.Write(accessibilityPayload{Kind: accessibilityCaptureKindTree, JSON: `too-large`}))
154 afterBytes, err := os.ReadDir(dir)
155 require.NoError(t, err)
156 require.Len(t, afterBytes, 2, "byte-budget reject must not create a file")
157 require.Equal(t, uint64(2), capture.seq, "byte-budget reject must not advance sequence")
158}
159
160func TestAccessibilityCapture_ScanIgnoresSymlinkAndNonRegular(t *testing.T) {
161 dir := t.TempDir()
162 require.NoError(t, os.WriteFile(filepath.Join(dir, "000001-tree.json"), []byte(`{"ok":1}`), 0o600))
163 require.NoError(t, os.Symlink(filepath.Join(dir, "000001-tree.json"), filepath.Join(dir, "000009-tree.json")))
164 require.NoError(t, os.Mkdir(filepath.Join(dir, "000008-location.json"), 0o700))
165
166 capture, err := newAccessibilityCapture(dir)
167 require.NoError(t, err)
168 require.Equal(t, uint64(1), capture.seq)
169 require.Equal(t, 1, capture.fileCount)
170 require.Equal(t, int64(len(`{"ok":1}`)), capture.totalBytes)
171
172 require.NoError(t, capture.Write(accessibilityPayload{Kind: accessibilityCaptureKindLocation, JSON: `{"n":2}`}))
173 require.FileExists(t, filepath.Join(dir, "000002-location.json"))
174 info, err := os.Lstat(filepath.Join(dir, "000009-tree.json"))
175 require.NoError(t, err)
176 assert.NotZero(t, info.Mode()&os.ModeSymlink)
177}
178
179func TestAccessibilityCapture_Namespace0700AndChmodExisting(t *testing.T) {
180 root := t.TempDir()
181 dir := filepath.Join(root, "webview-9")
182 require.NoError(t, os.MkdirAll(dir, 0o755))
183 info, err := os.Stat(dir)
184 require.NoError(t, err)
185 require.Equal(t, os.FileMode(0o755), info.Mode().Perm())
186
187 capture, err := newAccessibilityCapture(dir)
188 require.NoError(t, err)
189 require.NotNil(t, capture)
190
191 info, err = os.Stat(dir)
192 require.NoError(t, err)
193 assert.Equal(t, os.FileMode(0o700), info.Mode().Perm())
194}
195
196func TestAccessibilityCapture_ConcurrentCounterNoCollision(t *testing.T) {
197 dir := t.TempDir()
198 capture, err := newAccessibilityCapture(dir)
199 require.NoError(t, err)
200
201 const n = 32
202 var wg sync.WaitGroup
203 var writeErrs atomic.Int64
204 wg.Add(n)
205 for i := 0; i < n; i++ {
206 go func() {
207 defer wg.Done()
208 if writeErr := capture.Write(accessibilityPayload{Kind: accessibilityCaptureKindTree, JSON: `{}`}); writeErr != nil {
209 writeErrs.Add(1)
210 }
211 }()
212 }
213 wg.Wait()
214 require.Equal(t, int64(0), writeErrs.Load())
215
216 entries, err := os.ReadDir(dir)
217 require.NoError(t, err)
218 require.Len(t, entries, n)
219 seen := make(map[string]struct{}, n)
220 for _, e := range entries {
221 _, dup := seen[e.Name()]
222 assert.False(t, dup, "duplicate %s", e.Name())
223 seen[e.Name()] = struct{}{}
224 }
225}
226
227func TestAccessibilityCapture_TwoWebViewIDsIsolated(t *testing.T) {
228 root := t.TempDir()
229 dir1 := filepath.Join(root, "webview-1")
230 dir2 := filepath.Join(root, "webview-2")
231 c1, err := newAccessibilityCapture(dir1)
232 require.NoError(t, err)
233 c2, err := newAccessibilityCapture(dir2)
234 require.NoError(t, err)
235
236 var wg sync.WaitGroup
237 var writeErrs atomic.Int64
238 wg.Add(2)
239 go func() {
240 defer wg.Done()
241 if writeErr := c1.Write(accessibilityPayload{Kind: accessibilityCaptureKindTree, JSON: `{"id":1}`}); writeErr != nil {
242 writeErrs.Add(1)
243 }
244 }()
245 go func() {
246 defer wg.Done()
247 if writeErr := c2.Write(accessibilityPayload{Kind: accessibilityCaptureKindTree, JSON: `{"id":2}`}); writeErr != nil {
248 writeErrs.Add(1)
249 }
250 }()
251 wg.Wait()
252 require.Equal(t, int64(0), writeErrs.Load())
253
254 b1, err := os.ReadFile(filepath.Join(dir1, "000001-tree.json"))
255 require.NoError(t, err)
256 b2, err := os.ReadFile(filepath.Join(dir2, "000001-tree.json"))
257 require.NoError(t, err)
258 assert.JSONEq(t, `{"id":1}`, string(b1))
259 assert.JSONEq(t, `{"id":2}`, string(b2))
260}
261
262func TestAccessibilityCaptureDir_UsesLogDirNamespace(t *testing.T) {
263 home := t.TempDir()
264 t.Setenv("ENV", "")
265 t.Setenv("HOME", home)
266 t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
267 t.Setenv("XDG_DATA_HOME", filepath.Join(home, ".local", "share"))
268 t.Setenv("XDG_STATE_HOME", filepath.Join(home, ".local", "state"))
269 t.Setenv("XDG_CACHE_HOME", filepath.Join(home, ".cache"))
270
271 dir, err := accessibilityCaptureDir(42)
272 require.NoError(t, err)
273 assert.Equal(t, filepath.Join(home, ".local", "state", "dumber", "logs", "a11y-capture", "webview-42"), dir)
274}
275
276func TestAccessibilityCaptureEnabled_RequiresExactOne(t *testing.T) {
277 t.Setenv("DUMBER_A11Y_CAPTURE", "")
278 assert.False(t, accessibilityCaptureEnabled())
279 t.Setenv("DUMBER_A11Y_CAPTURE", "0")
280 assert.False(t, accessibilityCaptureEnabled())
281 t.Setenv("DUMBER_A11Y_CAPTURE", "true")
282 assert.False(t, accessibilityCaptureEnabled())
283 t.Setenv("DUMBER_A11Y_CAPTURE", "1")
284 assert.True(t, accessibilityCaptureEnabled())
285}
286
287func TestAccessibilityCaptureWorker_DropsWhenFullThenCloseDrains(t *testing.T) {
288 var (
289 mu sync.Mutex
290 got []accessibilityPayload
291 )
292 w := &accessibilityCaptureWorker{
293 queue: make(chan accessibilityPayload, 2),
294 consume: func(p accessibilityPayload) {
295 mu.Lock()
296 got = append(got, p)
297 mu.Unlock()
298 },
299 }
300 require.True(t, w.Submit(accessibilityPayload{Kind: accessibilityCaptureKindTree, JSON: "1"}))
301 require.True(t, w.Submit(accessibilityPayload{Kind: accessibilityCaptureKindTree, JSON: "2"}))
302 require.False(t, w.Submit(accessibilityPayload{Kind: accessibilityCaptureKindTree, JSON: "3"}))
303 assert.Equal(t, uint64(1), w.Dropped())
304
305 w.wg.Add(1)
306 go w.loop()
307 w.Close()
308
309 mu.Lock()
310 defer mu.Unlock()
311 require.Len(t, got, 2)
312 assert.Equal(t, "1", got[0].JSON)
313 assert.Equal(t, "2", got[1].JSON)
314 require.False(t, w.Submit(accessibilityPayload{Kind: accessibilityCaptureKindTree, JSON: "4"}))
315}
316
317func TestAccessibilityCaptureWorker_NonBlockingUnderBackpressure(t *testing.T) {
318 block := make(chan struct{})
319 started := make(chan struct{})
320 var consumed atomic.Int64
321 w := newAccessibilityCaptureWorker(2, func(accessibilityPayload) {
322 select {
323 case <-started:
324 default:
325 close(started)
326 }
327 <-block
328 consumed.Add(1)
329 })
330
331 require.True(t, w.Submit(accessibilityPayload{Kind: accessibilityCaptureKindTree, JSON: "a"}))
332 <-started
333 require.True(t, w.Submit(accessibilityPayload{Kind: accessibilityCaptureKindTree, JSON: "b"}))
334 require.True(t, w.Submit(accessibilityPayload{Kind: accessibilityCaptureKindTree, JSON: "c"}))
335
336 done := make(chan struct{})
337 go func() {
338 defer close(done)
339 assert.False(t, w.Submit(accessibilityPayload{Kind: accessibilityCaptureKindTree, JSON: "d"}))
340 }()
341 select {
342 case <-done:
343 case <-time.After(2 * time.Second):
344 t.Fatal("Submit blocked under backpressure")
345 }
346 assert.Equal(t, uint64(1), w.Dropped())
347
348 close(block)
349 w.Close()
350 assert.Equal(t, int64(3), consumed.Load())
351}
352
353func TestAccessibilityStats_ExactAverages(t *testing.T) {
354 var stats accessibilityStats
355 stats.Observe(accessibilityPayload{Kind: accessibilityCaptureKindTree, Bytes: 1000, SerializeNanos: 2_000_000})
356 stats.Observe(accessibilityPayload{Kind: accessibilityCaptureKindTree, Bytes: 3000, SerializeNanos: 4_000_000})
357 stats.Observe(accessibilityPayload{Kind: accessibilityCaptureKindLocation, Bytes: 100, SerializeNanos: 500_000})
358
359 snap := stats.Snapshot()
360 assert.Equal(t, 3, snap.Count)
361 assert.Equal(t, 2, snap.TreeCount)
362 assert.Equal(t, 1, snap.LocationCount)
363 assert.Equal(t, int64(4100), snap.TotalBytes)
364 assert.Equal(t, 100, snap.MinBytes)
365 assert.Equal(t, 3000, snap.MaxBytes)
366 assert.InDelta(t, 1366.6666666666667, snap.AverageBytes, 1e-9)
367 assert.Equal(t, int64(6_500_000), snap.TotalSerializeNanos)
368 assert.InDelta(t, 2_166_666.6666666665, snap.AverageSerializeNanos, 1e-9)
369 assert.Equal(t, int64(4_000_000), snap.MaxSerializeNanos)
370}
371
372func TestAccessibilityStats_EmptySnapshotZero(t *testing.T) {
373 var stats accessibilityStats
374 snap := stats.Snapshot()
375 assert.Equal(t, accessibilityStatsSnapshot{}, snap)
376}
377
378func TestAccessibilityCapture_InactiveWithoutEnvZeroWork(t *testing.T) {
379 t.Setenv("DUMBER_A11Y_CAPTURE", "")
380 var output bytes.Buffer
381 logger := zerolog.New(&output).Level(zerolog.DebugLevel)
382 ctx := logging.WithContext(context.Background(), logger)
383
384 wv := &WebView{ctx: ctx, id: port.WebViewID(7), viewBridge: &Cef2gtkAdapter{}}
385 require.Nil(t, wv.a11yWorker)
386 require.Nil(t, wv.a11yCapture)
387
388 rh := newDumberRenderHandler(wv)
389 require.NotNil(t, rh)
390 handler, ok := rh.GetAccessibilityHandler().(*accessibilityHandler)
391 require.True(t, ok)
392 require.Nil(t, handler.sink)
393
394 writeCalls := 0
395 handler.writeJSON = func(purecef.Value, purecef.JsonWriterOptions) string {
396 writeCalls++
397 return `{"x":1}`
398 }
399 handler.OnAccessibilityTreeChange(stubCEFValue{})
400 handler.OnAccessibilityLocationChange(stubCEFValue{})
401 assert.Equal(t, 0, writeCalls)
402 assert.Equal(t, accessibilityStatsSnapshot{}, wv.a11yStats.Snapshot())
403 assert.Empty(t, output.String())
404 assert.False(t, wv.enqueueAccessibilityPayload(accessibilityPayload{Kind: accessibilityCaptureKindTree, JSON: `{}`}))
405}
406
407func TestAccessibilityCapture_ConsumeLogsAndWritesOffCallback(t *testing.T) {
408 dir := t.TempDir()
409 capture, err := newAccessibilityCapture(dir)
410 require.NoError(t, err)
411
412 var output bytes.Buffer
413 logger := zerolog.New(&output).Level(zerolog.InfoLevel)
414 ctx := logging.WithContext(context.Background(), logger)
415
416 wv := &WebView{
417 ctx: ctx,
418 id: port.WebViewID(3),
419 a11yCapture: capture,
420 }
421 wv.a11yWorker = newAccessibilityCaptureWorker(8, wv.consumeAccessibilityPayload)
422
423 require.True(t, wv.enqueueAccessibilityPayload(accessibilityPayload{
424 Kind: accessibilityCaptureKindTree, JSON: `{"ok":true}`, Bytes: 11, SerializeNanos: 123,
425 }))
426 require.Eventually(t, func() bool {
427 _, err := os.Stat(filepath.Join(dir, "000001-tree.json"))
428 return err == nil
429 }, 2*time.Second, 10*time.Millisecond)
430 wv.a11yWorker.Close()
431
432 snap := wv.a11yStats.Snapshot()
433 assert.Equal(t, 1, snap.Count)
434 assert.Equal(t, 1, snap.TreeCount)
435 assert.Equal(t, 11, snap.MaxBytes)
436
437 var found bool
438 dec := json.NewDecoder(bytes.NewReader(output.Bytes()))
439 for dec.More() {
440 var rec map[string]any
441 require.NoError(t, dec.Decode(&rec))
442 if rec["message"] == "cef: accessibility update" {
443 found = true
444 assert.InDelta(t, float64(3), rec["webview_id"], 0)
445 assert.Equal(t, accessibilityCaptureKindTree, rec["kind"])
446 assert.InDelta(t, float64(11), rec["bytes"], 0)
447 assert.InDelta(t, float64(123), rec["serialize_ns"], 0)
448 }
449 }
450 assert.True(t, found)
451}
452
453func TestAccessibilityCapture_DestroyEmitsSummary(t *testing.T) {
454 dir := t.TempDir()
455 capture, err := newAccessibilityCapture(dir)
456 require.NoError(t, err)
457
458 var output bytes.Buffer
459 logger := zerolog.New(&output).Level(zerolog.InfoLevel)
460 ctx := logging.WithContext(context.Background(), logger)
461
462 wv := &WebView{
463 ctx: ctx,
464 id: port.WebViewID(5),
465 a11yCapture: capture,
466 }
467 wv.a11yWorker = newAccessibilityCaptureWorker(4, wv.consumeAccessibilityPayload)
468 require.True(t, wv.enqueueAccessibilityPayload(accessibilityPayload{
469 Kind: accessibilityCaptureKindLocation, JSON: `{}`, Bytes: 2, SerializeNanos: 50,
470 }))
471 require.Eventually(t, func() bool {
472 return wv.a11yStats.Snapshot().Count == 1
473 }, 2*time.Second, 10*time.Millisecond)
474
475 worker := wv.a11yWorker
476 wv.shutdownAccessibilityCapture()
477
478 require.True(t, wv.a11yCaptureFinalized.Load())
479 require.Same(t, worker, wv.a11yWorker)
480 require.NotNil(t, wv.a11yCapture)
481
482 var found bool
483 dec := json.NewDecoder(bytes.NewReader(output.Bytes()))
484 for dec.More() {
485 var rec map[string]any
486 require.NoError(t, dec.Decode(&rec))
487 if rec["message"] == "cef: accessibility summary" {
488 found = true
489 assert.InDelta(t, float64(5), rec["webview_id"], 0)
490 assert.InDelta(t, float64(1), rec["count"], 0)
491 assert.InDelta(t, float64(1), rec["location_count"], 0)
492 assert.InDelta(t, float64(0), rec["dropped"], 0)
493 }
494 }
495 assert.True(t, found)
496 require.False(t, wv.enqueueAccessibilityPayload(accessibilityPayload{Kind: accessibilityCaptureKindTree, JSON: `{}`}))
497}
498
499func TestInstallNewWebViewViewportHooksAfterCapture_FailureClosesWorkerOnce(t *testing.T) {
500 home := t.TempDir()
501 t.Setenv("HOME", home)
502 t.Setenv("XDG_STATE_HOME", filepath.Join(home, ".local", "state"))
503 t.Setenv("DUMBER_A11Y_CAPTURE", "1")
504
505 prev := newWebViewInstallViewportSyncHooks
506 t.Cleanup(func() { newWebViewInstallViewportSyncHooks = prev })
507 newWebViewInstallViewportSyncHooks = func(*WebView) syncdispatch.SyncDispatchResult {
508 return syncdispatch.SyncDispatchResult{
509 Label: "cef.install_viewport_sync_hooks",
510 Status: syncdispatch.SyncDispatchTimedOut,
511 }
512 }
513
514 var output bytes.Buffer
515 logger := zerolog.New(&output).Level(zerolog.InfoLevel)
516 ctx := logging.WithContext(context.Background(), logger)
517
518 wv := &WebView{
519 ctx: ctx,
520 id: port.WebViewID(99),
521 gtkSyncDispatch: func(fn func()) {
522 if fn != nil {
523 fn()
524 }
525 },
526 gtkSyncIsOwner: func() bool { return true },
527 viewBridge: &Cef2gtkAdapter{},
528 }
529 require.NoError(t, wv.enableAccessibilityCaptureIfRequested())
530 worker := wv.a11yWorker
531 require.NotNil(t, worker)
532
533 err := wv.installNewWebViewViewportHooksAfterCapture()
534 require.Error(t, err)
535 require.Contains(t, err.Error(), "install CEF viewport sync hooks")
536 require.True(t, wv.a11yCaptureFinalized.Load())
537 require.Same(t, worker, wv.a11yWorker, "published worker pointer must stay immutable")
538 require.NotNil(t, wv.a11yCapture, "published capture pointer must stay immutable")
539 require.True(t, worker.closed)
540 require.False(t, worker.Submit(accessibilityPayload{Kind: accessibilityCaptureKindTree, JSON: `{}`}))
541 require.False(t, wv.enqueueAccessibilityPayload(accessibilityPayload{Kind: accessibilityCaptureKindTree, JSON: `{}`}))
542
543 wv.shutdownAccessibilityCapture()
544 wv.Destroy()
545
546 summaryCount := 0
547 dec := json.NewDecoder(bytes.NewReader(output.Bytes()))
548 for dec.More() {
549 var rec map[string]any
550 require.NoError(t, dec.Decode(&rec))
551 if rec["message"] == "cef: accessibility summary" {
552 summaryCount++
553 }
554 }
555 assert.Equal(t, 0, summaryCount, "failed init must not emit Destroy summary")
556}
557
558func TestShutdownAccessibilityCapture_IdempotentAfterAbort(t *testing.T) {
559 dir := t.TempDir()
560 capture, err := newAccessibilityCapture(dir)
561 require.NoError(t, err)
562
563 var output bytes.Buffer
564 logger := zerolog.New(&output).Level(zerolog.InfoLevel)
565 ctx := logging.WithContext(context.Background(), logger)
566
567 wv := &WebView{ctx: ctx, id: port.WebViewID(7), a11yCapture: capture}
568 wv.a11yWorker = newAccessibilityCaptureWorker(2, wv.consumeAccessibilityPayload)
569
570 wv.abortAccessibilityCaptureSetup()
571 require.True(t, wv.a11yCaptureFinalized.Load())
572 require.NotNil(t, wv.a11yWorker)
573 require.True(t, wv.a11yWorker.closed)
574 require.NotNil(t, wv.a11yCapture)
575 wv.shutdownAccessibilityCapture()
576 wv.shutdownAccessibilityCapture()
577
578 summaryCount := 0
579 dec := json.NewDecoder(bytes.NewReader(output.Bytes()))
580 for dec.More() {
581 var rec map[string]any
582 require.NoError(t, dec.Decode(&rec))
583 if rec["message"] == "cef: accessibility summary" {
584 summaryCount++
585 }
586 }
587 assert.Equal(t, 0, summaryCount)
588}
589
590func TestEnqueueAccessibilityPayload_ConcurrentWithShutdownRaceFree(t *testing.T) {
591 dir := t.TempDir()
592 capture, err := newAccessibilityCapture(dir)
593 require.NoError(t, err)
594
595 var output bytes.Buffer
596 logger := zerolog.New(&output).Level(zerolog.InfoLevel)
597 ctx := logging.WithContext(context.Background(), logger)
598
599 wv := &WebView{ctx: ctx, id: port.WebViewID(11), a11yCapture: capture}
600 wv.a11yWorker = newAccessibilityCaptureWorker(64, wv.consumeAccessibilityPayload)
601 worker := wv.a11yWorker
602
603 const producers = 8
604 const perProducer = 200
605 var wg sync.WaitGroup
606 start := make(chan struct{})
607 for range producers {
608 wg.Add(1)
609 go func() {
610 defer wg.Done()
611 <-start
612 for range perProducer {
613 _ = wv.enqueueAccessibilityPayload(accessibilityPayload{
614 Kind: accessibilityCaptureKindTree, JSON: `{}`, Bytes: 2, SerializeNanos: 1,
615 })
616 }
617 }()
618 }
619 wg.Add(1)
620 go func() {
621 defer wg.Done()
622 <-start
623 wv.shutdownAccessibilityCapture()
624 }()
625 close(start)
626 wg.Wait()
627
628 require.True(t, wv.a11yCaptureFinalized.Load())
629 require.Same(t, worker, wv.a11yWorker)
630 require.NotNil(t, wv.a11yCapture)
631 require.True(t, worker.closed)
632 assert.False(t, wv.enqueueAccessibilityPayload(accessibilityPayload{
633 Kind: accessibilityCaptureKindTree, JSON: `{}`,
634 }))
635
636 wv.shutdownAccessibilityCapture()
637 wv.abortAccessibilityCaptureSetup()
638
639 summaryCount := 0
640 dec := json.NewDecoder(bytes.NewReader(output.Bytes()))
641 for dec.More() {
642 var rec map[string]any
643 require.NoError(t, dec.Decode(&rec))
644 if rec["message"] == "cef: accessibility summary" {
645 summaryCount++
646 }
647 }
648 assert.Equal(t, 1, summaryCount, "successful shutdown must emit exactly one summary")
649}