1package cef
2
3import (
4 "context"
5 "errors"
6 "fmt"
7 "os/exec"
8 "strings"
9 "sync"
10 "testing"
11
12 purecef "github.com/bnema/purego-cef/cef"
13 cefmocks "github.com/bnema/purego-cef/cef/mocks"
14 "github.com/stretchr/testify/mock"
15
16 "github.com/bnema/dumber/internal/application/port"
17)
18
19func TestPageScrollWheelDeltas_UseFallbackDeltasWithCEFSign(t *testing.T) {
20 tests := []struct {
21 name string
22 req port.PageScrollRequest
23 wantDeltaX int32
24 wantDeltaY int32
25 }{
26 {"left", port.PageScrollRequest{FallbackDX: -80}, 80, 0},
27 {"right", port.PageScrollRequest{FallbackDX: 80}, -80, 0},
28 {"up", port.PageScrollRequest{FallbackDY: -80}, 0, 80},
29 {"down", port.PageScrollRequest{FallbackDY: 80}, 0, -80},
30 {"up fast", port.PageScrollRequest{FallbackDY: -320}, 0, 320},
31 {"down fast", port.PageScrollRequest{FallbackDY: 320}, 0, -320},
32 }
33
34 for _, tt := range tests {
35 t.Run(tt.name, func(t *testing.T) {
36 gotDeltaX, gotDeltaY := pageScrollWheelDeltas(tt.req)
37 if gotDeltaX != tt.wantDeltaX || gotDeltaY != tt.wantDeltaY {
38 t.Fatalf("pageScrollWheelDeltas() = (%d, %d), want (%d, %d)", gotDeltaX, gotDeltaY, tt.wantDeltaX, tt.wantDeltaY)
39 }
40 })
41 }
42}
43
44// scrollRecorderHost wraps purecef.BrowserHost and records mouse wheel events,
45// allowing tests to verify that Vim Mode uses Chromium's native scroll path.
46type scrollRecorderHost struct {
47 purecef.BrowserHost // embedded nil — only SendMouseWheelEvent is called in these tests
48
49 mu sync.Mutex
50 events []recordedWheelEvent
51}
52
53type recordedWheelEvent struct {
54 event purecef.MouseEvent
55 deltaX, deltaY int32
56}
57
58func (h *scrollRecorderHost) SendMouseWheelEvent(event *purecef.MouseEvent, deltaX, deltaY int32) {
59 h.mu.Lock()
60 defer h.mu.Unlock()
61 // Record a copy so the test can inspect it after the call returns.
62 h.events = append(h.events, recordedWheelEvent{event: *event, deltaX: deltaX, deltaY: deltaY})
63}
64
65func (h *scrollRecorderHost) recordedEvents() []recordedWheelEvent {
66 h.mu.Lock()
67 defer h.mu.Unlock()
68 out := make([]recordedWheelEvent, len(h.events))
69 copy(out, h.events)
70 return out
71}
72
73func (h *scrollRecorderHost) resetRecords() {
74 h.mu.Lock()
75 defer h.mu.Unlock()
76 h.events = nil
77}
78
79func TestScrollPage_NativeWheelPath_SendsPrecisionWheelEvent(t *testing.T) {
80 host := &scrollRecorderHost{}
81 wv := &WebView{host: host}
82
83 req := port.PageScrollRequest{
84 Command: port.PageScrollCommandDown,
85 FallbackDX: 0,
86 FallbackDY: 80,
87 }
88
89 err := wv.ScrollPage(context.Background(), req)
90 if err != nil {
91 t.Fatalf("ScrollPage() unexpected error: %v", err)
92 }
93
94 events := host.recordedEvents()
95 if len(events) != 1 {
96 t.Fatalf("expected 1 wheel event, got %d", len(events))
97 }
98 if events[0].deltaX != 0 || events[0].deltaY != -80 {
99 t.Fatalf("wheel delta = (%d, %d), want (0, -80)", events[0].deltaX, events[0].deltaY)
100 }
101 if events[0].event.Modifiers&uint32(purecef.EventFlagsEventflagPrecisionScrollingDelta) == 0 {
102 t.Fatal("wheel event should set EVENTFLAG_PRECISION_SCROLLING_DELTA")
103 }
104}
105
106func TestScrollPage_NativeWheelPath_AllCommandsUseFallbackDeltas(t *testing.T) {
107 host := &scrollRecorderHost{}
108 wv := &WebView{host: host}
109
110 commands := []struct {
111 name string
112 req port.PageScrollRequest
113 wantDeltaX int32
114 wantDeltaY int32
115 }{
116 {"PageScrollLeft", port.PageScrollRequest{Command: port.PageScrollCommandLeft, FallbackDX: -80}, 80, 0},
117 {"PageScrollRight", port.PageScrollRequest{Command: port.PageScrollCommandRight, FallbackDX: 80}, -80, 0},
118 {"PageScrollUp", port.PageScrollRequest{Command: port.PageScrollCommandUp, FallbackDY: -80}, 0, 80},
119 {"PageScrollDown", port.PageScrollRequest{Command: port.PageScrollCommandDown, FallbackDY: 80}, 0, -80},
120 {"PageScrollUpFast", port.PageScrollRequest{Command: port.PageScrollCommandUpFast, FallbackDY: -320}, 0, 320},
121 {"PageScrollDownFast", port.PageScrollRequest{Command: port.PageScrollCommandDownFast, FallbackDY: 320}, 0, -320},
122 }
123
124 for _, tt := range commands {
125 t.Run(tt.name, func(t *testing.T) {
126 host.resetRecords()
127
128 if err := wv.ScrollPage(context.Background(), tt.req); err != nil {
129 t.Fatalf("ScrollPage() error: %v", err)
130 }
131
132 events := host.recordedEvents()
133 if len(events) != 1 {
134 t.Fatalf("expected 1 wheel event, got %d", len(events))
135 }
136 if events[0].deltaX != tt.wantDeltaX || events[0].deltaY != tt.wantDeltaY {
137 t.Fatalf("wheel delta = (%d, %d), want (%d, %d)", events[0].deltaX, events[0].deltaY, tt.wantDeltaX, tt.wantDeltaY)
138 }
139 })
140 }
141}
142
143func TestScrollPage_ReadyBrowser_HorizontalDirectionsUseDOMSigns(t *testing.T) {
144 tests := []struct {
145 name string
146 dx int
147 marker string
148 }{
149 {name: "h scrolls left", dx: -80, marker: "var dx=-80,dy=0"},
150 {name: "l scrolls right", dx: 80, marker: "var dx=80,dy=0"},
151 }
152
153 for _, tt := range tests {
154 t.Run(tt.name, func(t *testing.T) {
155 host := &scrollRecorderHost{}
156 browser := cefmocks.NewMockBrowser(t)
157 frame := cefmocks.NewMockFrame(t)
158 browser.EXPECT().GetMainFrame().Return(frame).Once()
159 frame.EXPECT().ExecuteJavaScript(mock.MatchedBy(func(script string) bool {
160 return strings.Contains(script, tt.marker)
161 }), "", int32(0)).Once()
162
163 wv := &WebView{host: host, browser: browser}
164 if err := wv.ScrollPage(context.Background(), port.PageScrollRequest{FallbackDX: tt.dx}); err != nil {
165 t.Fatalf("ScrollPage() error: %v", err)
166 }
167 if events := host.recordedEvents(); len(events) != 0 {
168 t.Fatalf("ready browser should use DOM target resolution, got %d wheel events", len(events))
169 }
170 })
171 }
172}
173
174func TestScrollPage_ReadyBrowser_NestedBoundaryHandsOffToDocument(t *testing.T) {
175 node, err := exec.LookPath("node")
176 if err != nil {
177 t.Skip("node is required to execute the generated vim-scroll helper")
178 }
179
180 host := &scrollRecorderHost{}
181 browser := cefmocks.NewMockBrowser(t)
182 frame := cefmocks.NewMockFrame(t)
183 var pageScrollScript string
184 browser.EXPECT().GetMainFrame().Return(frame).Once()
185 frame.EXPECT().ExecuteJavaScript(mock.MatchedBy(func(script string) bool {
186 pageScrollScript = script
187 return true
188 }), "", int32(0)).Once()
189
190 wv := &WebView{host: host, browser: browser}
191 if scrollErr := wv.ScrollPage(context.Background(), port.PageScrollRequest{FallbackDY: 80}); scrollErr != nil {
192 t.Fatalf("ScrollPage() error: %v", scrollErr)
193 }
194 if events := host.recordedEvents(); len(events) != 0 {
195 t.Fatalf("ready browser should resolve a scrollable DOM target instead of latching a wheel event, got %d wheel events", len(events))
196 }
197
198 // The centered nested scroller can consume the first tick only. The next
199 // tick must skip its exhausted boundary and resume on the document scroller.
200 harness := fmt.Sprintf(`
201const body={parentElement:null};
202const root={scrollTop:0,scrollLeft:0,scrollHeight:3000,clientHeight:500,scrollWidth:800,clientWidth:800,parentElement:null,style:{overflowY:'auto',overflowX:'auto'}};
203const inner={scrollTop:120,scrollLeft:0,scrollHeight:300,clientHeight:100,scrollWidth:200,clientWidth:200,parentElement:body,style:{overflowY:'auto',overflowX:'auto'}};
204body.parentElement=root;
205global.document={activeElement:body,body:body,documentElement:root,scrollingElement:root,elementFromPoint:()=>inner};
206global.window={innerWidth:800,innerHeight:600,getComputedStyle:(el)=>el.style||{},scrollX:0,scrollY:0,scrollBy:()=>{},scrollTo:()=>{}};
207const tick=%q;
208eval(tick);
209eval(tick);
210process.stdout.write(inner.scrollTop+','+root.scrollTop);
211`, pageScrollScript)
212 output, err := exec.Command(node, "-e", harness).CombinedOutput()
213 if err != nil {
214 t.Fatalf("execute generated vim-scroll helper: %v\n%s", err, output)
215 }
216 if got := strings.TrimSpace(string(output)); got != "200,80" {
217 t.Fatalf("nested/document scroll offsets = %s, want 200,80", got)
218 }
219}
220
221func TestScrollPage_NativeWheelPath_ZeroDeltaDoesNotSendEvent(t *testing.T) {
222 host := &scrollRecorderHost{}
223 wv := &WebView{host: host}
224
225 if err := wv.ScrollPage(context.Background(), port.PageScrollRequest{}); err != nil {
226 t.Fatalf("ScrollPage() error: %v", err)
227 }
228 if events := host.recordedEvents(); len(events) != 0 {
229 t.Fatalf("expected no wheel events for zero delta, got %d", len(events))
230 }
231}
232
233func TestScrollPage_NativePath_FallbackToJSWhenHostIsNil(t *testing.T) {
234 // With no host set, ScrollPage should still succeed via the JS fallback.
235 wv := &WebView{}
236
237 req := port.PageScrollRequest{
238 Command: port.PageScrollCommandDown,
239 FallbackDX: 0,
240 FallbackDY: 80,
241 }
242
243 // Should not panic and should succeed (JS fallback is fire-and-forget
244 // via RunJavaScript, which is safe with a nil browser).
245 err := wv.ScrollPage(context.Background(), req)
246 if err != nil {
247 t.Fatalf("ScrollPage() with nil host should not return error: %v", err)
248 }
249}
250
251func TestScrollPage_CoalescesHeldLoadIntoOneBoundedCEFTask(t *testing.T) {
252 oldNewTask, oldPostTask := cefNewTask, cefPostTask
253 defer func() { cefNewTask, cefPostTask = oldNewTask, oldPostTask }()
254 cefNewTask = func(task purecef.Task) purecef.Task { return task }
255 var scheduled purecef.Task
256 posts := 0
257 cefPostTask = func(threadID purecef.ThreadID, task purecef.Task) int32 {
258 if threadID != purecef.ThreadIDTidUi {
259 t.Fatalf("thread=%v, want CEF UI", threadID)
260 }
261 posts++
262 scheduled = task
263 return 1
264 }
265
266 browser := cefmocks.NewMockBrowser(t)
267 frame := cefmocks.NewMockFrame(t)
268 browser.EXPECT().GetMainFrame().Return(frame).Once()
269 frame.EXPECT().ExecuteJavaScript(mock.MatchedBy(func(script string) bool {
270 return strings.Contains(script, "var dx=0,dy=3200")
271 }), "", int32(0)).Once()
272 wv := &WebView{engine: &Engine{}, browser: browser}
273
274 for range 10_000 {
275 if err := wv.ScrollPage(context.Background(), port.PageScrollRequest{FallbackDY: 80, Continuous: true}); err != nil {
276 t.Fatal(err)
277 }
278 }
279 if posts != 1 {
280 t.Fatalf("CEF flush posts=%d, want exactly 1 under load", posts)
281 }
282 if scheduled == nil {
283 t.Fatal("missing scheduled flush")
284 }
285 scheduled.Execute()
286 if posts != 1 {
287 t.Fatalf("unexpected replay after drain: posts=%d", posts)
288 }
289}
290
291func TestCancelPageScroll_DropsHeldDeltaButPreservesTap(t *testing.T) {
292 oldNewTask, oldPostTask := cefNewTask, cefPostTask
293 defer func() { cefNewTask, cefPostTask = oldNewTask, oldPostTask }()
294 cefNewTask = func(task purecef.Task) purecef.Task { return task }
295 var scheduled purecef.Task
296 posts := 0
297 cefPostTask = func(_ purecef.ThreadID, task purecef.Task) int32 {
298 posts++
299 scheduled = task
300 return 1
301 }
302
303 browser := cefmocks.NewMockBrowser(t)
304 frame := cefmocks.NewMockFrame(t)
305 browser.EXPECT().GetMainFrame().Return(frame).Once()
306 frame.EXPECT().ExecuteJavaScript(mock.MatchedBy(func(script string) bool {
307 return strings.Contains(script, "var dx=0,dy=80") && !strings.Contains(script, "requestAnimationFrame")
308 }), "", int32(0)).Once()
309 wv := &WebView{engine: &Engine{}, browser: browser}
310
311 if err := wv.ScrollPage(context.Background(), port.PageScrollRequest{FallbackDY: 80}); err != nil {
312 t.Fatal(err)
313 }
314 for range 50 {
315 if err := wv.ScrollPage(context.Background(), port.PageScrollRequest{FallbackDY: 80, Continuous: true}); err != nil {
316 t.Fatal(err)
317 }
318 }
319 wv.CancelPageScroll(context.Background())
320 if posts != 1 {
321 t.Fatalf("posts before release=%d, want 1", posts)
322 }
323 scheduled.Execute()
324 if posts != 1 {
325 t.Fatalf("release must not schedule a trailing replay: posts=%d", posts)
326 }
327}
328
329func TestDrainPageScrollSynchronously_DrainsTapEnqueuedDuringExecution(t *testing.T) {
330 browser := cefmocks.NewMockBrowser(t)
331 frame := cefmocks.NewMockFrame(t)
332 browser.EXPECT().GetMainFrame().Return(frame).Twice()
333
334 wv := &WebView{browser: browser}
335 calls := 0
336 frame.EXPECT().ExecuteJavaScript(mock.Anything, "", int32(0)).Run(func(_, _ string, _ int32) {
337 calls++
338 if calls == 1 {
339 if err := wv.ScrollPage(context.Background(), port.PageScrollRequest{FallbackDY: 80}); err != nil {
340 t.Errorf("enqueue tap during execution: %v", err)
341 }
342 }
343 }).Twice()
344
345 if err := wv.ScrollPage(context.Background(), port.PageScrollRequest{FallbackDY: 80}); err != nil {
346 t.Fatal(err)
347 }
348 if calls != 2 {
349 t.Fatalf("ExecuteJavaScript calls=%d, want 2", calls)
350 }
351
352 q := &wv.pageScrollQueue
353 q.mu.Lock()
354 defer q.mu.Unlock()
355 if q.flushPending || q.tapDX != 0 || q.tapDY != 0 || q.heldDX != 0 || q.heldDY != 0 {
356 t.Fatalf("queue not drained: pending=%t tap=(%d,%d) held=(%d,%d)", q.flushPending, q.tapDX, q.tapDY, q.heldDX, q.heldDY)
357 }
358}
359
360func TestCancelPageScroll_AfterDrainDropsOldHeldAndPreservesTapAndNextGesture(t *testing.T) {
361 oldNewTask, oldPostTask := cefNewTask, cefPostTask
362 defer func() { cefNewTask, cefPostTask = oldNewTask, oldPostTask }()
363 cefNewTask = func(task purecef.Task) purecef.Task { return task }
364 var scheduled []purecef.Task
365 cefPostTask = func(_ purecef.ThreadID, task purecef.Task) int32 {
366 scheduled = append(scheduled, task)
367 return 1
368 }
369
370 drained := make(chan struct{})
371 resume := make(chan struct{})
372 var pauseOnce sync.Once
373 beforeExecute := func() {
374 pauseOnce.Do(func() {
375 close(drained)
376 <-resume
377 })
378 }
379
380 browser := cefmocks.NewMockBrowser(t)
381 frame := cefmocks.NewMockFrame(t)
382 browser.EXPECT().GetMainFrame().Return(frame).Twice()
383 var scripts []string
384 frame.EXPECT().ExecuteJavaScript(mock.Anything, "", int32(0)).Run(func(script, _ string, _ int32) {
385 scripts = append(scripts, script)
386 }).Twice()
387 wv := &WebView{engine: &Engine{}, browser: browser}
388 wv.pageScrollQueue.beforeExecute = beforeExecute
389
390 if err := wv.ScrollPage(context.Background(), port.PageScrollRequest{FallbackDY: 80}); err != nil {
391 t.Fatal(err)
392 }
393 if err := wv.ScrollPage(context.Background(), port.PageScrollRequest{FallbackDY: 80, Continuous: true}); err != nil {
394 t.Fatal(err)
395 }
396 if len(scheduled) != 1 {
397 t.Fatalf("initial pending tasks=%d, want 1", len(scheduled))
398 }
399
400 flushDone := make(chan struct{})
401 go func() {
402 defer close(flushDone)
403 scheduled[0].Execute()
404 }()
405 <-drained
406
407 // Cancel returns while the old batch is drained but has not committed to
408 // ExecuteJavaScript. Its held component must therefore be invalidated.
409 wv.CancelPageScroll(context.Background())
410 if err := wv.ScrollPage(context.Background(), port.PageScrollRequest{FallbackDX: 80}); err != nil {
411 t.Fatal(err)
412 }
413 if err := wv.ScrollPage(context.Background(), port.PageScrollRequest{FallbackDX: 80, Continuous: true}); err != nil {
414 t.Fatal(err)
415 }
416 if len(scheduled) != 1 {
417 t.Fatalf("tasks while old flush is paused=%d, want 1", len(scheduled))
418 }
419
420 close(resume)
421 <-flushDone
422 if len(scripts) != 1 || !strings.Contains(scripts[0], "var dx=0,dy=80") {
423 t.Fatalf("old generation scripts=%v, want the preserved 80px tap without held delta", scripts)
424 }
425 if len(scheduled) != 2 {
426 t.Fatalf("tasks after old flush=%d, want one successor for the new gesture", len(scheduled))
427 }
428 scheduled[1].Execute()
429 if len(scripts) != 2 || !strings.Contains(scripts[1], "var dx=160,dy=0") {
430 t.Fatalf("scripts after new gesture=%v, want a fresh 160px horizontal batch", scripts)
431 }
432 if len(scheduled) != 2 {
433 t.Fatalf("trailing tasks=%d, want 2 total", len(scheduled))
434 }
435}
436
437func TestScrollPage_AllowsAtMostOneSuccessorAfterInFlightFlush(t *testing.T) {
438 oldNewTask, oldPostTask := cefNewTask, cefPostTask
439 defer func() { cefNewTask, cefPostTask = oldNewTask, oldPostTask }()
440 cefNewTask = func(task purecef.Task) purecef.Task { return task }
441 var scheduled []purecef.Task
442 cefPostTask = func(_ purecef.ThreadID, task purecef.Task) int32 {
443 scheduled = append(scheduled, task)
444 return 1
445 }
446
447 browser := cefmocks.NewMockBrowser(t)
448 frame := cefmocks.NewMockFrame(t)
449 browser.EXPECT().GetMainFrame().Return(frame).Twice()
450 var wv *WebView
451 frame.EXPECT().ExecuteJavaScript(mock.Anything, "", int32(0)).Run(func(string, string, int32) {
452 for range 100 {
453 if err := wv.ScrollPage(context.Background(), port.PageScrollRequest{FallbackDY: 80, Continuous: true}); err != nil {
454 t.Fatal(err)
455 }
456 }
457 if len(scheduled) != 1 {
458 t.Fatalf("tasks while flush in flight=%d, want 1", len(scheduled))
459 }
460 }).Once()
461 frame.EXPECT().ExecuteJavaScript(mock.Anything, "", int32(0)).Once()
462 wv = &WebView{engine: &Engine{}, browser: browser}
463 if err := wv.ScrollPage(context.Background(), port.PageScrollRequest{FallbackDY: 80, Continuous: true}); err != nil {
464 t.Fatal(err)
465 }
466 if len(scheduled) != 1 {
467 t.Fatalf("initial pending tasks=%d, want 1", len(scheduled))
468 }
469
470 scheduled[0].Execute()
471 if len(scheduled) != 2 {
472 t.Fatalf("successor tasks=%d, want one successor", len(scheduled))
473 }
474 scheduled[1].Execute()
475 if len(scheduled) != 2 {
476 t.Fatalf("prolonged replay tasks=%d, want 2 total", len(scheduled))
477 }
478}
479
480func TestEnsurePageScrollFlush_PostTaskFailureDrainsTapAndDropsHeld(t *testing.T) {
481 oldNewTask, oldPostTask := cefNewTask, cefPostTask
482 defer func() { cefNewTask, cefPostTask = oldNewTask, oldPostTask }()
483 cefNewTask = func(task purecef.Task) purecef.Task { return task }
484 cefPostTask = func(_ purecef.ThreadID, _ purecef.Task) int32 { return 0 }
485
486 browser := cefmocks.NewMockBrowser(t)
487 frame := cefmocks.NewMockFrame(t)
488 browser.EXPECT().GetMainFrame().Return(frame).Once()
489 frame.EXPECT().ExecuteJavaScript(mock.MatchedBy(func(script string) bool {
490 return strings.Contains(script, "var dx=0,dy=80") && !strings.Contains(script, "dy=160")
491 }), "", int32(0)).Once()
492 wv := &WebView{engine: &Engine{}, browser: browser}
493
494 if err := wv.ScrollPage(context.Background(), port.PageScrollRequest{FallbackDY: 80}); err != nil {
495 t.Fatal(err)
496 }
497 if err := wv.ScrollPage(context.Background(), port.PageScrollRequest{FallbackDY: 80, Continuous: true}); err != nil {
498 t.Fatal(err)
499 }
500
501 q := &wv.pageScrollQueue
502 q.mu.Lock()
503 tapDX, tapDY := q.tapDX, q.tapDY
504 heldDX, heldDY := q.heldDX, q.heldDY
505 pending := q.flushPending
506 q.mu.Unlock()
507 if tapDX != 0 || tapDY != 0 {
508 t.Fatalf("tap deltas stranded after PostTask failure: tap=(%d,%d)", tapDX, tapDY)
509 }
510 if heldDX != 0 || heldDY != 0 {
511 t.Fatalf("held deltas must keep cancel-on-schedule-failure semantics: held=(%d,%d)", heldDX, heldDY)
512 }
513 if pending {
514 t.Fatal("flushPending must clear after PostTask failure drain")
515 }
516}
517
518func TestScrollPage_Destroyed_ReturnsError(t *testing.T) {
519 wv := &WebView{}
520 wv.destroyed.Store(true)
521
522 err := wv.ScrollPage(context.Background(), port.PageScrollRequest{})
523 if !errors.Is(err, errDestroyed) {
524 t.Fatalf("ScrollPage() on destroyed webview: got %v, want %v", err, errDestroyed)
525 }
526}