v0.33.0 62a09f5

webview_handlers_test.go RawHistoryFolder

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}

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