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