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 "github.com/stretchr/testify/assert"
12 "github.com/stretchr/testify/mock"
13 "github.com/stretchr/testify/require"
14
15 "github.com/bnema/dumber/internal/application/dto"
16 "github.com/bnema/dumber/internal/application/port"
17 portmocks "github.com/bnema/dumber/internal/application/port/mocks"
18)
19
20type clipboardOrchestratorRecorder struct {
21 mu sync.Mutex
22 selection dto.SelectionClipboardInput
23 selectionCalls int
24}
25
26type stubDownloadPreparer struct{}
27
28func (stubDownloadPreparer) Execute(context.Context, port.DownloadPrepareInput) *port.DownloadPrepareOutput {
29 return &port.DownloadPrepareOutput{}
30}
31
32type controlledSelectionTimer struct {
33 stopped bool
34}
35
36func (t *controlledSelectionTimer) Stop() bool {
37 alreadyStopped := t.stopped
38 t.stopped = true
39 return !alreadyStopped
40}
41
42type controlledSelectionScheduler struct {
43 timers []*controlledSelectionTimer
44 callbacks []func()
45}
46
47func (s *controlledSelectionScheduler) schedule(_ time.Duration, fn func()) stoppableTimer {
48 timer := &controlledSelectionTimer{}
49 s.timers = append(s.timers, timer)
50 s.callbacks = append(s.callbacks, fn)
51 return timer
52}
53
54func (s *controlledSelectionScheduler) fire(index int) {
55 if index < 0 || index >= len(s.callbacks) {
56 return
57 }
58 if !s.timers[index].stopped {
59 s.callbacks[index]()
60 }
61}
62
63type stubFrame struct {
64 main bool
65 url string
66}
67
68func (f stubFrame) IsValid() bool { return true }
69func (f stubFrame) Undo() {}
70func (f stubFrame) Redo() {}
71func (f stubFrame) Cut() {}
72func (f stubFrame) Copy() {}
73func (f stubFrame) Paste() {}
74func (f stubFrame) PasteAndMatchStyle() {}
75func (f stubFrame) Del() {}
76func (f stubFrame) SelectAll() {}
77func (f stubFrame) ViewSource() {}
78func (f stubFrame) GetSource(purecef.StringVisitor) {}
79func (f stubFrame) GetText(purecef.StringVisitor) {}
80func (f stubFrame) LoadRequest(purecef.Request) {}
81func (f stubFrame) LoadURL(string) {}
82func (f stubFrame) ExecuteJavaScript(string, string, int32) {}
83func (f stubFrame) IsMain() bool { return f.main }
84func (f stubFrame) IsFocused() bool { return false }
85func (f stubFrame) GetName() string { return "" }
86func (f stubFrame) GetIdentifier() string { return "" }
87func (f stubFrame) GetParent() purecef.Frame { return nil }
88func (f stubFrame) GetURL() string { return f.url }
89func (f stubFrame) GetBrowser() purecef.Browser { return nil }
90func (f stubFrame) GetV8Context() purecef.V8Context { return nil }
91func (f stubFrame) VisitDom(purecef.Domvisitor) {}
92func (f stubFrame) SendProcessMessage(purecef.ProcessID, purecef.ProcessMessage) {}
93func (f stubFrame) CreateUrlrequest(purecef.Request, purecef.UrlrequestClient) purecef.Urlrequest {
94 return nil
95}
96
97type stubStoppableTimer struct{}
98
99func (stubStoppableTimer) Stop() bool { return true }
100
101type spyStoppableTimer struct{ stopped bool }
102
103func (t *spyStoppableTimer) Stop() bool {
104 t.stopped = true
105 return true
106}
107
108func TestPreparePaneHostedBrowsingContext_ClearsNativePopupFallbackState(t *testing.T) {
109 parent := &WebView{ctx: context.Background()}
110 timer := &spyStoppableTimer{}
111 wv := &WebView{
112 nativePopupCandidate: true,
113 nativePopupParent: parent,
114 nativePopupID: 77,
115 nativePopupFallbackStarted: true,
116 nativePopupFallbackTimer: timer,
117 pendingCreate: &pendingBrowserCreate{},
118 popupOpenerBridgeParent: parent,
119 popupOpenerBridgeParentURI: "https://example.com/login",
120 }
121
122 wv.PreparePaneHostedBrowsingContext()
123
124 require.False(t, wv.nativePopupCandidate)
125 require.Nil(t, wv.nativePopupParent)
126 require.Zero(t, wv.nativePopupID)
127 require.False(t, wv.nativePopupFallbackStarted)
128 require.Nil(t, wv.nativePopupFallbackTimer)
129 require.True(t, timer.stopped)
130 require.Nil(t, wv.popupOpenerBridgeParent)
131 require.Empty(t, wv.popupOpenerBridgeParentURI)
132}
133
134func TestStartNativePopupFallback_AllowsPopupShellAwaitingArm(t *testing.T) {
135 wv := &WebView{
136 nativePopupCandidate: true,
137 pendingCreate: &pendingBrowserCreate{},
138 }
139
140 require.True(t, wv.startNativePopupFallback())
141 require.False(t, wv.isNativePopupCandidate())
142 require.True(t, wv.nativePopupFallbackStarted)
143}
144
145func TestPreparePopupShellDirectBrowserCreation_EnablesSyntheticOpenerBridge(t *testing.T) {
146 parent := &WebView{ctx: context.Background()}
147 parent.updateURI("https://example.com/login")
148 wv := &WebView{
149 pendingCreate: &pendingBrowserCreate{},
150 nativePopupParent: parent,
151 }
152
153 require.True(t, wv.preparePopupShellDirectBrowserCreation())
154 require.Same(t, parent, wv.popupOpenerBridgeParent)
155 require.Equal(t, "https://example.com/login", wv.popupOpenerBridgeParentURI)
156}
157
158func TestPreparePopupShellDirectBrowserCreation_SkipsDestroyedParentOpenerBridge(t *testing.T) {
159 parent := &WebView{ctx: context.Background()}
160 parent.updateURI("https://example.com/login")
161 parent.destroyed.Store(true)
162 wv := &WebView{
163 pendingCreate: &pendingBrowserCreate{},
164 nativePopupParent: parent,
165 }
166
167 require.True(t, wv.preparePopupShellDirectBrowserCreation())
168 require.Nil(t, wv.popupOpenerBridgeParent)
169 require.Empty(t, wv.popupOpenerBridgeParentURI)
170}
171
172func TestScheduleNativePopupFallback_SkipsDestroyedWebView(t *testing.T) {
173 called := false
174 wv := &WebView{}
175 wv.destroyed.Store(true)
176 wv.nativePopupFallbackSchedule = func(time.Duration, func()) stoppableTimer {
177 called = true
178 return stubStoppableTimer{}
179 }
180
181 wv.scheduleNativePopupFallback(time.Millisecond, func() {
182 called = true
183 })
184
185 require.False(t, called)
186}
187
188func TestScheduleNativePopupFallback_DoesNotFireAfterDestroy(t *testing.T) {
189 called := false
190 var scheduled func()
191 wv := &WebView{}
192 wv.nativePopupFallbackSchedule = func(_ time.Duration, fn func()) stoppableTimer {
193 scheduled = fn
194 return stubStoppableTimer{}
195 }
196
197 wv.scheduleNativePopupFallback(time.Millisecond, func() {
198 called = true
199 })
200 require.NotNil(t, scheduled)
201
202 wv.destroyed.Store(true)
203 scheduled()
204
205 require.False(t, called)
206}
207
208func TestStartNativePopupFallback_RejectsDestroyedWebView(t *testing.T) {
209 wv := &WebView{
210 nativePopupCandidate: true,
211 pendingCreate: &pendingBrowserCreate{},
212 }
213 wv.destroyed.Store(true)
214
215 require.False(t, wv.startNativePopupFallback())
216}
217
218func TestNativePopupActivationDoesNotLetInitialBlankLoadClearPendingNavigationBeforeReplay(t *testing.T) {
219 parent := &WebView{}
220 wv := &WebView{
221 ctx: context.Background(),
222 nativePopupCandidate: true,
223 nativePopupParent: parent,
224 client: cefmocks.NewMockRawClient(t),
225 pendingCreate: &pendingBrowserCreate{},
226 }
227
228 rawClient, ok := wv.activateNativePopup(55, "https://example.com/oauth")
229 require.True(t, ok)
230 require.NotNil(t, rawClient)
231 require.Equal(t, int32(55), wv.nativePopupID)
232 require.Same(t, parent, wv.nativePopupParent)
233 require.Equal(t, "https://example.com/oauth", wv.pendingNavigationURI())
234 wv.mu.RLock()
235 require.Equal(t, "https://example.com/oauth", wv.committedURL)
236 require.Equal(t, "https://example.com/oauth", wv.uri)
237 wv.mu.RUnlock()
238
239 wv.updateURI("about:blank")
240 wv.updateLoadState(false, false, false)
241
242 require.Equal(t, "https://example.com/oauth", wv.pendingNavigationURI())
243}
244
245func TestHandleNativePopupAborted_PreservesPrimedNavigationForFallback(t *testing.T) {
246 closed := false
247 parent := &WebView{}
248 wv := &WebView{
249 nativePopupCandidate: true,
250 nativePopupParent: parent,
251 nativePopupID: 55,
252 pendingCreate: &pendingBrowserCreate{},
253 pendingURI: "https://example.com/oauth",
254 isLoading: true,
255 closeCallbacks: []func(){
256 func() { closed = true },
257 },
258 }
259
260 wv.handleNativePopupAborted()
261
262 require.False(t, closed)
263 require.Equal(t, "https://example.com/oauth", wv.pendingNavigationURI())
264 require.False(t, wv.isNativePopupCandidate())
265 require.Same(t, parent, wv.nativePopupParent)
266 require.Equal(t, int32(0), wv.nativePopupID)
267 require.True(t, wv.isLoading)
268}
269
270func TestConfigureNativePopupWindow_UsesSharedTextureWindowlessDefaults(t *testing.T) {
271 windowInfo := purecef.NewWindowInfo()
272 settings := purecef.NewBrowserSettings()
273
274 configureNativePopupWindow(&windowInfo, &settings, 72)
275
276 require.Equal(t, purecef.WindowHandle(0), windowInfo.ParentWindow)
277 require.Equal(t, int32(1), windowInfo.WindowlessRenderingEnabled)
278 require.Equal(t, int32(1), windowInfo.SharedTextureEnabled)
279 require.Equal(t, int32(72), settings.WindowlessFrameRate)
280 require.Equal(t, int32(1), settings.LocalStorage)
281 require.Equal(t, opaqueWhiteBackground, settings.BackgroundColor)
282}
283
284func TestOnLoadStartFiresCommittedAndUpdatesURI(t *testing.T) {
285 wv := &WebView{ctx: context.Background()}
286 var gotEvents []port.LoadEvent
287 wv.SetCallbacks(&port.WebViewCallbacks{
288 OnLoadChanged: func(event port.LoadEvent) {
289 gotEvents = append(gotEvents, event)
290 },
291 })
292
293 h := &handlerSet{wv: wv}
294 h.OnLoadStart(nil, stubFrame{main: true, url: "https://google.com"}, 0)
295
296 require.Len(t, gotEvents, 1)
297 require.Equal(t, port.LoadCommitted, gotEvents[0])
298 require.Equal(t, "https://google.com", wv.URI())
299}
300
301func TestOnLoadEndDoesNotDispatchBrowserLevelCompletion(t *testing.T) {
302 wv := &WebView{ctx: context.Background()}
303 var gotEvents []port.LoadEvent
304 var gotProgress []float64
305 wv.SetCallbacks(&port.WebViewCallbacks{
306 OnLoadChanged: func(event port.LoadEvent) {
307 gotEvents = append(gotEvents, event)
308 },
309 OnProgressChanged: func(progress float64) {
310 gotProgress = append(gotProgress, progress)
311 },
312 })
313
314 h := &handlerSet{wv: wv}
315 h.OnLoadEnd(nil, stubFrame{main: true, url: "https://reddit.com"}, 200)
316
317 assert.Empty(t, gotEvents)
318 assert.Empty(t, gotProgress)
319}
320
321func TestOnLoadErrorMainFrameCommitsFailedNavigation(t *testing.T) {
322 wv := &WebView{ctx: context.Background()}
323 wv.updateLoadState(true, true, false)
324 var gotEvents []port.LoadEvent
325 wv.SetCallbacks(&port.WebViewCallbacks{
326 OnLoadChanged: func(event port.LoadEvent) {
327 gotEvents = append(gotEvents, event)
328 },
329 })
330
331 h := &handlerSet{wv: wv}
332 h.OnLoadError(nil, stubFrame{main: true, url: "http://localhost:9"}, -102, "CONNECTION_REFUSED", "http://localhost:9")
333
334 require.Equal(t, []port.LoadEvent{port.LoadCommitted}, gotEvents)
335 require.Equal(t, "http://localhost:9", wv.URI())
336 require.True(t, wv.IsLoading())
337 require.True(t, wv.CanGoBack())
338}
339
340func TestOnLoadErrorFollowedByLoadingStateFinishTerminatesNavigation(t *testing.T) {
341 wv := &WebView{ctx: context.Background()}
342 wv.updateLoadState(true, false, false)
343 var gotEvents []port.LoadEvent
344 wv.SetCallbacks(&port.WebViewCallbacks{
345 OnLoadChanged: func(event port.LoadEvent) {
346 gotEvents = append(gotEvents, event)
347 },
348 })
349
350 h := &handlerSet{wv: wv}
351 h.OnLoadError(nil, stubFrame{main: true, url: "http://localhost:9"}, -102, "CONNECTION_REFUSED", "http://localhost:9")
352 h.OnLoadingStateChange(nil, 0, 0, 0)
353
354 require.Equal(t, []port.LoadEvent{port.LoadCommitted, port.LoadFinished}, gotEvents)
355 require.False(t, wv.IsLoading())
356}
357
358func TestOnLoadErrorAfterLoadStartDoesNotDispatchDuplicateEvents(t *testing.T) {
359 wv := &WebView{ctx: context.Background()}
360 var gotEvents []port.LoadEvent
361 wv.SetCallbacks(&port.WebViewCallbacks{
362 OnLoadChanged: func(event port.LoadEvent) {
363 gotEvents = append(gotEvents, event)
364 },
365 })
366
367 h := &handlerSet{wv: wv}
368 h.OnLoadStart(nil, stubFrame{main: true, url: "http://localhost:9"}, 0)
369 gotEvents = nil
370 h.OnLoadError(nil, stubFrame{main: true, url: "http://localhost:9"}, -102, "CONNECTION_REFUSED", "http://localhost:9")
371
372 require.Empty(t, gotEvents)
373}
374
375func TestOnLoadErrorAbortedNavigationDoesNotCommit(t *testing.T) {
376 wv := &WebView{ctx: context.Background()}
377 var gotEvents []port.LoadEvent
378 wv.SetCallbacks(&port.WebViewCallbacks{
379 OnLoadChanged: func(event port.LoadEvent) {
380 gotEvents = append(gotEvents, event)
381 },
382 })
383
384 h := &handlerSet{wv: wv}
385 h.OnLoadError(nil, stubFrame{main: true, url: "https://example.com/file.zip"}, cefErrAborted, "ERR_ABORTED", "https://example.com/file.zip")
386
387 require.Empty(t, gotEvents)
388 require.Empty(t, wv.URI())
389}
390
391func TestOnLoadErrorSubFrameDoesNotTerminateNavigation(t *testing.T) {
392 wv := &WebView{ctx: context.Background()}
393 var gotEvents []port.LoadEvent
394 wv.SetCallbacks(&port.WebViewCallbacks{
395 OnLoadChanged: func(event port.LoadEvent) {
396 gotEvents = append(gotEvents, event)
397 },
398 })
399
400 h := &handlerSet{wv: wv}
401 h.OnLoadError(nil, stubFrame{main: false, url: "http://localhost:9/frame"}, -102, "CONNECTION_REFUSED", "http://localhost:9/frame")
402
403 assert.Empty(t, gotEvents)
404}
405
406func TestOnTextSelectionChanged_ForwardsSelectionToClipboardOrchestrator(t *testing.T) {
407 recorder := &clipboardOrchestratorRecorder{}
408 orchestrator := portmocks.NewMockClipboardTextOrchestrator(t)
409 orchestrator.EXPECT().HandleSelectionUpdate(mock.Anything, mock.Anything).Run(func(_ context.Context, input dto.SelectionClipboardInput) {
410 recorder.mu.Lock()
411 defer recorder.mu.Unlock()
412 recorder.selection = input
413 recorder.selectionCalls++
414 }).Return(nil).Once()
415 zeroDelay := time.Duration(0)
416 wv := &WebView{
417 ctx: context.Background(),
418 id: 42,
419 selectionDebounceDelay: &zeroDelay,
420 engine: &Engine{
421 clipboardTextOrchestrator: orchestrator,
422 },
423 }
424 h := &selectionRenderHandlerForTest{wv: wv}
425
426 h.OnTextSelectionChanged(nil, "selected text", nil)
427
428 recorder.mu.Lock()
429 defer recorder.mu.Unlock()
430 require.Equal(t, 1, recorder.selectionCalls)
431 require.Equal(t, "selected text", recorder.selection.Text)
432 require.Equal(t, dto.ClipboardSourceCEF, recorder.selection.SourceEngine)
433 require.Equal(t, uint64(42), recorder.selection.ViewID)
434}
435
436func TestOnTextSelectionChanged_DebouncesAndCollapsesRapidUpdates(t *testing.T) {
437 recorder := &clipboardOrchestratorRecorder{}
438 scheduler := &controlledSelectionScheduler{}
439 orchestrator := portmocks.NewMockClipboardTextOrchestrator(t)
440 orchestrator.EXPECT().HandleSelectionUpdate(mock.Anything, mock.Anything).Run(func(_ context.Context, input dto.SelectionClipboardInput) {
441 recorder.mu.Lock()
442 defer recorder.mu.Unlock()
443 recorder.selection = input
444 recorder.selectionCalls++
445 }).Return(nil).Once()
446 wv := &WebView{
447 ctx: context.Background(),
448 id: 42,
449 selectionDebounceSchedule: scheduler.schedule,
450 engine: &Engine{
451 clipboardTextOrchestrator: orchestrator,
452 },
453 }
454 h := &selectionRenderHandlerForTest{wv: wv}
455
456 h.OnTextSelectionChanged(nil, "first selection", nil)
457 h.OnTextSelectionChanged(nil, "second selection", nil)
458
459 recorder.mu.Lock()
460 gotCalls := recorder.selectionCalls
461 recorder.mu.Unlock()
462 require.Zero(t, gotCalls)
463 require.Len(t, scheduler.callbacks, 2)
464
465 scheduler.fire(0)
466 recorder.mu.Lock()
467 gotCalls = recorder.selectionCalls
468 recorder.mu.Unlock()
469 require.Zero(t, gotCalls)
470
471 scheduler.fire(1)
472 recorder.mu.Lock()
473 defer recorder.mu.Unlock()
474 require.Equal(t, 1, recorder.selectionCalls)
475 require.Equal(t, "second selection", recorder.selection.Text)
476 require.Equal(t, "second selection", wv.selectedTextSnapshot())
477}
478
479func TestOnTextSelectionChanged_SuppressesAutoCopyWhenFocusedNodeEditableAndResumesWhenCleared(t *testing.T) {
480 recorder := &clipboardOrchestratorRecorder{}
481 scheduler := &controlledSelectionScheduler{}
482 orchestrator := portmocks.NewMockClipboardTextOrchestrator(t)
483 orchestrator.EXPECT().HandleSelectionUpdate(mock.Anything, mock.Anything).Run(func(_ context.Context, input dto.SelectionClipboardInput) {
484 recorder.mu.Lock()
485 defer recorder.mu.Unlock()
486 recorder.selection = input
487 recorder.selectionCalls++
488 }).Return(nil).Once()
489 wv := &WebView{
490 ctx: context.Background(),
491 id: 42,
492 selectionDebounceSchedule: scheduler.schedule,
493 engine: &Engine{
494 clipboardTextOrchestrator: orchestrator,
495 },
496 }
497 h := &selectionRenderHandlerForTest{wv: wv}
498 clientHandlers := &handlerSet{wv: wv}
499 frame := cefmocks.NewMockFrame(t)
500 oldFactory := newRendererBridgeProcessMessage
501 t.Cleanup(func() { newRendererBridgeProcessMessage = oldFactory })
502
503 newRendererBridgeProcessMessage = func(name string) purecef.ProcessMessage {
504 return newTestBridgeProcessMessage(name, true)
505 }
506 frame.EXPECT().SendProcessMessage(purecef.ProcessIDPidBrowser, mock.Anything).Run(func(_ purecef.ProcessID, message purecef.ProcessMessage) {
507 require.Equal(t, rendererBridgeMessageName, message.GetName())
508 args := message.GetArgumentList()
509 require.Equal(t, "editable_focus_changed", args.GetString(0))
510 require.Equal(t, "1", args.GetString(1))
511 clientHandlers.OnProcessMessageReceived(nil, nil, 0, message)
512 }).Once()
513 (&rendererBridgeProcessHandler{}).OnFocusedNodeChanged(nil, frame, stubEditableDomnode{editable: true})
514
515 h.OnTextSelectionChanged(nil, "editable selection", nil)
516 require.Equal(t, "editable selection", wv.selectedTextSnapshot())
517
518 recorder.mu.Lock()
519 gotCalls := recorder.selectionCalls
520 recorder.mu.Unlock()
521 require.Zero(t, gotCalls)
522 require.Empty(t, scheduler.callbacks)
523
524 newRendererBridgeProcessMessage = func(name string) purecef.ProcessMessage {
525 return newTestBridgeProcessMessage(name, false)
526 }
527 frame.EXPECT().SendProcessMessage(purecef.ProcessIDPidBrowser, mock.Anything).Run(func(_ purecef.ProcessID, message purecef.ProcessMessage) {
528 require.Equal(t, rendererBridgeMessageName, message.GetName())
529 args := message.GetArgumentList()
530 require.Equal(t, "editable_focus_changed", args.GetString(0))
531 require.Equal(t, "0", args.GetString(1))
532 clientHandlers.OnProcessMessageReceived(nil, nil, 0, message)
533 }).Once()
534 (&rendererBridgeProcessHandler{}).OnFocusedNodeChanged(nil, frame, stubEditableDomnode{editable: false})
535
536 h.OnTextSelectionChanged(nil, "free selection", nil)
537 require.Equal(t, "free selection", wv.selectedTextSnapshot())
538 recorder.mu.Lock()
539 gotCalls = recorder.selectionCalls
540 recorder.mu.Unlock()
541 require.Zero(t, gotCalls)
542 require.Len(t, scheduler.callbacks, 1)
543
544 scheduler.fire(0)
545 recorder.mu.Lock()
546 defer recorder.mu.Unlock()
547 require.Equal(t, 1, recorder.selectionCalls)
548 require.Equal(t, "free selection", recorder.selection.Text)
549}
550
551func TestSetEditableFocus_InvokesCallbackOnTransitions(t *testing.T) {
552 var states []bool
553 wv := &WebView{ctx: context.Background()}
554 wv.SetCallbacks(&port.WebViewCallbacks{
555 OnEditableFocusChanged: func(editable bool) {
556 states = append(states, editable)
557 },
558 })
559
560 wv.setEditableFocus(true)
561 wv.setEditableFocus(true)
562 wv.setEditableFocus(false)
563
564 require.Equal(t, []bool{true, false}, states)
565}
566
567func TestOnLoadStart_ClearsEditableFocus(t *testing.T) {
568 var states []bool
569 wv := &WebView{ctx: context.Background(), focusedEditable: true}
570 wv.SetCallbacks(&port.WebViewCallbacks{
571 OnEditableFocusChanged: func(editable bool) {
572 states = append(states, editable)
573 },
574 })
575 frame := cefmocks.NewMockFrame(t)
576 frame.EXPECT().IsMain().Return(true).Once()
577 frame.EXPECT().GetURL().Return("https://example.com").Maybe()
578
579 (&handlerSet{wv: wv}).OnLoadStart(nil, frame, 0)
580
581 require.Equal(t, []bool{false}, states)
582 require.False(t, wv.focusedEditable)
583}
584
585func TestDestroy_WithoutHostRunsCloseCallbacks(t *testing.T) {
586 called := false
587 wv := &WebView{closeCallbacks: []func(){func() { called = true }}}
588
589 wv.Destroy()
590
591 require.True(t, called)
592}
593
594func TestOnTextSelectionChanged_DoesNotEmitLateDebouncedUpdateAfterDestroy(t *testing.T) {
595 scheduler := &controlledSelectionScheduler{}
596 orchestrator := portmocks.NewMockClipboardTextOrchestrator(t)
597 wv := &WebView{
598 ctx: context.Background(),
599 id: 42,
600 selectionDebounceSchedule: scheduler.schedule,
601 engine: &Engine{
602 clipboardTextOrchestrator: orchestrator,
603 },
604 }
605 h := &selectionRenderHandlerForTest{wv: wv}
606
607 h.OnTextSelectionChanged(nil, "selected text", nil)
608 require.Len(t, scheduler.callbacks, 1)
609 wv.Destroy()
610 scheduler.fire(0)
611
612 orchestrator.AssertNotCalled(t, "HandleSelectionUpdate", mock.Anything, mock.Anything)
613 require.Equal(t, "selected text", wv.selectedTextSnapshot())
614}
615
616type stubEditableDomnode struct {
617 editable bool
618}
619
620func (n stubEditableDomnode) GetType() purecef.DomNodeType { return 0 }
621func (n stubEditableDomnode) IsText() bool { return false }
622func (n stubEditableDomnode) IsElement() bool { return true }
623func (n stubEditableDomnode) IsEditable() bool { return n.editable }
624func (n stubEditableDomnode) IsFormControlElement() bool { return false }
625func (n stubEditableDomnode) GetFormControlElementType() purecef.DomFormControlType { return 0 }
626func (n stubEditableDomnode) IsSame(that purecef.Domnode) bool {
627 other, ok := that.(stubEditableDomnode)
628 return ok && n == other
629}
630func (n stubEditableDomnode) GetName() string { return "" }
631func (n stubEditableDomnode) GetValue() string { return "" }
632func (n stubEditableDomnode) SetValue(string) int32 { return 0 }
633func (n stubEditableDomnode) GetAsMarkup() string { return "" }
634func (n stubEditableDomnode) GetDocument() purecef.Domdocument { return nil }
635func (n stubEditableDomnode) GetParent() purecef.Domnode { return nil }
636func (n stubEditableDomnode) GetPreviousSibling() purecef.Domnode { return nil }
637func (n stubEditableDomnode) GetNextSibling() purecef.Domnode { return nil }
638func (n stubEditableDomnode) HasChildren() bool { return false }
639func (n stubEditableDomnode) GetFirstChild() purecef.Domnode { return nil }
640func (n stubEditableDomnode) GetLastChild() purecef.Domnode { return nil }
641func (n stubEditableDomnode) GetElementTagName() string { return "" }
642func (n stubEditableDomnode) HasElementAttributes() bool { return false }
643func (n stubEditableDomnode) HasElementAttribute(string) bool { return false }
644func (n stubEditableDomnode) GetElementAttribute(string) string { return "" }
645func (n stubEditableDomnode) GetElementAttributes(purecef.StringMap) {}
646func (n stubEditableDomnode) SetElementAttribute(string, string) int32 { return 0 }
647func (n stubEditableDomnode) GetElementInnerText() string { return "" }
648func (n stubEditableDomnode) GetElementBounds() uintptr { return 0 }
649
650func TestOptionalHandlersAreAlwaysEnabled(t *testing.T) {
651 h := &handlerSet{}
652
653 // AudioHandler is always enabled (required for media decoding).
654 require.Same(t, h, h.GetAudioHandler())
655 require.Same(t, h, h.GetContextMenuHandler())
656 require.Nil(t, h.GetRenderHandler())
657}
658
659func TestRenderTextSelectionHookPreservesWebViewState(t *testing.T) {
660 wv := &WebView{ctx: context.Background()}
661
662 handleRenderTextSelectionChanged(wv, "selected via hook")
663
664 require.Equal(t, "selected via hook", wv.selectedTextSnapshot())
665}
666
667func TestGetDownloadHandler_EnabledWhenEngineConfigured(t *testing.T) {
668 eng := &Engine{}
669 require.NoError(t, eng.ConfigureDownloads(context.Background(), "/tmp/downloads", nil, stubDownloadPreparer{}))
670
671 h := &handlerSet{
672 wv: &WebView{
673 ctx: context.Background(),
674 engine: eng,
675 },
676 }
677
678 require.Same(t, h, h.GetDownloadHandler())
679}
680
681type selectionRenderHandlerForTest struct{ wv *WebView }
682
683func (h *selectionRenderHandlerForTest) OnTextSelectionChanged(_ purecef.Browser, selectedText string, _ *purecef.Range) {
684 handleRenderTextSelectionChanged(h.wv, selectedText)
685}
686
687func TestCEFCursorToGDKName(t *testing.T) {
688 tests := []struct {
689 name string
690 cursor purecef.CursorType
691 expected string
692 }{
693 {name: "none maps to invisible cursor", cursor: purecef.CursorTypeCtNone, expected: "none"},
694 {name: "pointer maps to default", cursor: purecef.CursorTypeCtPointer, expected: "default"},
695 {name: "hand maps to pointer", cursor: purecef.CursorTypeCtHand, expected: "pointer"},
696 {name: "ibeam maps to text", cursor: purecef.CursorTypeCtIbeam, expected: "text"},
697 {name: "unknown falls back to default", cursor: purecef.CursorType(-1), expected: "default"},
698 }
699
700 for _, tt := range tests {
701 t.Run(tt.name, func(t *testing.T) {
702 require.Equal(t, tt.expected, cefCursorToGDKName(tt.cursor))
703 })
704 }
705}