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}