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 { | |||
| 15 | 15 | 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 | 18 | type accessibilityHandler struct { | |
| 25 | 19 | sink func(accessibilityPayload) bool | |
| 26 | 20 | writeJSON func(purecef.Value, purecef.JsonWriterOptions) string | |
| @@ -36,11 +30,11 @@ func newAccessibilityHandler(sink func(accessibilityPayload) bool) purecef.Acces | |||
| 36 | 30 | } | |
| 37 | 31 | ||
| 38 | 32 | func (h *accessibilityHandler) OnAccessibilityTreeChange(value purecef.Value) { | |
| 39 | - | h.emit("tree", value) | |
| 33 | + | h.emit(accessibilityCaptureKindTree, value) | |
| 40 | 34 | } | |
| 41 | 35 | ||
| 42 | 36 | func (h *accessibilityHandler) OnAccessibilityLocationChange(value purecef.Value) { | |
| 43 | - | h.emit("location", value) | |
| 37 | + | h.emit(accessibilityCaptureKindLocation, value) | |
| 44 | 38 | } | |
| 45 | 39 | ||
| 46 | 40 | func (h *accessibilityHandler) emit(kind string, value purecef.Value) { | |
| @@ -15,12 +15,6 @@ type accessibilityPayload struct { | |||||
| 15 | SerializeNanos int64 | 15 | 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) bool | 19 | sink func(accessibilityPayload) bool | ||
| 26 | writeJSON func(purecef.Value, purecef.JsonWriterOptions) string | 20 | 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) { | |||
| 120 | 120 | ||
| 121 | 121 | require.Len(t, writeCalls, 2) | |
| 122 | 122 | require.Len(t, got, 2) | |
| 123 | - | assert.Equal(t, "tree", got[0].Kind) | |
| 123 | + | assert.Equal(t, accessibilityCaptureKindTree, got[0].Kind) | |
| 124 | 124 | assert.Equal(t, `{"ok":true}`, got[0].JSON) | |
| 125 | 125 | assert.Equal(t, len(`{"ok":true}`), got[0].Bytes) | |
| 126 | 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 | 128 | assert.Equal(t, `{"ok":true}`, got[1].JSON) | |
| 129 | 129 | assert.Equal(t, len(`{"ok":true}`), got[1].Bytes) | |
| 130 | 130 | 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) { | |||
| 156 | 156 | wv.handlers = handlers | |
| 157 | 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 | 167 | // Build a CEF client backed by our handlerSet. | |
| 160 | 168 | // Store on WebView to prevent GC collection before CEF AddRef's it. | |
| 161 | 169 | wv.client = purecef.NewClient(wv.handlers) | |
| @@ -156,6 +156,14 @@ func (f *WebViewFactory) newWebView(ctx context.Context) (*WebView, error) { | |||||
| 156 | wv.handlers = handlers | 156 | 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 { | |||
| 154 | 154 | ||
| 155 | 155 | // a11yWorker receives serialized accessibility payloads off the CEF UI | |
| 156 | 156 | // thread. Production leaves it nil unless DUMBER_A11Y_CAPTURE=1. | |
| 157 | - | a11yWorker accessibilityPayloadSink | |
| 157 | + | a11yWorker *accessibilityCaptureWorker | |
| 158 | + | a11yCapture *accessibilityCapture | |
| 159 | + | a11yStats accessibilityStats | |
| 158 | 160 | ||
| 159 | 161 | // beginFrameTick drives CEF external BeginFrame requests while the GTK | |
| 160 | 162 | // widget is visible. Access is guarded by mu. | |
| @@ -1226,6 +1228,7 @@ func (wv *WebView) Destroy() { | |||
| 1226 | 1228 | if !wv.destroyed.CompareAndSwap(false, true) { | |
| 1227 | 1229 | return | |
| 1228 | 1230 | } | |
| 1231 | + | wv.shutdownAccessibilityCapture() | |
| 1229 | 1232 | wv.resetPageScrollQueue() | |
| 1230 | 1233 | wv.syntheticPopupMu.Lock() | |
| 1231 | 1234 | wv.syntheticPopups = nil | |
| @@ -154,7 +154,9 @@ type WebView struct { | |||||
| 154 | 154 | ||||
| 155 | // a11yWorker receives serialized accessibility payloads off the CEF UI | 155 | // 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 accessibilityPayloadSink | 157 | + | a11yWorker *accessibilityCaptureWorker |
| 158 | + | a11yCapture *accessibilityCapture | |||
| 159 | + | a11yStats accessibilityStats | |||
| 158 | 160 | ||||
| 159 | // beginFrameTick drives CEF external BeginFrame requests while the GTK | 161 | // 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 | return | 1229 | return | ||
| 1228 | } | 1230 | } | ||
| 1231 | + | wv.shutdownAccessibilityCapture() | |||
| 1229 | wv.resetPageScrollQueue() | 1232 | wv.resetPageScrollQueue() | ||
| 1230 | wv.syntheticPopupMu.Lock() | 1233 | wv.syntheticPopupMu.Lock() | ||
| 1231 | wv.syntheticPopups = nil | 1234 | wv.syntheticPopups = nil | ||