detached 482b51e

feat(cef): capture and measure accessibility payloads

commit
482b51e5d061166ad39b547ef743afa46de0dde5
author
brice · · 2026-07-29 08:45 UTC
committer
bnema · · 2026-08-14 02:42 UTC
parent
804337e
signature
unsigned
8 files · +795 -11
A internal/infrastructure/cef/accessibility_capture.go +293 -0
@@ -0,0 +1,293 @@
1+package cef
2+
3+import (
4+ "fmt"
5+ "os"
6+ "path/filepath"
7+ "strings"
8+ "sync"
9+ "sync/atomic"
10+ "syscall"
11+
12+ "github.com/bnema/dumber/internal/application/port"
13+ "github.com/bnema/dumber/internal/infrastructure/config"
14+ "github.com/bnema/dumber/internal/logging"
15+)
16+
17+const (
18+ accessibilityCaptureQueueCapacity = 64
19+ accessibilityCaptureDirPerm = 0o700
20+ accessibilityCaptureFilePerm = 0o600
21+ accessibilityCaptureEnvName = "DUMBER_A11Y_CAPTURE"
22+ accessibilityCaptureEnvValue = "1"
23+ accessibilityCaptureKindTree = "tree"
24+ accessibilityCaptureKindLocation = "location"
25+)
26+
27+type accessibilityStatsSnapshot struct {
28+ Count int
29+ TreeCount int
30+ LocationCount int
31+ TotalBytes int64
32+ MinBytes int
33+ MaxBytes int
34+ AverageBytes float64
35+ TotalSerializeNanos int64
36+ MaxSerializeNanos int64
37+ AverageSerializeNanos float64
38+}
39+
40+type accessibilityStats struct {
41+ mu sync.Mutex
42+ count int
43+ treeCount int
44+ locationCount int
45+ totalBytes int64
46+ minBytes int
47+ maxBytes int
48+ totalSerializeNanos int64
49+ maxSerializeNanos int64
50+}
51+
52+func (s *accessibilityStats) Observe(p accessibilityPayload) {
53+ if s == nil {
54+ return
55+ }
56+ s.mu.Lock()
57+ defer s.mu.Unlock()
58+ s.count++
59+ switch p.Kind {
60+ case accessibilityCaptureKindTree:
61+ s.treeCount++
62+ case accessibilityCaptureKindLocation:
63+ s.locationCount++
64+ }
65+ s.totalBytes += int64(p.Bytes)
66+ if s.count == 1 || p.Bytes < s.minBytes {
67+ s.minBytes = p.Bytes
68+ }
69+ if p.Bytes > s.maxBytes {
70+ s.maxBytes = p.Bytes
71+ }
72+ s.totalSerializeNanos += p.SerializeNanos
73+ if p.SerializeNanos > s.maxSerializeNanos {
74+ s.maxSerializeNanos = p.SerializeNanos
75+ }
76+}
77+
78+func (s *accessibilityStats) Snapshot() accessibilityStatsSnapshot {
79+ if s == nil {
80+ return accessibilityStatsSnapshot{}
81+ }
82+ s.mu.Lock()
83+ defer s.mu.Unlock()
84+ if s.count == 0 {
85+ return accessibilityStatsSnapshot{}
86+ }
87+ return accessibilityStatsSnapshot{
88+ Count: s.count,
89+ TreeCount: s.treeCount,
90+ LocationCount: s.locationCount,
91+ TotalBytes: s.totalBytes,
92+ MinBytes: s.minBytes,
93+ MaxBytes: s.maxBytes,
94+ AverageBytes: float64(s.totalBytes) / float64(s.count),
95+ TotalSerializeNanos: s.totalSerializeNanos,
96+ MaxSerializeNanos: s.maxSerializeNanos,
97+ AverageSerializeNanos: float64(s.totalSerializeNanos) / float64(s.count),
98+ }
99+}
100+
101+type accessibilityCaptureWorker struct {
102+ mu sync.RWMutex
103+ closed bool
104+ queue chan accessibilityPayload
105+ wg sync.WaitGroup
106+ consume func(accessibilityPayload)
107+ dropped atomic.Uint64
108+}
109+
110+func newAccessibilityCaptureWorker(capacity int, consume func(accessibilityPayload)) *accessibilityCaptureWorker {
111+ if capacity < 1 {
112+ capacity = 1
113+ }
114+ w := &accessibilityCaptureWorker{
115+ queue: make(chan accessibilityPayload, capacity),
116+ consume: consume,
117+ }
118+ w.wg.Add(1)
119+ go w.loop()
120+ return w
121+}
122+
123+func (w *accessibilityCaptureWorker) loop() {
124+ defer w.wg.Done()
125+ for p := range w.queue {
126+ if w.consume != nil {
127+ w.consume(p)
128+ }
129+ }
130+}
131+
132+func (w *accessibilityCaptureWorker) Submit(p accessibilityPayload) bool {
133+ if w == nil {
134+ return false
135+ }
136+ w.mu.RLock()
137+ defer w.mu.RUnlock()
138+ if w.closed {
139+ return false
140+ }
141+ select {
142+ case w.queue <- p:
143+ return true
144+ default:
145+ w.dropped.Add(1)
146+ return false
147+ }
148+}
149+
150+func (w *accessibilityCaptureWorker) Dropped() uint64 {
151+ if w == nil {
152+ return 0
153+ }
154+ return w.dropped.Load()
155+}
156+
157+func (w *accessibilityCaptureWorker) Close() {
158+ if w == nil {
159+ return
160+ }
161+ w.mu.Lock()
162+ if w.closed {
163+ w.mu.Unlock()
164+ return
165+ }
166+ w.closed = true
167+ close(w.queue)
168+ w.mu.Unlock()
169+ w.wg.Wait()
170+}
171+
172+type accessibilityCapture struct {
173+ mu sync.Mutex
174+ dir string
175+ seq uint64
176+}
177+
178+func accessibilityCaptureEnabled() bool {
179+ return os.Getenv(accessibilityCaptureEnvName) == accessibilityCaptureEnvValue
180+}
181+
182+func accessibilityCaptureDir(webViewID port.WebViewID) (string, error) {
183+ logDir, err := config.GetLogDir()
184+ if err != nil {
185+ return "", err
186+ }
187+ return filepath.Join(logDir, "a11y-capture", fmt.Sprintf("webview-%d", webViewID)), nil
188+}
189+
190+func validateAccessibilityCaptureDir(dir string) error {
191+ if dir == "" || dir == "." {
192+ return fmt.Errorf("accessibility capture dir rejected: %q", dir)
193+ }
194+ for _, part := range strings.Split(filepath.ToSlash(dir), "/") {
195+ if part == ".." {
196+ return fmt.Errorf("accessibility capture dir rejected: %q", dir)
197+ }
198+ }
199+ return nil
200+}
201+
202+func newAccessibilityCapture(dir string) (*accessibilityCapture, error) {
203+ if err := validateAccessibilityCaptureDir(dir); err != nil {
204+ return nil, err
205+ }
206+ if err := os.MkdirAll(dir, accessibilityCaptureDirPerm); err != nil {
207+ return nil, fmt.Errorf("mkdir accessibility capture dir: %w", err)
208+ }
209+ if err := os.Chmod(dir, accessibilityCaptureDirPerm); err != nil {
210+ return nil, fmt.Errorf("chmod accessibility capture dir: %w", err)
211+ }
212+ return &accessibilityCapture{dir: dir}, nil
213+}
214+
215+func accessibilityCaptureKindAllowed(kind string) bool {
216+ switch kind {
217+ case accessibilityCaptureKindTree, accessibilityCaptureKindLocation:
218+ return true
219+ default:
220+ return false
221+ }
222+}
223+
224+func (c *accessibilityCapture) Write(p accessibilityPayload) error {
225+ if c == nil || p.JSON == "" {
226+ return nil
227+ }
228+ if !accessibilityCaptureKindAllowed(p.Kind) {
229+ return fmt.Errorf("accessibility capture kind rejected: %q", p.Kind)
230+ }
231+ c.mu.Lock()
232+ defer c.mu.Unlock()
233+ c.seq++
234+ name := fmt.Sprintf("%06d-%s.json", c.seq, p.Kind)
235+ path := filepath.Join(c.dir, name)
236+ file, err := openFileNoFollow(path, syscall.O_CREAT|syscall.O_EXCL|syscall.O_WRONLY, accessibilityCaptureFilePerm)
237+ if err != nil {
238+ return err
239+ }
240+ _, writeErr := file.WriteString(p.JSON)
241+ chmodErr := file.Chmod(accessibilityCaptureFilePerm)
242+ closeErr := file.Close()
243+ if writeErr != nil {
244+ return writeErr
245+ }
246+ if chmodErr != nil {
247+ return chmodErr
248+ }
249+ return closeErr
250+}
251+
252+func (wv *WebView) enableAccessibilityCaptureIfRequested() error {
253+ if wv == nil || !accessibilityCaptureEnabled() {
254+ return nil
255+ }
256+ dir, err := accessibilityCaptureDir(wv.id)
257+ if err != nil {
258+ return err
259+ }
260+ capture, err := newAccessibilityCapture(dir)
261+ if err != nil {
262+ return err
263+ }
264+ wv.a11yCapture = capture
265+ wv.a11yWorker = newAccessibilityCaptureWorker(accessibilityCaptureQueueCapacity, wv.consumeAccessibilityPayload)
266+ logAccessibilityCaptureEnabled(wv.ctx, wv.id, dir)
267+ return nil
268+}
269+
270+func (wv *WebView) consumeAccessibilityPayload(p accessibilityPayload) {
271+ if wv == nil {
272+ return
273+ }
274+ wv.a11yStats.Observe(p)
275+ logAccessibilityUpdate(wv.ctx, wv.id, p)
276+ if wv.a11yCapture == nil {
277+ return
278+ }
279+ if err := wv.a11yCapture.Write(p); err != nil {
280+ logging.FromContext(wv.ctx).Warn().Err(err).
281+ Uint64("webview_id", uint64(wv.id)).
282+ Msg("cef: accessibility capture write failed")
283+ }
284+}
285+
286+func (wv *WebView) shutdownAccessibilityCapture() {
287+ if wv == nil || wv.a11yWorker == nil {
288+ return
289+ }
290+ worker := wv.a11yWorker
291+ worker.Close()
292+ logAccessibilitySummary(wv.ctx, wv.id, wv.a11yStats.Snapshot(), worker.Dropped())
293+}
@@ -0,0 +1,293 @@
1+package cef
2+
3+import (
4+ "fmt"
5+ "os"
6+ "path/filepath"
7+ "strings"
8+ "sync"
9+ "sync/atomic"
10+ "syscall"
11+
12+ "github.com/bnema/dumber/internal/application/port"
13+ "github.com/bnema/dumber/internal/infrastructure/config"
14+ "github.com/bnema/dumber/internal/logging"
15+)
16+
17+const (
18+ accessibilityCaptureQueueCapacity = 64
19+ accessibilityCaptureDirPerm = 0o700
20+ accessibilityCaptureFilePerm = 0o600
21+ accessibilityCaptureEnvName = "DUMBER_A11Y_CAPTURE"
22+ accessibilityCaptureEnvValue = "1"
23+ accessibilityCaptureKindTree = "tree"
24+ accessibilityCaptureKindLocation = "location"
25+)
26+
27+type accessibilityStatsSnapshot struct {
28+ Count int
29+ TreeCount int
30+ LocationCount int
31+ TotalBytes int64
32+ MinBytes int
33+ MaxBytes int
34+ AverageBytes float64
35+ TotalSerializeNanos int64
36+ MaxSerializeNanos int64
37+ AverageSerializeNanos float64
38+}
39+
40+type accessibilityStats struct {
41+ mu sync.Mutex
42+ count int
43+ treeCount int
44+ locationCount int
45+ totalBytes int64
46+ minBytes int
47+ maxBytes int
48+ totalSerializeNanos int64
49+ maxSerializeNanos int64
50+}
51+
52+func (s *accessibilityStats) Observe(p accessibilityPayload) {
53+ if s == nil {
54+ return
55+ }
56+ s.mu.Lock()
57+ defer s.mu.Unlock()
58+ s.count++
59+ switch p.Kind {
60+ case accessibilityCaptureKindTree:
61+ s.treeCount++
62+ case accessibilityCaptureKindLocation:
63+ s.locationCount++
64+ }
65+ s.totalBytes += int64(p.Bytes)
66+ if s.count == 1 || p.Bytes < s.minBytes {
67+ s.minBytes = p.Bytes
68+ }
69+ if p.Bytes > s.maxBytes {
70+ s.maxBytes = p.Bytes
71+ }
72+ s.totalSerializeNanos += p.SerializeNanos
73+ if p.SerializeNanos > s.maxSerializeNanos {
74+ s.maxSerializeNanos = p.SerializeNanos
75+ }
76+}
77+
78+func (s *accessibilityStats) Snapshot() accessibilityStatsSnapshot {
79+ if s == nil {
80+ return accessibilityStatsSnapshot{}
81+ }
82+ s.mu.Lock()
83+ defer s.mu.Unlock()
84+ if s.count == 0 {
85+ return accessibilityStatsSnapshot{}
86+ }
87+ return accessibilityStatsSnapshot{
88+ Count: s.count,
89+ TreeCount: s.treeCount,
90+ LocationCount: s.locationCount,
91+ TotalBytes: s.totalBytes,
92+ MinBytes: s.minBytes,
93+ MaxBytes: s.maxBytes,
94+ AverageBytes: float64(s.totalBytes) / float64(s.count),
95+ TotalSerializeNanos: s.totalSerializeNanos,
96+ MaxSerializeNanos: s.maxSerializeNanos,
97+ AverageSerializeNanos: float64(s.totalSerializeNanos) / float64(s.count),
98+ }
99+}
100+
101+type accessibilityCaptureWorker struct {
102+ mu sync.RWMutex
103+ closed bool
104+ queue chan accessibilityPayload
105+ wg sync.WaitGroup
106+ consume func(accessibilityPayload)
107+ dropped atomic.Uint64
108+}
109+
110+func newAccessibilityCaptureWorker(capacity int, consume func(accessibilityPayload)) *accessibilityCaptureWorker {
111+ if capacity < 1 {
112+ capacity = 1
113+ }
114+ w := &accessibilityCaptureWorker{
115+ queue: make(chan accessibilityPayload, capacity),
116+ consume: consume,
117+ }
118+ w.wg.Add(1)
119+ go w.loop()
120+ return w
121+}
122+
123+func (w *accessibilityCaptureWorker) loop() {
124+ defer w.wg.Done()
125+ for p := range w.queue {
126+ if w.consume != nil {
127+ w.consume(p)
128+ }
129+ }
130+}
131+
132+func (w *accessibilityCaptureWorker) Submit(p accessibilityPayload) bool {
133+ if w == nil {
134+ return false
135+ }
136+ w.mu.RLock()
137+ defer w.mu.RUnlock()
138+ if w.closed {
139+ return false
140+ }
141+ select {
142+ case w.queue <- p:
143+ return true
144+ default:
145+ w.dropped.Add(1)
146+ return false
147+ }
148+}
149+
150+func (w *accessibilityCaptureWorker) Dropped() uint64 {
151+ if w == nil {
152+ return 0
153+ }
154+ return w.dropped.Load()
155+}
156+
157+func (w *accessibilityCaptureWorker) Close() {
158+ if w == nil {
159+ return
160+ }
161+ w.mu.Lock()
162+ if w.closed {
163+ w.mu.Unlock()
164+ return
165+ }
166+ w.closed = true
167+ close(w.queue)
168+ w.mu.Unlock()
169+ w.wg.Wait()
170+}
171+
172+type accessibilityCapture struct {
173+ mu sync.Mutex
174+ dir string
175+ seq uint64
176+}
177+
178+func accessibilityCaptureEnabled() bool {
179+ return os.Getenv(accessibilityCaptureEnvName) == accessibilityCaptureEnvValue
180+}
181+
182+func accessibilityCaptureDir(webViewID port.WebViewID) (string, error) {
183+ logDir, err := config.GetLogDir()
184+ if err != nil {
185+ return "", err
186+ }
187+ return filepath.Join(logDir, "a11y-capture", fmt.Sprintf("webview-%d", webViewID)), nil
188+}
189+
190+func validateAccessibilityCaptureDir(dir string) error {
191+ if dir == "" || dir == "." {
192+ return fmt.Errorf("accessibility capture dir rejected: %q", dir)
193+ }
194+ for _, part := range strings.Split(filepath.ToSlash(dir), "/") {
195+ if part == ".." {
196+ return fmt.Errorf("accessibility capture dir rejected: %q", dir)
197+ }
198+ }
199+ return nil
200+}
201+
202+func newAccessibilityCapture(dir string) (*accessibilityCapture, error) {
203+ if err := validateAccessibilityCaptureDir(dir); err != nil {
204+ return nil, err
205+ }
206+ if err := os.MkdirAll(dir, accessibilityCaptureDirPerm); err != nil {
207+ return nil, fmt.Errorf("mkdir accessibility capture dir: %w", err)
208+ }
209+ if err := os.Chmod(dir, accessibilityCaptureDirPerm); err != nil {
210+ return nil, fmt.Errorf("chmod accessibility capture dir: %w", err)
211+ }
212+ return &accessibilityCapture{dir: dir}, nil
213+}
214+
215+func accessibilityCaptureKindAllowed(kind string) bool {
216+ switch kind {
217+ case accessibilityCaptureKindTree, accessibilityCaptureKindLocation:
218+ return true
219+ default:
220+ return false
221+ }
222+}
223+
224+func (c *accessibilityCapture) Write(p accessibilityPayload) error {
225+ if c == nil || p.JSON == "" {
226+ return nil
227+ }
228+ if !accessibilityCaptureKindAllowed(p.Kind) {
229+ return fmt.Errorf("accessibility capture kind rejected: %q", p.Kind)
230+ }
231+ c.mu.Lock()
232+ defer c.mu.Unlock()
233+ c.seq++
234+ name := fmt.Sprintf("%06d-%s.json", c.seq, p.Kind)
235+ path := filepath.Join(c.dir, name)
236+ file, err := openFileNoFollow(path, syscall.O_CREAT|syscall.O_EXCL|syscall.O_WRONLY, accessibilityCaptureFilePerm)
237+ if err != nil {
238+ return err
239+ }
240+ _, writeErr := file.WriteString(p.JSON)
241+ chmodErr := file.Chmod(accessibilityCaptureFilePerm)
242+ closeErr := file.Close()
243+ if writeErr != nil {
244+ return writeErr
245+ }
246+ if chmodErr != nil {
247+ return chmodErr
248+ }
249+ return closeErr
250+}
251+
252+func (wv *WebView) enableAccessibilityCaptureIfRequested() error {
253+ if wv == nil || !accessibilityCaptureEnabled() {
254+ return nil
255+ }
256+ dir, err := accessibilityCaptureDir(wv.id)
257+ if err != nil {
258+ return err
259+ }
260+ capture, err := newAccessibilityCapture(dir)
261+ if err != nil {
262+ return err
263+ }
264+ wv.a11yCapture = capture
265+ wv.a11yWorker = newAccessibilityCaptureWorker(accessibilityCaptureQueueCapacity, wv.consumeAccessibilityPayload)
266+ logAccessibilityCaptureEnabled(wv.ctx, wv.id, dir)
267+ return nil
268+}
269+
270+func (wv *WebView) consumeAccessibilityPayload(p accessibilityPayload) {
271+ if wv == nil {
272+ return
273+ }
274+ wv.a11yStats.Observe(p)
275+ logAccessibilityUpdate(wv.ctx, wv.id, p)
276+ if wv.a11yCapture == nil {
277+ return
278+ }
279+ if err := wv.a11yCapture.Write(p); err != nil {
280+ logging.FromContext(wv.ctx).Warn().Err(err).
281+ Uint64("webview_id", uint64(wv.id)).
282+ Msg("cef: accessibility capture write failed")
283+ }
284+}
285+
286+func (wv *WebView) shutdownAccessibilityCapture() {
287+ if wv == nil || wv.a11yWorker == nil {
288+ return
289+ }
290+ worker := wv.a11yWorker
291+ worker.Close()
292+ logAccessibilitySummary(wv.ctx, wv.id, wv.a11yStats.Snapshot(), worker.Dropped())
293+}
A internal/infrastructure/cef/accessibility_capture_test.go +417 -0
@@ -0,0 +1,417 @@
1+package cef
2+
3+import (
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+ purecef "github.com/bnema/purego-cef/cef"
17+ "github.com/rs/zerolog"
18+ "github.com/stretchr/testify/assert"
19+ "github.com/stretchr/testify/require"
20+)
21+
22+func TestAccessibilityCapture_WritesNumberedJSONWithSecurePerms(t *testing.T) {
23+ dir := t.TempDir()
24+ capture, err := newAccessibilityCapture(dir)
25+ require.NoError(t, err)
26+
27+ require.NoError(t, capture.Write(accessibilityPayload{Kind: accessibilityCaptureKindTree, JSON: `{"a":1}`}))
28+ require.NoError(t, capture.Write(accessibilityPayload{Kind: accessibilityCaptureKindLocation, JSON: `{"b":2}`}))
29+ require.NoError(t, capture.Write(accessibilityPayload{Kind: accessibilityCaptureKindTree, JSON: `{"c":3}`}))
30+
31+ want := []string{"000001-tree.json", "000002-location.json", "000003-tree.json"}
32+ entries, err := os.ReadDir(dir)
33+ require.NoError(t, err)
34+ require.Len(t, entries, 3)
35+ for i, name := range want {
36+ assert.Equal(t, name, entries[i].Name())
37+ info, err := entries[i].Info()
38+ require.NoError(t, err)
39+ assert.Equal(t, os.FileMode(0o600), info.Mode().Perm())
40+ }
41+}
42+
43+func TestAccessibilityCapture_IgnoresEmptyPayload(t *testing.T) {
44+ dir := t.TempDir()
45+ capture, err := newAccessibilityCapture(dir)
46+ require.NoError(t, err)
47+ require.NoError(t, capture.Write(accessibilityPayload{Kind: accessibilityCaptureKindTree, JSON: ""}))
48+ entries, err := os.ReadDir(dir)
49+ require.NoError(t, err)
50+ assert.Empty(t, entries)
51+}
52+
53+func TestAccessibilityCapture_RejectsUnsafePaths(t *testing.T) {
54+ for _, dir := range []string{"", ".", "foo/../bar", "../escape", "a/../../b"} {
55+ _, err := newAccessibilityCapture(dir)
56+ require.Error(t, err, "dir=%q", dir)
57+ }
58+}
59+
60+func TestAccessibilityCapture_RejectsPathLikeAndUnknownKinds(t *testing.T) {
61+ dir := t.TempDir()
62+ capture, err := newAccessibilityCapture(dir)
63+ require.NoError(t, err)
64+
65+ for _, kind := range []string{
66+ "../escape",
67+ "tree/../../etc/passwd",
68+ "tree/foo",
69+ "/tmp/x",
70+ "unknown",
71+ "TREE",
72+ "",
73+ "tree\x00",
74+ } {
75+ writeErr := capture.Write(accessibilityPayload{Kind: kind, JSON: `{"x":1}`})
76+ require.Error(t, writeErr, "kind=%q", kind)
77+ }
78+
79+ entries, err := os.ReadDir(dir)
80+ require.NoError(t, err)
81+ assert.Empty(t, entries)
82+}
83+
84+func TestAccessibilityCapture_NeverOverwritesExistingNumberedFile(t *testing.T) {
85+ dir := t.TempDir()
86+ preexisting := filepath.Join(dir, "000001-tree.json")
87+ const original = `{"keep":true}`
88+ require.NoError(t, os.WriteFile(preexisting, []byte(original), 0o600))
89+
90+ capture, err := newAccessibilityCapture(dir)
91+ require.NoError(t, err)
92+ require.Error(t, capture.Write(accessibilityPayload{Kind: accessibilityCaptureKindTree, JSON: `{"overwrite":true}`}))
93+
94+ got, err := os.ReadFile(preexisting)
95+ require.NoError(t, err)
96+ assert.Equal(t, original, string(got))
97+
98+ entries, err := os.ReadDir(dir)
99+ require.NoError(t, err)
100+ require.Len(t, entries, 1)
101+ assert.Equal(t, "000001-tree.json", entries[0].Name())
102+}
103+
104+func TestAccessibilityCapture_Namespace0700AndChmodExisting(t *testing.T) {
105+ root := t.TempDir()
106+ dir := filepath.Join(root, "webview-9")
107+ require.NoError(t, os.MkdirAll(dir, 0o755))
108+ info, err := os.Stat(dir)
109+ require.NoError(t, err)
110+ require.Equal(t, os.FileMode(0o755), info.Mode().Perm())
111+
112+ capture, err := newAccessibilityCapture(dir)
113+ require.NoError(t, err)
114+ require.NotNil(t, capture)
115+
116+ info, err = os.Stat(dir)
117+ require.NoError(t, err)
118+ assert.Equal(t, os.FileMode(0o700), info.Mode().Perm())
119+}
120+
121+func TestAccessibilityCapture_ConcurrentCounterNoCollision(t *testing.T) {
122+ dir := t.TempDir()
123+ capture, err := newAccessibilityCapture(dir)
124+ require.NoError(t, err)
125+
126+ const n = 32
127+ var wg sync.WaitGroup
128+ var writeErrs atomic.Int64
129+ wg.Add(n)
130+ for i := 0; i < n; i++ {
131+ go func() {
132+ defer wg.Done()
133+ if writeErr := capture.Write(accessibilityPayload{Kind: accessibilityCaptureKindTree, JSON: `{}`}); writeErr != nil {
134+ writeErrs.Add(1)
135+ }
136+ }()
137+ }
138+ wg.Wait()
139+ require.Equal(t, int64(0), writeErrs.Load())
140+
141+ entries, err := os.ReadDir(dir)
142+ require.NoError(t, err)
143+ require.Len(t, entries, n)
144+ seen := make(map[string]struct{}, n)
145+ for _, e := range entries {
146+ _, dup := seen[e.Name()]
147+ assert.False(t, dup, "duplicate %s", e.Name())
148+ seen[e.Name()] = struct{}{}
149+ }
150+}
151+
152+func TestAccessibilityCapture_TwoWebViewIDsIsolated(t *testing.T) {
153+ root := t.TempDir()
154+ dir1 := filepath.Join(root, "webview-1")
155+ dir2 := filepath.Join(root, "webview-2")
156+ c1, err := newAccessibilityCapture(dir1)
157+ require.NoError(t, err)
158+ c2, err := newAccessibilityCapture(dir2)
159+ require.NoError(t, err)
160+
161+ var wg sync.WaitGroup
162+ var writeErrs atomic.Int64
163+ wg.Add(2)
164+ go func() {
165+ defer wg.Done()
166+ if writeErr := c1.Write(accessibilityPayload{Kind: accessibilityCaptureKindTree, JSON: `{"id":1}`}); writeErr != nil {
167+ writeErrs.Add(1)
168+ }
169+ }()
170+ go func() {
171+ defer wg.Done()
172+ if writeErr := c2.Write(accessibilityPayload{Kind: accessibilityCaptureKindTree, JSON: `{"id":2}`}); writeErr != nil {
173+ writeErrs.Add(1)
174+ }
175+ }()
176+ wg.Wait()
177+ require.Equal(t, int64(0), writeErrs.Load())
178+
179+ b1, err := os.ReadFile(filepath.Join(dir1, "000001-tree.json"))
180+ require.NoError(t, err)
181+ b2, err := os.ReadFile(filepath.Join(dir2, "000001-tree.json"))
182+ require.NoError(t, err)
183+ assert.JSONEq(t, `{"id":1}`, string(b1))
184+ assert.JSONEq(t, `{"id":2}`, string(b2))
185+}
186+
187+func TestAccessibilityCaptureDir_UsesLogDirNamespace(t *testing.T) {
188+ home := t.TempDir()
189+ t.Setenv("ENV", "")
190+ t.Setenv("HOME", home)
191+ t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
192+ t.Setenv("XDG_DATA_HOME", filepath.Join(home, ".local", "share"))
193+ t.Setenv("XDG_STATE_HOME", filepath.Join(home, ".local", "state"))
194+ t.Setenv("XDG_CACHE_HOME", filepath.Join(home, ".cache"))
195+
196+ dir, err := accessibilityCaptureDir(42)
197+ require.NoError(t, err)
198+ assert.Equal(t, filepath.Join(home, ".local", "state", "dumber", "logs", "a11y-capture", "webview-42"), dir)
199+}
200+
201+func TestAccessibilityCaptureEnabled_RequiresExactOne(t *testing.T) {
202+ t.Setenv("DUMBER_A11Y_CAPTURE", "")
203+ assert.False(t, accessibilityCaptureEnabled())
204+ t.Setenv("DUMBER_A11Y_CAPTURE", "0")
205+ assert.False(t, accessibilityCaptureEnabled())
206+ t.Setenv("DUMBER_A11Y_CAPTURE", "true")
207+ assert.False(t, accessibilityCaptureEnabled())
208+ t.Setenv("DUMBER_A11Y_CAPTURE", "1")
209+ assert.True(t, accessibilityCaptureEnabled())
210+}
211+
212+func TestAccessibilityCaptureWorker_DropsWhenFullThenCloseDrains(t *testing.T) {
213+ var (
214+ mu sync.Mutex
215+ got []accessibilityPayload
216+ )
217+ w := &accessibilityCaptureWorker{
218+ queue: make(chan accessibilityPayload, 2),
219+ consume: func(p accessibilityPayload) {
220+ mu.Lock()
221+ got = append(got, p)
222+ mu.Unlock()
223+ },
224+ }
225+ require.True(t, w.Submit(accessibilityPayload{Kind: accessibilityCaptureKindTree, JSON: "1"}))
226+ require.True(t, w.Submit(accessibilityPayload{Kind: accessibilityCaptureKindTree, JSON: "2"}))
227+ require.False(t, w.Submit(accessibilityPayload{Kind: accessibilityCaptureKindTree, JSON: "3"}))
228+ assert.Equal(t, uint64(1), w.Dropped())
229+
230+ w.wg.Add(1)
231+ go w.loop()
232+ w.Close()
233+
234+ mu.Lock()
235+ defer mu.Unlock()
236+ require.Len(t, got, 2)
237+ assert.Equal(t, "1", got[0].JSON)
238+ assert.Equal(t, "2", got[1].JSON)
239+ require.False(t, w.Submit(accessibilityPayload{Kind: accessibilityCaptureKindTree, JSON: "4"}))
240+}
241+
242+func TestAccessibilityCaptureWorker_NonBlockingUnderBackpressure(t *testing.T) {
243+ block := make(chan struct{})
244+ started := make(chan struct{})
245+ var consumed atomic.Int64
246+ w := newAccessibilityCaptureWorker(2, func(accessibilityPayload) {
247+ select {
248+ case <-started:
249+ default:
250+ close(started)
251+ }
252+ <-block
253+ consumed.Add(1)
254+ })
255+
256+ require.True(t, w.Submit(accessibilityPayload{Kind: accessibilityCaptureKindTree, JSON: "a"}))
257+ <-started
258+ require.True(t, w.Submit(accessibilityPayload{Kind: accessibilityCaptureKindTree, JSON: "b"}))
259+ require.True(t, w.Submit(accessibilityPayload{Kind: accessibilityCaptureKindTree, JSON: "c"}))
260+
261+ done := make(chan struct{})
262+ go func() {
263+ defer close(done)
264+ assert.False(t, w.Submit(accessibilityPayload{Kind: accessibilityCaptureKindTree, JSON: "d"}))
265+ }()
266+ select {
267+ case <-done:
268+ case <-time.After(2 * time.Second):
269+ t.Fatal("Submit blocked under backpressure")
270+ }
271+ assert.Equal(t, uint64(1), w.Dropped())
272+
273+ close(block)
274+ w.Close()
275+ assert.Equal(t, int64(3), consumed.Load())
276+}
277+
278+func TestAccessibilityStats_ExactAverages(t *testing.T) {
279+ var stats accessibilityStats
280+ stats.Observe(accessibilityPayload{Kind: accessibilityCaptureKindTree, Bytes: 1000, SerializeNanos: 2_000_000})
281+ stats.Observe(accessibilityPayload{Kind: accessibilityCaptureKindTree, Bytes: 3000, SerializeNanos: 4_000_000})
282+ stats.Observe(accessibilityPayload{Kind: accessibilityCaptureKindLocation, Bytes: 100, SerializeNanos: 500_000})
283+
284+ snap := stats.Snapshot()
285+ assert.Equal(t, 3, snap.Count)
286+ assert.Equal(t, 2, snap.TreeCount)
287+ assert.Equal(t, 1, snap.LocationCount)
288+ assert.Equal(t, int64(4100), snap.TotalBytes)
289+ assert.Equal(t, 100, snap.MinBytes)
290+ assert.Equal(t, 3000, snap.MaxBytes)
291+ assert.InDelta(t, 1366.6666666666667, snap.AverageBytes, 1e-9)
292+ assert.Equal(t, int64(6_500_000), snap.TotalSerializeNanos)
293+ assert.InDelta(t, 2_166_666.6666666665, snap.AverageSerializeNanos, 1e-9)
294+ assert.Equal(t, int64(4_000_000), snap.MaxSerializeNanos)
295+}
296+
297+func TestAccessibilityStats_EmptySnapshotZero(t *testing.T) {
298+ var stats accessibilityStats
299+ snap := stats.Snapshot()
300+ assert.Equal(t, accessibilityStatsSnapshot{}, snap)
301+}
302+
303+func TestAccessibilityCapture_InactiveWithoutEnvZeroWork(t *testing.T) {
304+ t.Setenv("DUMBER_A11Y_CAPTURE", "")
305+ var output bytes.Buffer
306+ logger := zerolog.New(&output).Level(zerolog.DebugLevel)
307+ ctx := logging.WithContext(context.Background(), logger)
308+
309+ wv := &WebView{ctx: ctx, id: port.WebViewID(7), viewBridge: &Cef2gtkAdapter{}}
310+ require.Nil(t, wv.a11yWorker)
311+ require.Nil(t, wv.a11yCapture)
312+
313+ rh := newDumberRenderHandler(wv)
314+ require.NotNil(t, rh)
315+ handler, ok := rh.GetAccessibilityHandler().(*accessibilityHandler)
316+ require.True(t, ok)
317+ require.Nil(t, handler.sink)
318+
319+ writeCalls := 0
320+ handler.writeJSON = func(purecef.Value, purecef.JsonWriterOptions) string {
321+ writeCalls++
322+ return `{"x":1}`
323+ }
324+ handler.OnAccessibilityTreeChange(stubCEFValue{})
325+ handler.OnAccessibilityLocationChange(stubCEFValue{})
326+ assert.Equal(t, 0, writeCalls)
327+ assert.Equal(t, accessibilityStatsSnapshot{}, wv.a11yStats.Snapshot())
328+ assert.Empty(t, output.String())
329+ assert.False(t, wv.enqueueAccessibilityPayload(accessibilityPayload{Kind: accessibilityCaptureKindTree, JSON: `{}`}))
330+}
331+
332+func TestAccessibilityCapture_ConsumeLogsAndWritesOffCallback(t *testing.T) {
333+ dir := t.TempDir()
334+ capture, err := newAccessibilityCapture(dir)
335+ require.NoError(t, err)
336+
337+ var output bytes.Buffer
338+ logger := zerolog.New(&output).Level(zerolog.InfoLevel)
339+ ctx := logging.WithContext(context.Background(), logger)
340+
341+ wv := &WebView{
342+ ctx: ctx,
343+ id: port.WebViewID(3),
344+ a11yCapture: capture,
345+ }
346+ wv.a11yWorker = newAccessibilityCaptureWorker(8, wv.consumeAccessibilityPayload)
347+
348+ require.True(t, wv.enqueueAccessibilityPayload(accessibilityPayload{
349+ Kind: accessibilityCaptureKindTree, JSON: `{"ok":true}`, Bytes: 11, SerializeNanos: 123,
350+ }))
351+ require.Eventually(t, func() bool {
352+ _, err := os.Stat(filepath.Join(dir, "000001-tree.json"))
353+ return err == nil
354+ }, 2*time.Second, 10*time.Millisecond)
355+ wv.a11yWorker.Close()
356+
357+ snap := wv.a11yStats.Snapshot()
358+ assert.Equal(t, 1, snap.Count)
359+ assert.Equal(t, 1, snap.TreeCount)
360+ assert.Equal(t, 11, snap.MaxBytes)
361+
362+ var found bool
363+ dec := json.NewDecoder(bytes.NewReader(output.Bytes()))
364+ for dec.More() {
365+ var rec map[string]any
366+ require.NoError(t, dec.Decode(&rec))
367+ if rec["message"] == "cef: accessibility update" {
368+ found = true
369+ assert.InDelta(t, float64(3), rec["webview_id"], 0)
370+ assert.Equal(t, accessibilityCaptureKindTree, rec["kind"])
371+ assert.InDelta(t, float64(11), rec["bytes"], 0)
372+ assert.InDelta(t, float64(123), rec["serialize_ns"], 0)
373+ }
374+ }
375+ assert.True(t, found)
376+}
377+
378+func TestAccessibilityCapture_DestroyEmitsSummary(t *testing.T) {
379+ dir := t.TempDir()
380+ capture, err := newAccessibilityCapture(dir)
381+ require.NoError(t, err)
382+
383+ var output bytes.Buffer
384+ logger := zerolog.New(&output).Level(zerolog.InfoLevel)
385+ ctx := logging.WithContext(context.Background(), logger)
386+
387+ wv := &WebView{
388+ ctx: ctx,
389+ id: port.WebViewID(5),
390+ a11yCapture: capture,
391+ }
392+ wv.a11yWorker = newAccessibilityCaptureWorker(4, wv.consumeAccessibilityPayload)
393+ require.True(t, wv.enqueueAccessibilityPayload(accessibilityPayload{
394+ Kind: accessibilityCaptureKindLocation, JSON: `{}`, Bytes: 2, SerializeNanos: 50,
395+ }))
396+ require.Eventually(t, func() bool {
397+ return wv.a11yStats.Snapshot().Count == 1
398+ }, 2*time.Second, 10*time.Millisecond)
399+
400+ wv.shutdownAccessibilityCapture()
401+
402+ var found bool
403+ dec := json.NewDecoder(bytes.NewReader(output.Bytes()))
404+ for dec.More() {
405+ var rec map[string]any
406+ require.NoError(t, dec.Decode(&rec))
407+ if rec["message"] == "cef: accessibility summary" {
408+ found = true
409+ assert.InDelta(t, float64(5), rec["webview_id"], 0)
410+ assert.InDelta(t, float64(1), rec["count"], 0)
411+ assert.InDelta(t, float64(1), rec["location_count"], 0)
412+ assert.InDelta(t, float64(0), rec["dropped"], 0)
413+ }
414+ }
415+ assert.True(t, found)
416+ require.False(t, wv.enqueueAccessibilityPayload(accessibilityPayload{Kind: accessibilityCaptureKindTree, JSON: `{}`}))
417+}
@@ -0,0 +1,417 @@
1+package cef
2+
3+import (
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+ purecef "github.com/bnema/purego-cef/cef"
17+ "github.com/rs/zerolog"
18+ "github.com/stretchr/testify/assert"
19+ "github.com/stretchr/testify/require"
20+)
21+
22+func TestAccessibilityCapture_WritesNumberedJSONWithSecurePerms(t *testing.T) {
23+ dir := t.TempDir()
24+ capture, err := newAccessibilityCapture(dir)
25+ require.NoError(t, err)
26+
27+ require.NoError(t, capture.Write(accessibilityPayload{Kind: accessibilityCaptureKindTree, JSON: `{"a":1}`}))
28+ require.NoError(t, capture.Write(accessibilityPayload{Kind: accessibilityCaptureKindLocation, JSON: `{"b":2}`}))
29+ require.NoError(t, capture.Write(accessibilityPayload{Kind: accessibilityCaptureKindTree, JSON: `{"c":3}`}))
30+
31+ want := []string{"000001-tree.json", "000002-location.json", "000003-tree.json"}
32+ entries, err := os.ReadDir(dir)
33+ require.NoError(t, err)
34+ require.Len(t, entries, 3)
35+ for i, name := range want {
36+ assert.Equal(t, name, entries[i].Name())
37+ info, err := entries[i].Info()
38+ require.NoError(t, err)
39+ assert.Equal(t, os.FileMode(0o600), info.Mode().Perm())
40+ }
41+}
42+
43+func TestAccessibilityCapture_IgnoresEmptyPayload(t *testing.T) {
44+ dir := t.TempDir()
45+ capture, err := newAccessibilityCapture(dir)
46+ require.NoError(t, err)
47+ require.NoError(t, capture.Write(accessibilityPayload{Kind: accessibilityCaptureKindTree, JSON: ""}))
48+ entries, err := os.ReadDir(dir)
49+ require.NoError(t, err)
50+ assert.Empty(t, entries)
51+}
52+
53+func TestAccessibilityCapture_RejectsUnsafePaths(t *testing.T) {
54+ for _, dir := range []string{"", ".", "foo/../bar", "../escape", "a/../../b"} {
55+ _, err := newAccessibilityCapture(dir)
56+ require.Error(t, err, "dir=%q", dir)
57+ }
58+}
59+
60+func TestAccessibilityCapture_RejectsPathLikeAndUnknownKinds(t *testing.T) {
61+ dir := t.TempDir()
62+ capture, err := newAccessibilityCapture(dir)
63+ require.NoError(t, err)
64+
65+ for _, kind := range []string{
66+ "../escape",
67+ "tree/../../etc/passwd",
68+ "tree/foo",
69+ "/tmp/x",
70+ "unknown",
71+ "TREE",
72+ "",
73+ "tree\x00",
74+ } {
75+ writeErr := capture.Write(accessibilityPayload{Kind: kind, JSON: `{"x":1}`})
76+ require.Error(t, writeErr, "kind=%q", kind)
77+ }
78+
79+ entries, err := os.ReadDir(dir)
80+ require.NoError(t, err)
81+ assert.Empty(t, entries)
82+}
83+
84+func TestAccessibilityCapture_NeverOverwritesExistingNumberedFile(t *testing.T) {
85+ dir := t.TempDir()
86+ preexisting := filepath.Join(dir, "000001-tree.json")
87+ const original = `{"keep":true}`
88+ require.NoError(t, os.WriteFile(preexisting, []byte(original), 0o600))
89+
90+ capture, err := newAccessibilityCapture(dir)
91+ require.NoError(t, err)
92+ require.Error(t, capture.Write(accessibilityPayload{Kind: accessibilityCaptureKindTree, JSON: `{"overwrite":true}`}))
93+
94+ got, err := os.ReadFile(preexisting)
95+ require.NoError(t, err)
96+ assert.Equal(t, original, string(got))
97+
98+ entries, err := os.ReadDir(dir)
99+ require.NoError(t, err)
100+ require.Len(t, entries, 1)
101+ assert.Equal(t, "000001-tree.json", entries[0].Name())
102+}
103+
104+func TestAccessibilityCapture_Namespace0700AndChmodExisting(t *testing.T) {
105+ root := t.TempDir()
106+ dir := filepath.Join(root, "webview-9")
107+ require.NoError(t, os.MkdirAll(dir, 0o755))
108+ info, err := os.Stat(dir)
109+ require.NoError(t, err)
110+ require.Equal(t, os.FileMode(0o755), info.Mode().Perm())
111+
112+ capture, err := newAccessibilityCapture(dir)
113+ require.NoError(t, err)
114+ require.NotNil(t, capture)
115+
116+ info, err = os.Stat(dir)
117+ require.NoError(t, err)
118+ assert.Equal(t, os.FileMode(0o700), info.Mode().Perm())
119+}
120+
121+func TestAccessibilityCapture_ConcurrentCounterNoCollision(t *testing.T) {
122+ dir := t.TempDir()
123+ capture, err := newAccessibilityCapture(dir)
124+ require.NoError(t, err)
125+
126+ const n = 32
127+ var wg sync.WaitGroup
128+ var writeErrs atomic.Int64
129+ wg.Add(n)
130+ for i := 0; i < n; i++ {
131+ go func() {
132+ defer wg.Done()
133+ if writeErr := capture.Write(accessibilityPayload{Kind: accessibilityCaptureKindTree, JSON: `{}`}); writeErr != nil {
134+ writeErrs.Add(1)
135+ }
136+ }()
137+ }
138+ wg.Wait()
139+ require.Equal(t, int64(0), writeErrs.Load())
140+
141+ entries, err := os.ReadDir(dir)
142+ require.NoError(t, err)
143+ require.Len(t, entries, n)
144+ seen := make(map[string]struct{}, n)
145+ for _, e := range entries {
146+ _, dup := seen[e.Name()]
147+ assert.False(t, dup, "duplicate %s", e.Name())
148+ seen[e.Name()] = struct{}{}
149+ }
150+}
151+
152+func TestAccessibilityCapture_TwoWebViewIDsIsolated(t *testing.T) {
153+ root := t.TempDir()
154+ dir1 := filepath.Join(root, "webview-1")
155+ dir2 := filepath.Join(root, "webview-2")
156+ c1, err := newAccessibilityCapture(dir1)
157+ require.NoError(t, err)
158+ c2, err := newAccessibilityCapture(dir2)
159+ require.NoError(t, err)
160+
161+ var wg sync.WaitGroup
162+ var writeErrs atomic.Int64
163+ wg.Add(2)
164+ go func() {
165+ defer wg.Done()
166+ if writeErr := c1.Write(accessibilityPayload{Kind: accessibilityCaptureKindTree, JSON: `{"id":1}`}); writeErr != nil {
167+ writeErrs.Add(1)
168+ }
169+ }()
170+ go func() {
171+ defer wg.Done()
172+ if writeErr := c2.Write(accessibilityPayload{Kind: accessibilityCaptureKindTree, JSON: `{"id":2}`}); writeErr != nil {
173+ writeErrs.Add(1)
174+ }
175+ }()
176+ wg.Wait()
177+ require.Equal(t, int64(0), writeErrs.Load())
178+
179+ b1, err := os.ReadFile(filepath.Join(dir1, "000001-tree.json"))
180+ require.NoError(t, err)
181+ b2, err := os.ReadFile(filepath.Join(dir2, "000001-tree.json"))
182+ require.NoError(t, err)
183+ assert.JSONEq(t, `{"id":1}`, string(b1))
184+ assert.JSONEq(t, `{"id":2}`, string(b2))
185+}
186+
187+func TestAccessibilityCaptureDir_UsesLogDirNamespace(t *testing.T) {
188+ home := t.TempDir()
189+ t.Setenv("ENV", "")
190+ t.Setenv("HOME", home)
191+ t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
192+ t.Setenv("XDG_DATA_HOME", filepath.Join(home, ".local", "share"))
193+ t.Setenv("XDG_STATE_HOME", filepath.Join(home, ".local", "state"))
194+ t.Setenv("XDG_CACHE_HOME", filepath.Join(home, ".cache"))
195+
196+ dir, err := accessibilityCaptureDir(42)
197+ require.NoError(t, err)
198+ assert.Equal(t, filepath.Join(home, ".local", "state", "dumber", "logs", "a11y-capture", "webview-42"), dir)
199+}
200+
201+func TestAccessibilityCaptureEnabled_RequiresExactOne(t *testing.T) {
202+ t.Setenv("DUMBER_A11Y_CAPTURE", "")
203+ assert.False(t, accessibilityCaptureEnabled())
204+ t.Setenv("DUMBER_A11Y_CAPTURE", "0")
205+ assert.False(t, accessibilityCaptureEnabled())
206+ t.Setenv("DUMBER_A11Y_CAPTURE", "true")
207+ assert.False(t, accessibilityCaptureEnabled())
208+ t.Setenv("DUMBER_A11Y_CAPTURE", "1")
209+ assert.True(t, accessibilityCaptureEnabled())
210+}
211+
212+func TestAccessibilityCaptureWorker_DropsWhenFullThenCloseDrains(t *testing.T) {
213+ var (
214+ mu sync.Mutex
215+ got []accessibilityPayload
216+ )
217+ w := &accessibilityCaptureWorker{
218+ queue: make(chan accessibilityPayload, 2),
219+ consume: func(p accessibilityPayload) {
220+ mu.Lock()
221+ got = append(got, p)
222+ mu.Unlock()
223+ },
224+ }
225+ require.True(t, w.Submit(accessibilityPayload{Kind: accessibilityCaptureKindTree, JSON: "1"}))
226+ require.True(t, w.Submit(accessibilityPayload{Kind: accessibilityCaptureKindTree, JSON: "2"}))
227+ require.False(t, w.Submit(accessibilityPayload{Kind: accessibilityCaptureKindTree, JSON: "3"}))
228+ assert.Equal(t, uint64(1), w.Dropped())
229+
230+ w.wg.Add(1)
231+ go w.loop()
232+ w.Close()
233+
234+ mu.Lock()
235+ defer mu.Unlock()
236+ require.Len(t, got, 2)
237+ assert.Equal(t, "1", got[0].JSON)
238+ assert.Equal(t, "2", got[1].JSON)
239+ require.False(t, w.Submit(accessibilityPayload{Kind: accessibilityCaptureKindTree, JSON: "4"}))
240+}
241+
242+func TestAccessibilityCaptureWorker_NonBlockingUnderBackpressure(t *testing.T) {
243+ block := make(chan struct{})
244+ started := make(chan struct{})
245+ var consumed atomic.Int64
246+ w := newAccessibilityCaptureWorker(2, func(accessibilityPayload) {
247+ select {
248+ case <-started:
249+ default:
250+ close(started)
251+ }
252+ <-block
253+ consumed.Add(1)
254+ })
255+
256+ require.True(t, w.Submit(accessibilityPayload{Kind: accessibilityCaptureKindTree, JSON: "a"}))
257+ <-started
258+ require.True(t, w.Submit(accessibilityPayload{Kind: accessibilityCaptureKindTree, JSON: "b"}))
259+ require.True(t, w.Submit(accessibilityPayload{Kind: accessibilityCaptureKindTree, JSON: "c"}))
260+
261+ done := make(chan struct{})
262+ go func() {
263+ defer close(done)
264+ assert.False(t, w.Submit(accessibilityPayload{Kind: accessibilityCaptureKindTree, JSON: "d"}))
265+ }()
266+ select {
267+ case <-done:
268+ case <-time.After(2 * time.Second):
269+ t.Fatal("Submit blocked under backpressure")
270+ }
271+ assert.Equal(t, uint64(1), w.Dropped())
272+
273+ close(block)
274+ w.Close()
275+ assert.Equal(t, int64(3), consumed.Load())
276+}
277+
278+func TestAccessibilityStats_ExactAverages(t *testing.T) {
279+ var stats accessibilityStats
280+ stats.Observe(accessibilityPayload{Kind: accessibilityCaptureKindTree, Bytes: 1000, SerializeNanos: 2_000_000})
281+ stats.Observe(accessibilityPayload{Kind: accessibilityCaptureKindTree, Bytes: 3000, SerializeNanos: 4_000_000})
282+ stats.Observe(accessibilityPayload{Kind: accessibilityCaptureKindLocation, Bytes: 100, SerializeNanos: 500_000})
283+
284+ snap := stats.Snapshot()
285+ assert.Equal(t, 3, snap.Count)
286+ assert.Equal(t, 2, snap.TreeCount)
287+ assert.Equal(t, 1, snap.LocationCount)
288+ assert.Equal(t, int64(4100), snap.TotalBytes)
289+ assert.Equal(t, 100, snap.MinBytes)
290+ assert.Equal(t, 3000, snap.MaxBytes)
291+ assert.InDelta(t, 1366.6666666666667, snap.AverageBytes, 1e-9)
292+ assert.Equal(t, int64(6_500_000), snap.TotalSerializeNanos)
293+ assert.InDelta(t, 2_166_666.6666666665, snap.AverageSerializeNanos, 1e-9)
294+ assert.Equal(t, int64(4_000_000), snap.MaxSerializeNanos)
295+}
296+
297+func TestAccessibilityStats_EmptySnapshotZero(t *testing.T) {
298+ var stats accessibilityStats
299+ snap := stats.Snapshot()
300+ assert.Equal(t, accessibilityStatsSnapshot{}, snap)
301+}
302+
303+func TestAccessibilityCapture_InactiveWithoutEnvZeroWork(t *testing.T) {
304+ t.Setenv("DUMBER_A11Y_CAPTURE", "")
305+ var output bytes.Buffer
306+ logger := zerolog.New(&output).Level(zerolog.DebugLevel)
307+ ctx := logging.WithContext(context.Background(), logger)
308+
309+ wv := &WebView{ctx: ctx, id: port.WebViewID(7), viewBridge: &Cef2gtkAdapter{}}
310+ require.Nil(t, wv.a11yWorker)
311+ require.Nil(t, wv.a11yCapture)
312+
313+ rh := newDumberRenderHandler(wv)
314+ require.NotNil(t, rh)
315+ handler, ok := rh.GetAccessibilityHandler().(*accessibilityHandler)
316+ require.True(t, ok)
317+ require.Nil(t, handler.sink)
318+
319+ writeCalls := 0
320+ handler.writeJSON = func(purecef.Value, purecef.JsonWriterOptions) string {
321+ writeCalls++
322+ return `{"x":1}`
323+ }
324+ handler.OnAccessibilityTreeChange(stubCEFValue{})
325+ handler.OnAccessibilityLocationChange(stubCEFValue{})
326+ assert.Equal(t, 0, writeCalls)
327+ assert.Equal(t, accessibilityStatsSnapshot{}, wv.a11yStats.Snapshot())
328+ assert.Empty(t, output.String())
329+ assert.False(t, wv.enqueueAccessibilityPayload(accessibilityPayload{Kind: accessibilityCaptureKindTree, JSON: `{}`}))
330+}
331+
332+func TestAccessibilityCapture_ConsumeLogsAndWritesOffCallback(t *testing.T) {
333+ dir := t.TempDir()
334+ capture, err := newAccessibilityCapture(dir)
335+ require.NoError(t, err)
336+
337+ var output bytes.Buffer
338+ logger := zerolog.New(&output).Level(zerolog.InfoLevel)
339+ ctx := logging.WithContext(context.Background(), logger)
340+
341+ wv := &WebView{
342+ ctx: ctx,
343+ id: port.WebViewID(3),
344+ a11yCapture: capture,
345+ }
346+ wv.a11yWorker = newAccessibilityCaptureWorker(8, wv.consumeAccessibilityPayload)
347+
348+ require.True(t, wv.enqueueAccessibilityPayload(accessibilityPayload{
349+ Kind: accessibilityCaptureKindTree, JSON: `{"ok":true}`, Bytes: 11, SerializeNanos: 123,
350+ }))
351+ require.Eventually(t, func() bool {
352+ _, err := os.Stat(filepath.Join(dir, "000001-tree.json"))
353+ return err == nil
354+ }, 2*time.Second, 10*time.Millisecond)
355+ wv.a11yWorker.Close()
356+
357+ snap := wv.a11yStats.Snapshot()
358+ assert.Equal(t, 1, snap.Count)
359+ assert.Equal(t, 1, snap.TreeCount)
360+ assert.Equal(t, 11, snap.MaxBytes)
361+
362+ var found bool
363+ dec := json.NewDecoder(bytes.NewReader(output.Bytes()))
364+ for dec.More() {
365+ var rec map[string]any
366+ require.NoError(t, dec.Decode(&rec))
367+ if rec["message"] == "cef: accessibility update" {
368+ found = true
369+ assert.InDelta(t, float64(3), rec["webview_id"], 0)
370+ assert.Equal(t, accessibilityCaptureKindTree, rec["kind"])
371+ assert.InDelta(t, float64(11), rec["bytes"], 0)
372+ assert.InDelta(t, float64(123), rec["serialize_ns"], 0)
373+ }
374+ }
375+ assert.True(t, found)
376+}
377+
378+func TestAccessibilityCapture_DestroyEmitsSummary(t *testing.T) {
379+ dir := t.TempDir()
380+ capture, err := newAccessibilityCapture(dir)
381+ require.NoError(t, err)
382+
383+ var output bytes.Buffer
384+ logger := zerolog.New(&output).Level(zerolog.InfoLevel)
385+ ctx := logging.WithContext(context.Background(), logger)
386+
387+ wv := &WebView{
388+ ctx: ctx,
389+ id: port.WebViewID(5),
390+ a11yCapture: capture,
391+ }
392+ wv.a11yWorker = newAccessibilityCaptureWorker(4, wv.consumeAccessibilityPayload)
393+ require.True(t, wv.enqueueAccessibilityPayload(accessibilityPayload{
394+ Kind: accessibilityCaptureKindLocation, JSON: `{}`, Bytes: 2, SerializeNanos: 50,
395+ }))
396+ require.Eventually(t, func() bool {
397+ return wv.a11yStats.Snapshot().Count == 1
398+ }, 2*time.Second, 10*time.Millisecond)
399+
400+ wv.shutdownAccessibilityCapture()
401+
402+ var found bool
403+ dec := json.NewDecoder(bytes.NewReader(output.Bytes()))
404+ for dec.More() {
405+ var rec map[string]any
406+ require.NoError(t, dec.Decode(&rec))
407+ if rec["message"] == "cef: accessibility summary" {
408+ found = true
409+ assert.InDelta(t, float64(5), rec["webview_id"], 0)
410+ assert.InDelta(t, float64(1), rec["count"], 0)
411+ assert.InDelta(t, float64(1), rec["location_count"], 0)
412+ assert.InDelta(t, float64(0), rec["dropped"], 0)
413+ }
414+ }
415+ assert.True(t, found)
416+ require.False(t, wv.enqueueAccessibilityPayload(accessibilityPayload{Kind: accessibilityCaptureKindTree, JSON: `{}`}))
417+}
M internal/infrastructure/cef/accessibility_handler.go +2 -8
@@ -15,12 +15,6 @@ type accessibilityPayload struct {
1515 SerializeNanos int64
1616 }
1717
18-// accessibilityPayloadSink is the optional capture worker seam. Production
19-// leaves WebView.a11yWorker nil unless DUMBER_A11Y_CAPTURE=1.
20-type accessibilityPayloadSink interface {
21- Submit(accessibilityPayload) bool
22-}
23-
2418 type accessibilityHandler struct {
2519 sink func(accessibilityPayload) bool
2620 writeJSON func(purecef.Value, purecef.JsonWriterOptions) string
@@ -36,11 +30,11 @@ func newAccessibilityHandler(sink func(accessibilityPayload) bool) purecef.Acces
3630 }
3731
3832 func (h *accessibilityHandler) OnAccessibilityTreeChange(value purecef.Value) {
39- h.emit("tree", value)
33+ h.emit(accessibilityCaptureKindTree, value)
4034 }
4135
4236 func (h *accessibilityHandler) OnAccessibilityLocationChange(value purecef.Value) {
43- h.emit("location", value)
37+ h.emit(accessibilityCaptureKindLocation, value)
4438 }
4539
4640 func (h *accessibilityHandler) emit(kind string, value purecef.Value) {
@@ -15,12 +15,6 @@ type accessibilityPayload struct {
15 SerializeNanos int6415 SerializeNanos int64
16 }16 }
17 17
18-// accessibilityPayloadSink is the optional capture worker seam. Production
19-// leaves WebView.a11yWorker nil unless DUMBER_A11Y_CAPTURE=1.
20-type accessibilityPayloadSink interface {
21- Submit(accessibilityPayload) bool
22-}
23-
24 type accessibilityHandler struct {18 type accessibilityHandler struct {
25 sink func(accessibilityPayload) bool19 sink func(accessibilityPayload) bool
26 writeJSON func(purecef.Value, purecef.JsonWriterOptions) string20 writeJSON func(purecef.Value, purecef.JsonWriterOptions) string
@@ -36,11 +30,11 @@ func newAccessibilityHandler(sink func(accessibilityPayload) bool) purecef.Acces
36 }30 }
37 31
38 func (h *accessibilityHandler) OnAccessibilityTreeChange(value purecef.Value) {32 func (h *accessibilityHandler) OnAccessibilityTreeChange(value purecef.Value) {
39- h.emit("tree", value)33+ h.emit(accessibilityCaptureKindTree, value)
40 }34 }
41 35
42 func (h *accessibilityHandler) OnAccessibilityLocationChange(value purecef.Value) {36 func (h *accessibilityHandler) OnAccessibilityLocationChange(value purecef.Value) {
43- h.emit("location", value)37+ h.emit(accessibilityCaptureKindLocation, value)
44 }38 }
45 39
46 func (h *accessibilityHandler) emit(kind string, value purecef.Value) {40 func (h *accessibilityHandler) emit(kind string, value purecef.Value) {
M internal/infrastructure/cef/accessibility_handler_test.go +2 -2
@@ -120,11 +120,11 @@ func TestAccessibilityHandler_TreeAndLocationWriteJSONOnce(t *testing.T) {
120120
121121 require.Len(t, writeCalls, 2)
122122 require.Len(t, got, 2)
123- assert.Equal(t, "tree", got[0].Kind)
123+ assert.Equal(t, accessibilityCaptureKindTree, got[0].Kind)
124124 assert.Equal(t, `{"ok":true}`, got[0].JSON)
125125 assert.Equal(t, len(`{"ok":true}`), got[0].Bytes)
126126 assert.GreaterOrEqual(t, got[0].SerializeNanos, int64(0))
127- assert.Equal(t, "location", got[1].Kind)
127+ assert.Equal(t, accessibilityCaptureKindLocation, got[1].Kind)
128128 assert.Equal(t, `{"ok":true}`, got[1].JSON)
129129 assert.Equal(t, len(`{"ok":true}`), got[1].Bytes)
130130 assert.GreaterOrEqual(t, got[1].SerializeNanos, int64(0))
@@ -120,11 +120,11 @@ func TestAccessibilityHandler_TreeAndLocationWriteJSONOnce(t *testing.T) {
120 120
121 require.Len(t, writeCalls, 2)121 require.Len(t, writeCalls, 2)
122 require.Len(t, got, 2)122 require.Len(t, got, 2)
123- assert.Equal(t, "tree", got[0].Kind)123+ assert.Equal(t, accessibilityCaptureKindTree, got[0].Kind)
124 assert.Equal(t, `{"ok":true}`, got[0].JSON)124 assert.Equal(t, `{"ok":true}`, got[0].JSON)
125 assert.Equal(t, len(`{"ok":true}`), got[0].Bytes)125 assert.Equal(t, len(`{"ok":true}`), got[0].Bytes)
126 assert.GreaterOrEqual(t, got[0].SerializeNanos, int64(0))126 assert.GreaterOrEqual(t, got[0].SerializeNanos, int64(0))
127- assert.Equal(t, "location", got[1].Kind)127+ assert.Equal(t, accessibilityCaptureKindLocation, got[1].Kind)
128 assert.Equal(t, `{"ok":true}`, got[1].JSON)128 assert.Equal(t, `{"ok":true}`, got[1].JSON)
129 assert.Equal(t, len(`{"ok":true}`), got[1].Bytes)129 assert.Equal(t, len(`{"ok":true}`), got[1].Bytes)
130 assert.GreaterOrEqual(t, got[1].SerializeNanos, int64(0))130 assert.GreaterOrEqual(t, got[1].SerializeNanos, int64(0))
A internal/infrastructure/cef/accessibility_trace.go +41 -0
@@ -0,0 +1,41 @@
1+package cef
2+
3+import (
4+ "context"
5+
6+ "github.com/bnema/dumber/internal/application/port"
7+ "github.com/bnema/dumber/internal/logging"
8+)
9+
10+func logAccessibilityCaptureEnabled(ctx context.Context, webViewID port.WebViewID, captureDir string) {
11+ logging.FromContext(ctx).Info().
12+ Uint64("webview_id", uint64(webViewID)).
13+ Str("capture_dir", captureDir).
14+ Msg("cef: accessibility capture enabled")
15+}
16+
17+func logAccessibilityUpdate(ctx context.Context, webViewID port.WebViewID, p accessibilityPayload) {
18+ logging.FromContext(ctx).Info().
19+ Uint64("webview_id", uint64(webViewID)).
20+ Str("kind", p.Kind).
21+ Int("bytes", p.Bytes).
22+ Int64("serialize_ns", p.SerializeNanos).
23+ Msg("cef: accessibility update")
24+}
25+
26+func logAccessibilitySummary(ctx context.Context, webViewID port.WebViewID, snap accessibilityStatsSnapshot, dropped uint64) {
27+ logging.FromContext(ctx).Info().
28+ Uint64("webview_id", uint64(webViewID)).
29+ Int("count", snap.Count).
30+ Int("tree_count", snap.TreeCount).
31+ Int("location_count", snap.LocationCount).
32+ Int64("total_bytes", snap.TotalBytes).
33+ Int("min_bytes", snap.MinBytes).
34+ Int("max_bytes", snap.MaxBytes).
35+ Float64("average_bytes", snap.AverageBytes).
36+ Int64("total_serialize_ns", snap.TotalSerializeNanos).
37+ Float64("average_serialize_ns", snap.AverageSerializeNanos).
38+ Int64("max_serialize_ns", snap.MaxSerializeNanos).
39+ Uint64("dropped", dropped).
40+ Msg("cef: accessibility summary")
41+}
@@ -0,0 +1,41 @@
1+package cef
2+
3+import (
4+ "context"
5+
6+ "github.com/bnema/dumber/internal/application/port"
7+ "github.com/bnema/dumber/internal/logging"
8+)
9+
10+func logAccessibilityCaptureEnabled(ctx context.Context, webViewID port.WebViewID, captureDir string) {
11+ logging.FromContext(ctx).Info().
12+ Uint64("webview_id", uint64(webViewID)).
13+ Str("capture_dir", captureDir).
14+ Msg("cef: accessibility capture enabled")
15+}
16+
17+func logAccessibilityUpdate(ctx context.Context, webViewID port.WebViewID, p accessibilityPayload) {
18+ logging.FromContext(ctx).Info().
19+ Uint64("webview_id", uint64(webViewID)).
20+ Str("kind", p.Kind).
21+ Int("bytes", p.Bytes).
22+ Int64("serialize_ns", p.SerializeNanos).
23+ Msg("cef: accessibility update")
24+}
25+
26+func logAccessibilitySummary(ctx context.Context, webViewID port.WebViewID, snap accessibilityStatsSnapshot, dropped uint64) {
27+ logging.FromContext(ctx).Info().
28+ Uint64("webview_id", uint64(webViewID)).
29+ Int("count", snap.Count).
30+ Int("tree_count", snap.TreeCount).
31+ Int("location_count", snap.LocationCount).
32+ Int64("total_bytes", snap.TotalBytes).
33+ Int("min_bytes", snap.MinBytes).
34+ Int("max_bytes", snap.MaxBytes).
35+ Float64("average_bytes", snap.AverageBytes).
36+ Int64("total_serialize_ns", snap.TotalSerializeNanos).
37+ Float64("average_serialize_ns", snap.AverageSerializeNanos).
38+ Int64("max_serialize_ns", snap.MaxSerializeNanos).
39+ Uint64("dropped", dropped).
40+ Msg("cef: accessibility summary")
41+}
A internal/infrastructure/cef/accessibility_trace_test.go +28 -0
@@ -0,0 +1,28 @@
1+package cef
2+
3+import (
4+ "bytes"
5+ "context"
6+ "encoding/json"
7+ "testing"
8+
9+ "github.com/bnema/dumber/internal/application/port"
10+ "github.com/bnema/dumber/internal/logging"
11+ "github.com/rs/zerolog"
12+ "github.com/stretchr/testify/assert"
13+ "github.com/stretchr/testify/require"
14+)
15+
16+func TestLogAccessibilityCaptureEnabled_Fields(t *testing.T) {
17+ var output bytes.Buffer
18+ logger := zerolog.New(&output).Level(zerolog.InfoLevel)
19+ ctx := logging.WithContext(context.Background(), logger)
20+
21+ logAccessibilityCaptureEnabled(ctx, port.WebViewID(9), "/tmp/a11y-capture/webview-9")
22+
23+ var rec map[string]any
24+ require.NoError(t, json.Unmarshal(output.Bytes(), &rec))
25+ assert.Equal(t, "cef: accessibility capture enabled", rec["message"])
26+ assert.InDelta(t, float64(9), rec["webview_id"], 0)
27+ assert.Equal(t, "/tmp/a11y-capture/webview-9", rec["capture_dir"])
28+}
@@ -0,0 +1,28 @@
1+package cef
2+
3+import (
4+ "bytes"
5+ "context"
6+ "encoding/json"
7+ "testing"
8+
9+ "github.com/bnema/dumber/internal/application/port"
10+ "github.com/bnema/dumber/internal/logging"
11+ "github.com/rs/zerolog"
12+ "github.com/stretchr/testify/assert"
13+ "github.com/stretchr/testify/require"
14+)
15+
16+func TestLogAccessibilityCaptureEnabled_Fields(t *testing.T) {
17+ var output bytes.Buffer
18+ logger := zerolog.New(&output).Level(zerolog.InfoLevel)
19+ ctx := logging.WithContext(context.Background(), logger)
20+
21+ logAccessibilityCaptureEnabled(ctx, port.WebViewID(9), "/tmp/a11y-capture/webview-9")
22+
23+ var rec map[string]any
24+ require.NoError(t, json.Unmarshal(output.Bytes(), &rec))
25+ assert.Equal(t, "cef: accessibility capture enabled", rec["message"])
26+ assert.InDelta(t, float64(9), rec["webview_id"], 0)
27+ assert.Equal(t, "/tmp/a11y-capture/webview-9", rec["capture_dir"])
28+}
M internal/infrastructure/cef/factory.go +8 -0
@@ -156,6 +156,14 @@ func (f *WebViewFactory) newWebView(ctx context.Context) (*WebView, error) {
156156 wv.handlers = handlers
157157 wv.findCtrl = newFindController()
158158
159+ // Capture worker must exist before NewClient so the lazily wrapped render
160+ // handler can install a real sink when DUMBER_A11Y_CAPTURE=1.
161+ if err := wv.enableAccessibilityCaptureIfRequested(); err != nil {
162+ logging.FromContext(ctx).Warn().Err(err).
163+ Uint64("webview_id", uint64(id)).
164+ Msg("cef: accessibility capture setup failed")
165+ }
166+
159167 // Build a CEF client backed by our handlerSet.
160168 // Store on WebView to prevent GC collection before CEF AddRef's it.
161169 wv.client = purecef.NewClient(wv.handlers)
@@ -156,6 +156,14 @@ func (f *WebViewFactory) newWebView(ctx context.Context) (*WebView, error) {
156 wv.handlers = handlers156 wv.handlers = handlers
157 wv.findCtrl = newFindController()157 wv.findCtrl = newFindController()
158 158
159+ // Capture worker must exist before NewClient so the lazily wrapped render
160+ // handler can install a real sink when DUMBER_A11Y_CAPTURE=1.
161+ if err := wv.enableAccessibilityCaptureIfRequested(); err != nil {
162+ logging.FromContext(ctx).Warn().Err(err).
163+ Uint64("webview_id", uint64(id)).
164+ Msg("cef: accessibility capture setup failed")
165+ }
166+
159 // Build a CEF client backed by our handlerSet.167 // Build a CEF client backed by our handlerSet.
160 // Store on WebView to prevent GC collection before CEF AddRef's it.168 // Store on WebView to prevent GC collection before CEF AddRef's it.
161 wv.client = purecef.NewClient(wv.handlers)169 wv.client = purecef.NewClient(wv.handlers)
M internal/infrastructure/cef/webview.go +4 -1
@@ -154,7 +154,9 @@ type WebView struct {
154154
155155 // a11yWorker receives serialized accessibility payloads off the CEF UI
156156 // thread. Production leaves it nil unless DUMBER_A11Y_CAPTURE=1.
157- a11yWorker accessibilityPayloadSink
157+ a11yWorker *accessibilityCaptureWorker
158+ a11yCapture *accessibilityCapture
159+ a11yStats accessibilityStats
158160
159161 // beginFrameTick drives CEF external BeginFrame requests while the GTK
160162 // widget is visible. Access is guarded by mu.
@@ -1226,6 +1228,7 @@ func (wv *WebView) Destroy() {
12261228 if !wv.destroyed.CompareAndSwap(false, true) {
12271229 return
12281230 }
1231+ wv.shutdownAccessibilityCapture()
12291232 wv.resetPageScrollQueue()
12301233 wv.syntheticPopupMu.Lock()
12311234 wv.syntheticPopups = nil
@@ -154,7 +154,9 @@ type WebView struct {
154 154
155 // a11yWorker receives serialized accessibility payloads off the CEF UI155 // a11yWorker receives serialized accessibility payloads off the CEF UI
156 // thread. Production leaves it nil unless DUMBER_A11Y_CAPTURE=1.156 // thread. Production leaves it nil unless DUMBER_A11Y_CAPTURE=1.
157- a11yWorker accessibilityPayloadSink157+ a11yWorker *accessibilityCaptureWorker
158+ a11yCapture *accessibilityCapture
159+ a11yStats accessibilityStats
158 160
159 // beginFrameTick drives CEF external BeginFrame requests while the GTK161 // beginFrameTick drives CEF external BeginFrame requests while the GTK
160 // widget is visible. Access is guarded by mu.162 // widget is visible. Access is guarded by mu.
@@ -1226,6 +1228,7 @@ func (wv *WebView) Destroy() {
1226 if !wv.destroyed.CompareAndSwap(false, true) {1228 if !wv.destroyed.CompareAndSwap(false, true) {
1227 return1229 return
1228 }1230 }
1231+ wv.shutdownAccessibilityCapture()
1229 wv.resetPageScrollQueue()1232 wv.resetPageScrollQueue()
1230 wv.syntheticPopupMu.Lock()1233 wv.syntheticPopupMu.Lock()
1231 wv.syntheticPopups = nil1234 wv.syntheticPopups = nil

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 8

Tags 42

↑↓ moveenter openesc close