webview_scroll_cancel_test.go RawHistoryFolder

1package cef
2
3import (
4	"context"
5	"sync"
6	"testing"
7	"time"
8
9	purecef "github.com/bnema/purego-cef/cef"
10	cefmocks "github.com/bnema/purego-cef/cef/mocks"
11	cef2gtk "github.com/bnema/purego-cef2gtk"
12	"github.com/stretchr/testify/require"
13)
14
15// recordingScrollBridge mirrors the library retired-epoch contract: Invalidate
16// retires the live gesture and returns its epoch while retaining it for
17// epoch-scoped cleanup; cleanup only clears the matching live gesture,
18// never a newer one, and reports whether it cleared anything.
19type recordingScrollBridge struct {
20	mu            sync.Mutex
21	current       uint64
22	live          uint64
23	invalidations int
24	cleanups      []uint64
25}
26
27func (f *recordingScrollBridge) startGesture() uint64 {
28	f.mu.Lock()
29	defer f.mu.Unlock()
30	f.current++
31	f.live = f.current
32	return f.live
33}
34
35func (f *recordingScrollBridge) InvalidateScroll() uint64 {
36	f.mu.Lock()
37	defer f.mu.Unlock()
38	f.invalidations++
39	// Retain the retired gesture until matching cleanup clears it, like
40	// the library session that stays stale until cleanupEpoch runs.
41	return f.live
42}
43
44func (f *recordingScrollBridge) CancelScrollEpoch(epoch uint64) bool {
45	f.mu.Lock()
46	defer f.mu.Unlock()
47	f.cleanups = append(f.cleanups, epoch)
48	if epoch == 0 || epoch != f.live {
49		return false
50	}
51	f.live = 0
52	return true
53}
54
55func (f *recordingScrollBridge) stats() (invalidations int, cleanups []uint64, live uint64) {
56	f.mu.Lock()
57	defer f.mu.Unlock()
58	return f.invalidations, append([]uint64(nil), f.cleanups...), f.live
59}
60
61func TestScrollCancelOptionsMapping(t *testing.T) {
62	wv := &WebView{
63		inputConfig: RuntimeInputConfig{
64			ScrollTouchpadInertia: true,
65			ScrollWheelSmoothing:  true,
66		},
67	}
68	opts := wv.bridgeInputOptions()
69	require.True(t, opts.Scroll.TouchpadInertia)
70	require.True(t, opts.Scroll.WheelSmoothing)
71
72	wvDisabled := &WebView{inputConfig: RuntimeInputConfig{}}
73	disabled := wvDisabled.bridgeInputOptions()
74	require.False(t, disabled.Scroll.TouchpadInertia, "explicit false must survive mapping")
75	require.False(t, disabled.Scroll.WheelSmoothing, "explicit false must survive mapping")
76}
77
78func TestNavCommandGTKInlineInvalidatesBeforeDispatch(t *testing.T) {
79	fake := &recordingScrollBridge{}
80	fake.startGesture()
81	var order []string
82	browser := cefmocks.NewMockBrowser(t)
83	browser.EXPECT().Reload().Run(func() { order = append(order, "dispatch") }).Return().Once()
84
85	wv := &WebView{ctx: context.Background(), browser: browser, scrollCancelSeam: fakeWrap(fake, &order)}
86
87	require.NoError(t, wv.Reload(context.Background()))
88
89	invalidations, cleanups, live := fake.stats()
90	require.Equal(t, 1, invalidations)
91	require.Len(t, cleanups, 1)
92	require.Zero(t, live)
93	require.Equal(t, []string{"invalidate", "cleanup", "dispatch"}, order)
94}
95
96// fakeWrap records invalidate/cleanup ordering around the fake.
97func fakeWrap(fake *recordingScrollBridge, order *[]string) *orderScrollBridge {
98	return &orderScrollBridge{fake: fake, order: order}
99}
100
101type orderScrollBridge struct {
102	fake  *recordingScrollBridge
103	order *[]string
104}
105
106func (o *orderScrollBridge) InvalidateScroll() uint64 {
107	*o.order = append(*o.order, "invalidate")
108	return o.fake.InvalidateScroll()
109}
110
111func (o *orderScrollBridge) CancelScrollEpoch(epoch uint64) bool {
112	*o.order = append(*o.order, "cleanup")
113	return o.fake.CancelScrollEpoch(epoch)
114}
115
116func TestNavCommandOffThreadDispatchCompletes(t *testing.T) {
117	fake := &recordingScrollBridge{}
118	fake.startGesture()
119	browser := cefmocks.NewMockBrowser(t)
120	browser.EXPECT().GoBack().Return().Once()
121
122	wv := &WebView{
123		ctx:             context.Background(),
124		engine:          &Engine{},
125		browser:         browser,
126		scrollCancelSeam: fake,
127		gtkSyncIsOwner:  func() bool { return false },
128		gtkSyncDispatch: func(fn func()) { go fn() },
129		gtkSyncTimeout:  2 * time.Second,
130	}
131
132	require.NoError(t, wv.GoBack(context.Background()))
133	invalidations, cleanups, live := fake.stats()
134	require.Equal(t, 1, invalidations)
135	require.Len(t, cleanups, 1)
136	require.Zero(t, live)
137}
138
139func TestNavCommandDispatchTimeoutSkipsNavigation(t *testing.T) {
140	fake := &recordingScrollBridge{}
141	fake.startGesture()
142
143	wv := &WebView{
144		ctx:             context.Background(),
145		engine:          &Engine{},
146		scrollCancelSeam: fake,
147		gtkSyncIsOwner:  func() bool { return false },
148		gtkSyncDispatch: func(func()) {},
149		gtkSyncTimeout:  20 * time.Millisecond,
150	}
151
152	err := wv.LoadURI(context.Background(), "https://example.com/")
153	require.Error(t, err)
154	require.Contains(t, err.Error(), "did not complete")
155	require.Empty(t, wv.pendingNavigationURI(), "abandoned queued work must never navigate")
156	invalidations, _, _ := fake.stats()
157	require.Equal(t, 1, invalidations, "motion retires even when dispatch fails")
158}
159
160func TestNavCommandPreservesErrorContract(t *testing.T) {
161	fake := &recordingScrollBridge{}
162	destroyed := &WebView{}
163	destroyed.destroyed.Store(true)
164	require.ErrorIs(t, destroyed.Reload(context.Background()), errDestroyed)
165
166	noBrowser := &WebView{ctx: context.Background(), scrollCancelSeam: fake}
167	require.ErrorIs(t, noBrowser.Reload(context.Background()), errNoBrowser)
168	require.ErrorIs(t, noBrowser.GoBack(context.Background()), errNoBrowser)
169	require.ErrorIs(t, noBrowser.GoForward(context.Background()), errNoBrowser)
170	require.ErrorIs(t, noBrowser.ReloadBypassCache(context.Background()), errNoBrowser)
171	require.ErrorIs(t, noBrowser.LoadHTML(context.Background(), "<p>hi</p>", ""), errNoBrowser)
172}
173
174func TestQueuedCleanupAfterFreshGesture(t *testing.T) {
175	fake := &recordingScrollBridge{}
176	wv := &WebView{ctx: context.Background(), scrollCancelSeam: fake}
177
178	first := fake.startGesture()
179	retired := wv.scrollMotionRetired()
180	require.Equal(t, first, retired)
181
182	second := fake.startGesture()
183	require.NotEqual(t, first, second)
184	// The stale cleanup cannot cancel the fresh gesture.
185	wv.cancelScrollEpochOnGTK(retired)
186	_, _, live := fake.stats()
187	require.Equal(t, second, live)
188	require.True(t, func() bool {
189		fake.mu.Lock()
190		defer fake.mu.Unlock()
191		for _, e := range fake.cleanups {
192			if e == retired {
193				return true
194			}
195		}
196		return false
197	}())
198}
199
200func TestInvalidateScrollMotionQueuesEpochCleanup(t *testing.T) {
201	fake := &recordingScrollBridge{}
202	wv := &WebView{ctx: context.Background(), scrollCancelSeam: fake}
203	live := fake.startGesture()
204
205	// engine==nil runs runOnGTK inline, so the queued cleanup applies now.
206	require.Equal(t, live, wv.invalidateScrollMotion())
207	_, cleanups, current := fake.stats()
208	require.Equal(t, []uint64{live}, cleanups)
209	require.Zero(t, current)
210}
211
212func TestPreBrowseInvalidationKeepsDecision(t *testing.T) {
213	fake := &recordingScrollBridge{}
214	fake.startGesture()
215	frame := cefmocks.NewMockFrame(t)
216	frame.EXPECT().IsMain().Return(true)
217	// downloadHandler is nil without an engine, so the request method is
218	// never consulted; the request only needs to be non-nil to reach
219	// pre-commit invalidation.
220	request := cefmocks.NewMockRequest(t)
221	h := &handlerSet{wv: &WebView{ctx: context.Background(), scrollCancelSeam: fake}}
222
223	require.False(t, h.OnBeforeBrowse(nil, frame, request, 0, 0))
224	invalidations, _, _ := fake.stats()
225	require.Equal(t, 1, invalidations, "main-frame pre-commit invalidates")
226}
227
228func TestPreBrowseSubframeDoesNotCancel(t *testing.T) {
229	fake := &recordingScrollBridge{}
230	fake.startGesture()
231	frame := cefmocks.NewMockFrame(t)
232	frame.EXPECT().IsMain().Return(false)
233	h := &handlerSet{wv: &WebView{ctx: context.Background(), scrollCancelSeam: fake}}
234
235	require.False(t, h.OnBeforeBrowse(nil, frame, nil, 0, 0))
236	invalidations, _, live := fake.stats()
237	require.Zero(t, invalidations, "subframe navigation must not cancel the main gesture")
238	require.NotZero(t, live)
239}
240
241func TestLoadStartFallbackInvalidates(t *testing.T) {
242	fake := &recordingScrollBridge{}
243	fake.startGesture()
244	frame := cefmocks.NewMockFrame(t)
245	frame.EXPECT().IsMain().Return(true).Once()
246	frame.EXPECT().GetURL().Return("https://example.com/").Twice()
247	h := &handlerSet{wv: &WebView{ctx: context.Background(), scrollCancelSeam: fake}}
248
249	h.OnLoadStart(nil, frame, 0)
250	invalidations, _, _ := fake.stats()
251	require.Equal(t, 1, invalidations, "post-commit fallback invalidates")
252}
253
254func TestDestroyInvalidatesBeforeNativeClose(t *testing.T) {
255	fake := &recordingScrollBridge{}
256	fake.startGesture()
257	var order []string
258	host := &orderBrowserHost{order: &order}
259	wv := &WebView{
260		viewBridge:       &Cef2gtkAdapter{},
261		host:             host,
262		scrollCancelSeam: &orderInvalidator{fake: fake, order: &order},
263	}
264
265	wv.Destroy()
266
267	require.Equal(t, []string{"invalidate", "close"}, order)
268	_, _, live := fake.stats()
269	require.Zero(t, live)
270}
271
272type orderInvalidator struct {
273	fake  *recordingScrollBridge
274	order *[]string
275}
276
277func (o *orderInvalidator) InvalidateScroll() uint64 {
278	*o.order = append(*o.order, "invalidate")
279	return o.fake.InvalidateScroll()
280}
281
282func (o *orderInvalidator) CancelScrollEpoch(epoch uint64) bool {
283	return o.fake.CancelScrollEpoch(epoch)
284}
285
286type orderBrowserHost struct {
287	purecef.BrowserHost
288	order *[]string
289}
290
291func (h *orderBrowserHost) CloseBrowser(_ int32) {
292	*h.order = append(*h.order, "close")
293}
294
295func TestVisibilityHiddenTransitionInvalidates(t *testing.T) {
296	fake := &recordingScrollBridge{}
297	fake.startGesture()
298	host := &viewportSyncOrderHost{}
299	wv := &WebView{scrollCancelSeam: fake}
300
301	wv.applyEffectiveVisibility(host, false)
302	invalidations, _, live := fake.stats()
303	require.Equal(t, 1, invalidations)
304	require.Zero(t, live)
305
306	wv.applyEffectiveVisibility(host, true)
307	invalidations, _, _ = fake.stats()
308	require.Equal(t, 1, invalidations, "re-showing must not invalidate again")
309}
310
311func TestGestureHistoryCommitInvalidates(t *testing.T) {
312	fake := &recordingScrollBridge{}
313	fake.startGesture()
314	browser := cefmocks.NewMockBrowser(t)
315	browser.EXPECT().GoBack().Return().Once()
316	wv := &WebView{
317		ctx:              context.Background(),
318		browser:          browser,
319		canGoBack:        true,
320		scrollCancelSeam: fake,
321	}
322
323	wv.handleNavigationSwipeAction(cef2gtk.NavigationSwipeBack)
324
325	invalidations, _, _ := fake.stats()
326	require.Equal(t, 1, invalidations, "gesture history commit invalidates")
327}

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