v0.30.0 eb3068f

webview_handlers_test.go RawHistoryFolder

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}

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