v0.31.0 bf0756b

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
235	wv.updateURI("about:blank")
236	wv.updateLoadState(false, false, false)
237
238	require.Equal(t, "https://example.com/oauth", wv.pendingNavigationURI())
239}
240
241func TestHandleNativePopupAborted_PreservesPrimedNavigationForFallback(t *testing.T) {
242	closed := false
243	parent := &WebView{}
244	wv := &WebView{
245		nativePopupCandidate: true,
246		nativePopupParent:    parent,
247		nativePopupID:        55,
248		pendingCreate:        &pendingBrowserCreate{},
249		pendingURI:           "https://example.com/oauth",
250		isLoading:            true,
251		closeCallbacks: []func(){
252			func() { closed = true },
253		},
254	}
255
256	wv.handleNativePopupAborted()
257
258	require.False(t, closed)
259	require.Equal(t, "https://example.com/oauth", wv.pendingNavigationURI())
260	require.False(t, wv.isNativePopupCandidate())
261	require.Same(t, parent, wv.nativePopupParent)
262	require.Equal(t, int32(0), wv.nativePopupID)
263	require.True(t, wv.isLoading)
264}
265
266func TestConfigureNativePopupWindow_UsesSharedTextureWindowlessDefaults(t *testing.T) {
267	windowInfo := purecef.NewWindowInfo()
268	settings := purecef.NewBrowserSettings()
269
270	configureNativePopupWindow(&windowInfo, &settings, 72)
271
272	require.Equal(t, purecef.WindowHandle(0), windowInfo.ParentWindow)
273	require.Equal(t, int32(1), windowInfo.WindowlessRenderingEnabled)
274	require.Equal(t, int32(1), windowInfo.SharedTextureEnabled)
275	require.Equal(t, int32(72), settings.WindowlessFrameRate)
276	require.Equal(t, int32(1), settings.LocalStorage)
277	require.Equal(t, opaqueWhiteBackground, settings.BackgroundColor)
278}
279
280func TestOnLoadStartFiresCommittedAndUpdatesURI(t *testing.T) {
281	wv := &WebView{ctx: context.Background()}
282	var gotEvents []port.LoadEvent
283	wv.SetCallbacks(&port.WebViewCallbacks{
284		OnLoadChanged: func(event port.LoadEvent) {
285			gotEvents = append(gotEvents, event)
286		},
287	})
288
289	h := &handlerSet{wv: wv}
290	h.OnLoadStart(nil, stubFrame{main: true, url: "https://google.com"}, 0)
291
292	require.Len(t, gotEvents, 1)
293	require.Equal(t, port.LoadCommitted, gotEvents[0])
294	require.Equal(t, "https://google.com", wv.URI())
295}
296
297func TestOnLoadEndDoesNotDispatchBrowserLevelCompletion(t *testing.T) {
298	wv := &WebView{ctx: context.Background()}
299	var gotEvents []port.LoadEvent
300	var gotProgress []float64
301	wv.SetCallbacks(&port.WebViewCallbacks{
302		OnLoadChanged: func(event port.LoadEvent) {
303			gotEvents = append(gotEvents, event)
304		},
305		OnProgressChanged: func(progress float64) {
306			gotProgress = append(gotProgress, progress)
307		},
308	})
309
310	h := &handlerSet{wv: wv}
311	h.OnLoadEnd(nil, stubFrame{main: true, url: "https://reddit.com"}, 200)
312
313	assert.Empty(t, gotEvents)
314	assert.Empty(t, gotProgress)
315}
316
317func TestOnLoadErrorMainFrameCommitsFailedNavigation(t *testing.T) {
318	wv := &WebView{ctx: context.Background()}
319	wv.updateLoadState(true, true, false)
320	var gotEvents []port.LoadEvent
321	wv.SetCallbacks(&port.WebViewCallbacks{
322		OnLoadChanged: func(event port.LoadEvent) {
323			gotEvents = append(gotEvents, event)
324		},
325	})
326
327	h := &handlerSet{wv: wv}
328	h.OnLoadError(nil, stubFrame{main: true, url: "http://localhost:9"}, -102, "CONNECTION_REFUSED", "http://localhost:9")
329
330	require.Equal(t, []port.LoadEvent{port.LoadCommitted}, gotEvents)
331	require.Equal(t, "http://localhost:9", wv.URI())
332	require.True(t, wv.IsLoading())
333	require.True(t, wv.CanGoBack())
334}
335
336func TestOnLoadErrorFollowedByLoadingStateFinishTerminatesNavigation(t *testing.T) {
337	wv := &WebView{ctx: context.Background()}
338	wv.updateLoadState(true, false, false)
339	var gotEvents []port.LoadEvent
340	wv.SetCallbacks(&port.WebViewCallbacks{
341		OnLoadChanged: func(event port.LoadEvent) {
342			gotEvents = append(gotEvents, event)
343		},
344	})
345
346	h := &handlerSet{wv: wv}
347	h.OnLoadError(nil, stubFrame{main: true, url: "http://localhost:9"}, -102, "CONNECTION_REFUSED", "http://localhost:9")
348	h.OnLoadingStateChange(nil, 0, 0, 0)
349
350	require.Equal(t, []port.LoadEvent{port.LoadCommitted, port.LoadFinished}, gotEvents)
351	require.False(t, wv.IsLoading())
352}
353
354func TestOnLoadErrorAfterLoadStartDoesNotDispatchDuplicateEvents(t *testing.T) {
355	wv := &WebView{ctx: context.Background()}
356	var gotEvents []port.LoadEvent
357	wv.SetCallbacks(&port.WebViewCallbacks{
358		OnLoadChanged: func(event port.LoadEvent) {
359			gotEvents = append(gotEvents, event)
360		},
361	})
362
363	h := &handlerSet{wv: wv}
364	h.OnLoadStart(nil, stubFrame{main: true, url: "http://localhost:9"}, 0)
365	gotEvents = nil
366	h.OnLoadError(nil, stubFrame{main: true, url: "http://localhost:9"}, -102, "CONNECTION_REFUSED", "http://localhost:9")
367
368	require.Empty(t, gotEvents)
369}
370
371func TestOnLoadErrorAbortedNavigationDoesNotCommit(t *testing.T) {
372	wv := &WebView{ctx: context.Background()}
373	var gotEvents []port.LoadEvent
374	wv.SetCallbacks(&port.WebViewCallbacks{
375		OnLoadChanged: func(event port.LoadEvent) {
376			gotEvents = append(gotEvents, event)
377		},
378	})
379
380	h := &handlerSet{wv: wv}
381	h.OnLoadError(nil, stubFrame{main: true, url: "https://example.com/file.zip"}, cefErrAborted, "ERR_ABORTED", "https://example.com/file.zip")
382
383	require.Empty(t, gotEvents)
384	require.Empty(t, wv.URI())
385}
386
387func TestOnLoadErrorSubFrameDoesNotTerminateNavigation(t *testing.T) {
388	wv := &WebView{ctx: context.Background()}
389	var gotEvents []port.LoadEvent
390	wv.SetCallbacks(&port.WebViewCallbacks{
391		OnLoadChanged: func(event port.LoadEvent) {
392			gotEvents = append(gotEvents, event)
393		},
394	})
395
396	h := &handlerSet{wv: wv}
397	h.OnLoadError(nil, stubFrame{main: false, url: "http://localhost:9/frame"}, -102, "CONNECTION_REFUSED", "http://localhost:9/frame")
398
399	assert.Empty(t, gotEvents)
400}
401
402func TestOnTextSelectionChanged_ForwardsSelectionToClipboardOrchestrator(t *testing.T) {
403	recorder := &clipboardOrchestratorRecorder{}
404	orchestrator := portmocks.NewMockClipboardTextOrchestrator(t)
405	orchestrator.EXPECT().HandleSelectionUpdate(mock.Anything, mock.Anything).Run(func(_ context.Context, input dto.SelectionClipboardInput) {
406		recorder.mu.Lock()
407		defer recorder.mu.Unlock()
408		recorder.selection = input
409		recorder.selectionCalls++
410	}).Return(nil).Once()
411	zeroDelay := time.Duration(0)
412	wv := &WebView{
413		ctx:                    context.Background(),
414		id:                     42,
415		selectionDebounceDelay: &zeroDelay,
416		engine: &Engine{
417			clipboardTextOrchestrator: orchestrator,
418		},
419	}
420	h := &selectionRenderHandlerForTest{wv: wv}
421
422	h.OnTextSelectionChanged(nil, "selected text", nil)
423
424	recorder.mu.Lock()
425	defer recorder.mu.Unlock()
426	require.Equal(t, 1, recorder.selectionCalls)
427	require.Equal(t, "selected text", recorder.selection.Text)
428	require.Equal(t, dto.ClipboardSourceCEF, recorder.selection.SourceEngine)
429	require.Equal(t, uint64(42), recorder.selection.ViewID)
430}
431
432func TestOnTextSelectionChanged_DebouncesAndCollapsesRapidUpdates(t *testing.T) {
433	recorder := &clipboardOrchestratorRecorder{}
434	scheduler := &controlledSelectionScheduler{}
435	orchestrator := portmocks.NewMockClipboardTextOrchestrator(t)
436	orchestrator.EXPECT().HandleSelectionUpdate(mock.Anything, mock.Anything).Run(func(_ context.Context, input dto.SelectionClipboardInput) {
437		recorder.mu.Lock()
438		defer recorder.mu.Unlock()
439		recorder.selection = input
440		recorder.selectionCalls++
441	}).Return(nil).Once()
442	wv := &WebView{
443		ctx:                       context.Background(),
444		id:                        42,
445		selectionDebounceSchedule: scheduler.schedule,
446		engine: &Engine{
447			clipboardTextOrchestrator: orchestrator,
448		},
449	}
450	h := &selectionRenderHandlerForTest{wv: wv}
451
452	h.OnTextSelectionChanged(nil, "first selection", nil)
453	h.OnTextSelectionChanged(nil, "second selection", nil)
454
455	recorder.mu.Lock()
456	gotCalls := recorder.selectionCalls
457	recorder.mu.Unlock()
458	require.Zero(t, gotCalls)
459	require.Len(t, scheduler.callbacks, 2)
460
461	scheduler.fire(0)
462	recorder.mu.Lock()
463	gotCalls = recorder.selectionCalls
464	recorder.mu.Unlock()
465	require.Zero(t, gotCalls)
466
467	scheduler.fire(1)
468	recorder.mu.Lock()
469	defer recorder.mu.Unlock()
470	require.Equal(t, 1, recorder.selectionCalls)
471	require.Equal(t, "second selection", recorder.selection.Text)
472	require.Equal(t, "second selection", wv.selectedTextSnapshot())
473}
474
475func TestOnTextSelectionChanged_SuppressesAutoCopyWhenFocusedNodeEditableAndResumesWhenCleared(t *testing.T) {
476	recorder := &clipboardOrchestratorRecorder{}
477	scheduler := &controlledSelectionScheduler{}
478	orchestrator := portmocks.NewMockClipboardTextOrchestrator(t)
479	orchestrator.EXPECT().HandleSelectionUpdate(mock.Anything, mock.Anything).Run(func(_ context.Context, input dto.SelectionClipboardInput) {
480		recorder.mu.Lock()
481		defer recorder.mu.Unlock()
482		recorder.selection = input
483		recorder.selectionCalls++
484	}).Return(nil).Once()
485	wv := &WebView{
486		ctx:                       context.Background(),
487		id:                        42,
488		selectionDebounceSchedule: scheduler.schedule,
489		engine: &Engine{
490			clipboardTextOrchestrator: orchestrator,
491		},
492	}
493	h := &selectionRenderHandlerForTest{wv: wv}
494	clientHandlers := &handlerSet{wv: wv}
495	frame := cefmocks.NewMockFrame(t)
496	oldFactory := newRendererBridgeProcessMessage
497	t.Cleanup(func() { newRendererBridgeProcessMessage = oldFactory })
498
499	newRendererBridgeProcessMessage = func(name string) purecef.ProcessMessage {
500		return newTestBridgeProcessMessage(name, true)
501	}
502	frame.EXPECT().SendProcessMessage(purecef.ProcessIDPidBrowser, mock.Anything).Run(func(_ purecef.ProcessID, message purecef.ProcessMessage) {
503		require.Equal(t, rendererBridgeMessageName, message.GetName())
504		args := message.GetArgumentList()
505		require.Equal(t, "editable_focus_changed", args.GetString(0))
506		require.Equal(t, "1", args.GetString(1))
507		clientHandlers.OnProcessMessageReceived(nil, nil, 0, message)
508	}).Once()
509	(&rendererBridgeProcessHandler{}).OnFocusedNodeChanged(nil, frame, stubEditableDomnode{editable: true})
510
511	h.OnTextSelectionChanged(nil, "editable selection", nil)
512	require.Equal(t, "editable selection", wv.selectedTextSnapshot())
513
514	recorder.mu.Lock()
515	gotCalls := recorder.selectionCalls
516	recorder.mu.Unlock()
517	require.Zero(t, gotCalls)
518	require.Empty(t, scheduler.callbacks)
519
520	newRendererBridgeProcessMessage = func(name string) purecef.ProcessMessage {
521		return newTestBridgeProcessMessage(name, false)
522	}
523	frame.EXPECT().SendProcessMessage(purecef.ProcessIDPidBrowser, mock.Anything).Run(func(_ purecef.ProcessID, message purecef.ProcessMessage) {
524		require.Equal(t, rendererBridgeMessageName, message.GetName())
525		args := message.GetArgumentList()
526		require.Equal(t, "editable_focus_changed", args.GetString(0))
527		require.Equal(t, "0", args.GetString(1))
528		clientHandlers.OnProcessMessageReceived(nil, nil, 0, message)
529	}).Once()
530	(&rendererBridgeProcessHandler{}).OnFocusedNodeChanged(nil, frame, stubEditableDomnode{editable: false})
531
532	h.OnTextSelectionChanged(nil, "free selection", nil)
533	require.Equal(t, "free selection", wv.selectedTextSnapshot())
534	recorder.mu.Lock()
535	gotCalls = recorder.selectionCalls
536	recorder.mu.Unlock()
537	require.Zero(t, gotCalls)
538	require.Len(t, scheduler.callbacks, 1)
539
540	scheduler.fire(0)
541	recorder.mu.Lock()
542	defer recorder.mu.Unlock()
543	require.Equal(t, 1, recorder.selectionCalls)
544	require.Equal(t, "free selection", recorder.selection.Text)
545}
546
547func TestSetEditableFocus_InvokesCallbackOnTransitions(t *testing.T) {
548	var states []bool
549	wv := &WebView{ctx: context.Background()}
550	wv.SetCallbacks(&port.WebViewCallbacks{
551		OnEditableFocusChanged: func(editable bool) {
552			states = append(states, editable)
553		},
554	})
555
556	wv.setEditableFocus(true)
557	wv.setEditableFocus(true)
558	wv.setEditableFocus(false)
559
560	require.Equal(t, []bool{true, false}, states)
561}
562
563func TestOnLoadStart_ClearsEditableFocus(t *testing.T) {
564	var states []bool
565	wv := &WebView{ctx: context.Background(), focusedEditable: true}
566	wv.SetCallbacks(&port.WebViewCallbacks{
567		OnEditableFocusChanged: func(editable bool) {
568			states = append(states, editable)
569		},
570	})
571	frame := cefmocks.NewMockFrame(t)
572	frame.EXPECT().IsMain().Return(true).Once()
573	frame.EXPECT().GetURL().Return("https://example.com").Maybe()
574
575	(&handlerSet{wv: wv}).OnLoadStart(nil, frame, 0)
576
577	require.Equal(t, []bool{false}, states)
578	require.False(t, wv.focusedEditable)
579}
580
581func TestDestroy_WithoutHostRunsCloseCallbacks(t *testing.T) {
582	called := false
583	wv := &WebView{closeCallbacks: []func(){func() { called = true }}}
584
585	wv.Destroy()
586
587	require.True(t, called)
588}
589
590func TestOnTextSelectionChanged_DoesNotEmitLateDebouncedUpdateAfterDestroy(t *testing.T) {
591	scheduler := &controlledSelectionScheduler{}
592	orchestrator := portmocks.NewMockClipboardTextOrchestrator(t)
593	wv := &WebView{
594		ctx:                       context.Background(),
595		id:                        42,
596		selectionDebounceSchedule: scheduler.schedule,
597		engine: &Engine{
598			clipboardTextOrchestrator: orchestrator,
599		},
600	}
601	h := &selectionRenderHandlerForTest{wv: wv}
602
603	h.OnTextSelectionChanged(nil, "selected text", nil)
604	require.Len(t, scheduler.callbacks, 1)
605	wv.Destroy()
606	scheduler.fire(0)
607
608	orchestrator.AssertNotCalled(t, "HandleSelectionUpdate", mock.Anything, mock.Anything)
609	require.Equal(t, "selected text", wv.selectedTextSnapshot())
610}
611
612type stubEditableDomnode struct {
613	editable bool
614}
615
616func (n stubEditableDomnode) GetType() purecef.DomNodeType                          { return 0 }
617func (n stubEditableDomnode) IsText() bool                                          { return false }
618func (n stubEditableDomnode) IsElement() bool                                       { return true }
619func (n stubEditableDomnode) IsEditable() bool                                      { return n.editable }
620func (n stubEditableDomnode) IsFormControlElement() bool                            { return false }
621func (n stubEditableDomnode) GetFormControlElementType() purecef.DomFormControlType { return 0 }
622func (n stubEditableDomnode) IsSame(that purecef.Domnode) bool {
623	other, ok := that.(stubEditableDomnode)
624	return ok && n == other
625}
626func (n stubEditableDomnode) GetName() string                          { return "" }
627func (n stubEditableDomnode) GetValue() string                         { return "" }
628func (n stubEditableDomnode) SetValue(string) int32                    { return 0 }
629func (n stubEditableDomnode) GetAsMarkup() string                      { return "" }
630func (n stubEditableDomnode) GetDocument() purecef.Domdocument         { return nil }
631func (n stubEditableDomnode) GetParent() purecef.Domnode               { return nil }
632func (n stubEditableDomnode) GetPreviousSibling() purecef.Domnode      { return nil }
633func (n stubEditableDomnode) GetNextSibling() purecef.Domnode          { return nil }
634func (n stubEditableDomnode) HasChildren() bool                        { return false }
635func (n stubEditableDomnode) GetFirstChild() purecef.Domnode           { return nil }
636func (n stubEditableDomnode) GetLastChild() purecef.Domnode            { return nil }
637func (n stubEditableDomnode) GetElementTagName() string                { return "" }
638func (n stubEditableDomnode) HasElementAttributes() bool               { return false }
639func (n stubEditableDomnode) HasElementAttribute(string) bool          { return false }
640func (n stubEditableDomnode) GetElementAttribute(string) string        { return "" }
641func (n stubEditableDomnode) GetElementAttributes(purecef.StringMap)   {}
642func (n stubEditableDomnode) SetElementAttribute(string, string) int32 { return 0 }
643func (n stubEditableDomnode) GetElementInnerText() string              { return "" }
644func (n stubEditableDomnode) GetElementBounds() uintptr                { return 0 }
645
646func TestOptionalHandlersAreAlwaysEnabled(t *testing.T) {
647	h := &handlerSet{}
648
649	// AudioHandler is always enabled (required for media decoding).
650	require.Same(t, h, h.GetAudioHandler())
651	require.Same(t, h, h.GetContextMenuHandler())
652	require.Nil(t, h.GetRenderHandler())
653}
654
655func TestRenderTextSelectionHookPreservesWebViewState(t *testing.T) {
656	wv := &WebView{ctx: context.Background()}
657
658	handleRenderTextSelectionChanged(wv, "selected via hook")
659
660	require.Equal(t, "selected via hook", wv.selectedTextSnapshot())
661}
662
663func TestGetDownloadHandler_EnabledWhenEngineConfigured(t *testing.T) {
664	eng := &Engine{}
665	require.NoError(t, eng.ConfigureDownloads(context.Background(), "/tmp/downloads", nil, stubDownloadPreparer{}))
666
667	h := &handlerSet{
668		wv: &WebView{
669			ctx:    context.Background(),
670			engine: eng,
671		},
672	}
673
674	require.Same(t, h, h.GetDownloadHandler())
675}
676
677type selectionRenderHandlerForTest struct{ wv *WebView }
678
679func (h *selectionRenderHandlerForTest) OnTextSelectionChanged(_ purecef.Browser, selectedText string, _ *purecef.Range) {
680	handleRenderTextSelectionChanged(h.wv, selectedText)
681}

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