v0.33.1 9577fee

accessibility_capture_test.go RawHistoryFolder

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

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