v0.32.0 0617278

webview_navigation_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/require"
12)
13
14func TestWebViewReplayPendingNavigation_LoadsQueuedURIWhenMainFrameAvailable(t *testing.T) {
15	browser := cefmocks.NewMockBrowser(t)
16	frame := cefmocks.NewMockFrame(t)
17	frame.EXPECT().GetURL().Return("").Once()
18	frame.EXPECT().LoadURL("https://github.com/bnema").Once()
19	browser.EXPECT().GetMainFrame().Return(frame).Once()
20	browser.EXPECT().GetIdentifier().Return(int32(1)).Twice()
21
22	wv := &WebView{ctx: context.Background(), browser: browser}
23	wv.setPendingNavigationLocked("https://github.com/bnema", time.Now())
24	wv.replayPendingNavigationForIntent(0, wv.pendingIntentID)
25
26	wv.mu.RLock()
27	defer wv.mu.RUnlock()
28	require.Equal(t, "https://github.com/bnema", wv.pendingURI)
29}
30
31// TestWebViewClaimPendingNavigationSubmission_RejectsInstanceReplacement
32// reproduces a browser swap landing between the identifier check and the
33// final claim: the replacement carries the same identifier, so only the
34// instance-identity guard can catch it. The claim must report replaced
35// without marking the intent issued, leaving the retry path able to submit
36// against the current browser.
37func TestWebViewClaimPendingNavigationSubmission_RejectsInstanceReplacement(t *testing.T) {
38	browserA := cefmocks.NewMockBrowser(t)
39	browserB := cefmocks.NewMockBrowser(t)
40	entered := make(chan struct{})
41	var enterOnce sync.Once
42	proceed := make(chan struct{})
43	browserA.EXPECT().GetIdentifier().RunAndReturn(func() int32 {
44		enterOnce.Do(func() { close(entered) })
45		<-proceed
46		return int32(7)
47	}).Once()
48
49	wv := &WebView{ctx: context.Background(), browser: browserA}
50	wv.setPendingNavigationLocked("https://example.com", time.Now())
51	intentID := wv.pendingIntentID
52	require.NotZero(t, intentID)
53
54	claimed := make(chan pendingClaimResult, 1)
55	go func() {
56		_, result := wv.claimPendingNavigationSubmission(intentID, int32(7))
57		claimed <- result
58	}()
59
60	select {
61	case <-entered:
62	case <-time.After(10 * time.Second):
63		t.Fatal("claim did not reach the identifier check")
64	}
65
66	// Swap the attached browser while the claim is past the snapshot: same
67	// identifier, different instance.
68	wv.mu.Lock()
69	wv.browser = browserB
70	wv.mu.Unlock()
71	close(proceed)
72
73	select {
74	case result := <-claimed:
75		require.Equal(t, pendingClaimReplaced, result, "instance replacement must not claim the intent")
76	case <-time.After(10 * time.Second):
77		t.Fatal("claim did not return after the swap")
78	}
79
80	wv.mu.RLock()
81	defer wv.mu.RUnlock()
82	require.False(t, wv.pendingIssued, "replaced claim must leave the intent unissued for retry")
83	require.Equal(t, intentID, wv.pendingIntentID)
84}
85
86func TestWebViewReplayPendingNavigation_RetriesWhenMainFrameUnavailable(t *testing.T) {
87	browser := cefmocks.NewMockBrowser(t)
88	browser.EXPECT().GetMainFrame().Return(purecef.Frame(nil)).Once()
89
90	oldTask := cefNewTask
91	oldDelayed := cefPostDelayedTask
92	defer func() {
93		cefNewTask = oldTask
94		cefPostDelayedTask = oldDelayed
95	}()
96	cefNewTask = func(task purecef.Task) purecef.Task { return task }
97
98	scheduled := false
99	cefPostDelayedTask = func(threadID purecef.ThreadID, task purecef.Task, delayMs int64) int32 {
100		require.Equal(t, purecef.ThreadIDTidUi, threadID)
101		require.NotNil(t, task)
102		require.Equal(t, int64(pendingNavigationRetryDelay/time.Millisecond), delayMs)
103		scheduled = true
104		return 1
105	}
106
107	wv := &WebView{ctx: context.Background(), browser: browser}
108	wv.setPendingNavigationLocked("https://github.com/bnema", time.Now())
109	wv.replayPendingNavigationForIntent(0, wv.pendingIntentID)
110
111	require.True(t, scheduled)
112	wv.mu.RLock()
113	defer wv.mu.RUnlock()
114	require.Equal(t, "https://github.com/bnema", wv.pendingURI)
115}
116
117func TestWebViewSchedulePendingNavigationReplay_RetriesWhenTaskPostFails(t *testing.T) {
118	browser := cefmocks.NewMockBrowser(t)
119
120	oldTask := cefNewTask
121	oldPost := cefPostTask
122	oldDelayed := cefPostDelayedTask
123	oldAfter := cefScheduleAfter
124	defer func() {
125		cefNewTask = oldTask
126		cefPostTask = oldPost
127		cefPostDelayedTask = oldDelayed
128		cefScheduleAfter = oldAfter
129	}()
130	cefNewTask = func(task purecef.Task) purecef.Task { return task }
131
132	retried := false
133	cefPostTask = func(threadID purecef.ThreadID, task purecef.Task) int32 {
134		require.Equal(t, purecef.ThreadIDTidUi, threadID)
135		require.NotNil(t, task)
136		return 0
137	}
138	cefScheduleAfter = func(delay time.Duration, fn func()) {
139		require.Equal(t, pendingNavigationRetryDelay, delay)
140		fn()
141	}
142	cefPostDelayedTask = func(threadID purecef.ThreadID, task purecef.Task, delayMs int64) int32 {
143		require.Equal(t, purecef.ThreadIDTidUi, threadID)
144		require.NotNil(t, task)
145		require.Equal(t, int64(pendingNavigationRetryDelay/time.Millisecond), delayMs)
146		retried = true
147		return 1
148	}
149
150	wv := &WebView{ctx: context.Background(), browser: browser}
151	wv.setPendingNavigationLocked("https://github.com/bnema", time.Now())
152	wv.schedulePendingNavigationReplay(0)
153
154	require.True(t, retried)
155}
156
157func TestWebViewReplayPendingNavigation_UsesCurrentBrowserAtExecutionTime(t *testing.T) {
158	staleBrowser := cefmocks.NewMockBrowser(t)
159	activeBrowser := cefmocks.NewMockBrowser(t)
160	frame := cefmocks.NewMockFrame(t)
161	frame.EXPECT().GetURL().Return("").Once()
162	frame.EXPECT().LoadURL("https://github.com/bnema").Once()
163	activeBrowser.EXPECT().GetMainFrame().Return(frame).Once()
164	activeBrowser.EXPECT().GetIdentifier().Return(int32(2)).Twice()
165
166	oldTask := cefNewTask
167	oldPost := cefPostTask
168	defer func() {
169		cefNewTask = oldTask
170		cefPostTask = oldPost
171	}()
172	var scheduled purecef.Task
173	cefNewTask = func(task purecef.Task) purecef.Task { return task }
174	cefPostTask = func(threadID purecef.ThreadID, task purecef.Task) int32 {
175		require.Equal(t, purecef.ThreadIDTidUi, threadID)
176		scheduled = task
177		return 1
178	}
179
180	wv := &WebView{ctx: context.Background(), browser: staleBrowser}
181	wv.setPendingNavigationLocked("https://github.com/bnema", time.Now())
182	wv.schedulePendingNavigationReplay(0)
183	wv.mu.Lock()
184	wv.browser = activeBrowser
185	wv.mu.Unlock()
186
187	require.NotNil(t, scheduled)
188	scheduled.Execute()
189}
190
191func TestWebViewLoadURI_QueuesPendingNavigationReplayForExistingBrowser(t *testing.T) {
192	browser := cefmocks.NewMockBrowser(t)
193	frame := cefmocks.NewMockFrame(t)
194	browser.EXPECT().GetMainFrame().Return(frame).Once()
195	browser.EXPECT().GetIdentifier().Return(int32(1)).Twice()
196	frame.EXPECT().GetURL().Return("").Once()
197	frame.EXPECT().LoadURL("github.com/bnema").Once()
198
199	oldTask := cefNewTask
200	oldPost := cefPostTask
201	defer func() {
202		cefNewTask = oldTask
203		cefPostTask = oldPost
204	}()
205	cefNewTask = func(task purecef.Task) purecef.Task { return task }
206	cefPostTask = func(threadID purecef.ThreadID, task purecef.Task) int32 {
207		require.Equal(t, purecef.ThreadIDTidUi, threadID)
208		require.NotNil(t, task)
209		task.Execute()
210		return 1
211	}
212
213	wv := &WebView{ctx: context.Background(), browser: browser}
214	require.NoError(t, wv.LoadURI(context.Background(), "github.com/bnema"))
215
216	wv.mu.RLock()
217	defer wv.mu.RUnlock()
218	require.Equal(t, "github.com/bnema", wv.pendingURI)
219}
220
221func TestWebViewUpdateURI_ClearsMatchingPendingNavigation(t *testing.T) {
222	wv := &WebView{ctx: context.Background(), pendingURI: "https://github.com/bnema"}
223
224	wv.updateURI("https://github.com/bnema")
225
226	require.Empty(t, wv.pendingNavigationURI())
227}
228
229func TestWebViewUpdateLoadState_ClearsStalePendingNavigationAfterStartedRedirectCompletes(t *testing.T) {
230	wv := &WebView{ctx: context.Background()}
231	pending := "https://duckduckgo.com/?q=pi+agent+reddit"
232	startedAt := time.Now().Add(-time.Second)
233	wv.mu.Lock()
234	wv.setPendingNavigationLocked(pending, startedAt.Add(-time.Millisecond))
235	wv.markPendingNavigationStartedLocked(pending, startedAt)
236	wv.mu.Unlock()
237	wv.updateURI("https://www.reddit.com/r/somewhere/")
238
239	wv.updateLoadState(false, true, false)
240
241	require.Empty(t, wv.pendingNavigationURI())
242}
243
244func TestWebViewUpdateLoadState_KeepsPendingNavigationBeforeReplayStarts(t *testing.T) {
245	wv := &WebView{ctx: context.Background()}
246	pending := "https://duckduckgo.com/?q=pi+agent+reddit"
247	wv.updateURI("https://old.example/still-finishing")
248	wv.mu.Lock()
249	wv.setPendingNavigationLocked(pending, time.Now())
250	wv.mu.Unlock()
251
252	wv.updateLoadState(false, true, false)
253
254	require.Equal(t, pending, wv.pendingNavigationURI())
255}
256
257func TestWebViewUpdateLoadState_DoesNotClearPendingNavigationWhenOnlyOlderAddressWasObserved(t *testing.T) {
258	wv := &WebView{ctx: context.Background()}
259	pending := "https://duckduckgo.com/?q=pi+agent+reddit"
260	observedAt := time.Now()
261	wv.mu.Lock()
262	wv.uri = "https://old.example/final"
263	wv.loadDiagLastAddressAt = observedAt
264	wv.setPendingNavigationLocked(pending, observedAt.Add(time.Millisecond))
265	wv.markPendingNavigationStartedLocked(pending, observedAt.Add(time.Millisecond))
266	wv.mu.Unlock()
267
268	wv.updateLoadState(false, true, false)
269
270	require.Equal(t, pending, wv.pendingNavigationURI())
271}
272
273func TestWebViewUpdateLoadState_KeepsPendingNavigationWhileLoading(t *testing.T) {
274	wv := &WebView{ctx: context.Background()}
275	pending := "https://duckduckgo.com/?q=pi+agent+reddit"
276	startedAt := time.Now().Add(-time.Second)
277	wv.mu.Lock()
278	wv.setPendingNavigationLocked(pending, startedAt.Add(-time.Millisecond))
279	wv.markPendingNavigationStartedLocked(pending, startedAt)
280	wv.mu.Unlock()
281	wv.updateURI("https://www.reddit.com/r/somewhere/")
282
283	wv.updateLoadState(true, true, false)
284
285	require.Equal(t, pending, wv.pendingNavigationURI())
286}
287
288// TestWebViewReplayPendingNavigation_SubmitsOncePerIntent reproduces the
289// fresh-window race: queue an intent, run OnAfterCreated replay with the
290// frame URL still blank, then deliver blank OnLoadEnd before commit. Exactly
291// one LoadURL must be issued for the intent.
292func TestWebViewReplayPendingNavigation_SubmitsOncePerIntent(t *testing.T) {
293	browser := cefmocks.NewMockBrowser(t)
294	frame := cefmocks.NewMockFrame(t)
295	frame.EXPECT().GetURL().Return("about:blank")
296	frame.EXPECT().LoadURL("https://example.com/target").Once()
297	browser.EXPECT().GetMainFrame().Return(frame)
298	browser.EXPECT().GetIdentifier().Return(int32(1)).Twice()
299
300	oldTask := cefNewTask
301	oldPost := cefPostTask
302	defer func() {
303		cefNewTask = oldTask
304		cefPostTask = oldPost
305	}()
306	cefNewTask = func(task purecef.Task) purecef.Task { return task }
307	var scheduled []purecef.Task
308	cefPostTask = func(_ purecef.ThreadID, task purecef.Task) int32 {
309		scheduled = append(scheduled, task)
310		return 1
311	}
312
313	wv := &WebView{ctx: context.Background(), browser: browser}
314	wv.setPendingNavigationLocked("https://example.com/target", time.Now())
315	// OnAfterCreated schedules replay...
316	wv.schedulePendingNavigationReplay(0)
317	// ...and blank OnLoadEnd schedules a second replay before commit.
318	wv.schedulePendingNavigationReplay(0)
319	require.Len(t, scheduled, 2)
320	for _, task := range scheduled {
321		task.Execute()
322	}
323}
324
325// TestWebViewReplayPendingNavigation_StaleIntentDoesNotResubmit verifies a
326// rapid replacement URL invalidates the previously scheduled task.
327func TestWebViewReplayPendingNavigation_StaleIntentDoesNotResubmit(t *testing.T) {
328	browser := cefmocks.NewMockBrowser(t)
329	frame := cefmocks.NewMockFrame(t)
330	frame.EXPECT().GetURL().Return("").Once()
331	frame.EXPECT().LoadURL("https://example.com/second").Once()
332	browser.EXPECT().GetMainFrame().Return(frame).Once()
333	browser.EXPECT().GetIdentifier().Return(int32(1)).Twice()
334
335	oldTask := cefNewTask
336	oldPost := cefPostTask
337	defer func() {
338		cefNewTask = oldTask
339		cefPostTask = oldPost
340	}()
341	cefNewTask = func(task purecef.Task) purecef.Task { return task }
342	var scheduled []purecef.Task
343	cefPostTask = func(_ purecef.ThreadID, task purecef.Task) int32 {
344		scheduled = append(scheduled, task)
345		return 1
346	}
347
348	wv := &WebView{ctx: context.Background(), browser: browser}
349	wv.setPendingNavigationLocked("https://example.com/first", time.Now())
350	wv.schedulePendingNavigationReplay(0)
351	require.Len(t, scheduled, 1)
352	stale := scheduled[0]
353	// Replacement installs a new intent; the stale task must not submit.
354	wv.setPendingNavigationLocked("https://example.com/second", time.Now())
355	stale.Execute()
356	// Current intent still unissued; a fresh replay submits it once.
357	wv.replayPendingNavigationForIntent(0, wv.pendingIntentID)
358}
359
360// TestWebViewReplayPendingNavigation_RetriesSameIntentOnFailedPost ensures a
361// failed task post retries without consuming or duplicating the intent.
362func TestWebViewReplayPendingNavigation_RetriesSameIntentOnFailedPost(t *testing.T) {
363	browser := cefmocks.NewMockBrowser(t)
364
365	oldTask := cefNewTask
366	oldPost := cefPostTask
367	oldAfter := cefScheduleAfter
368	defer func() {
369		cefNewTask = oldTask
370		cefPostTask = oldPost
371		cefScheduleAfter = oldAfter
372	}()
373	cefNewTask = func(task purecef.Task) purecef.Task { return task }
374	cefPostTask = func(_ purecef.ThreadID, _ purecef.Task) int32 {
375		return 0
376	}
377	scheduledAfter := false
378	cefScheduleAfter = func(delay time.Duration, _ func()) {
379		require.Equal(t, pendingNavigationRetryDelay, delay)
380		scheduledAfter = true
381	}
382
383	wv := &WebView{ctx: context.Background(), browser: browser}
384	wv.setPendingNavigationLocked("https://example.com/target", time.Now())
385	wv.mu.RLock()
386	intent := wv.pendingIntentID
387	wv.mu.RUnlock()
388	wv.schedulePendingNavigationReplay(0)
389	require.True(t, scheduledAfter)
390	wv.mu.RLock()
391	defer wv.mu.RUnlock()
392	require.Equal(t, intent, wv.pendingIntentID)
393	require.False(t, wv.pendingIssued)
394	require.Equal(t, "https://example.com/target", wv.pendingURI)
395}
396
397// TestWebViewSetPendingNavigation_RepeatedSameURLInstallsNewIntent ensures
398// explicit reloads and repeated navigations still work: each request is a
399// new intent even for an identical URL.
400func TestWebViewSetPendingNavigation_RepeatedSameURLInstallsNewIntent(t *testing.T) {
401	wv := &WebView{ctx: context.Background()}
402	wv.setPendingNavigationLocked("https://example.com/page", time.Now())
403	first := wv.pendingIntentID
404	require.NotZero(t, first)
405	wv.mu.Lock()
406	wv.pendingIssued = true
407	wv.mu.Unlock()
408	wv.setPendingNavigationLocked("https://example.com/page", time.Now())
409	require.NotEqual(t, first, wv.pendingIntentID)
410	require.False(t, wv.pendingIssued)
411}
412
413// TestWebViewReplayPendingNavigation_SameURLReloadSubmits verifies an
414// unissued explicit intent for the currently displayed URL proceeds to
415// LoadURL instead of being cleared as already active.
416func TestWebViewReplayPendingNavigation_SameURLReloadSubmits(t *testing.T) {
417	browser := cefmocks.NewMockBrowser(t)
418	frame := cefmocks.NewMockFrame(t)
419	frame.EXPECT().GetURL().Return("https://example.com/page").Once()
420	frame.EXPECT().LoadURL("https://example.com/page").Once()
421	browser.EXPECT().GetMainFrame().Return(frame).Once()
422	browser.EXPECT().GetIdentifier().Return(int32(1)).Twice()
423
424	wv := &WebView{ctx: context.Background(), browser: browser}
425	wv.setPendingNavigationLocked("https://example.com/page", time.Now())
426	wv.replayPendingNavigationForIntent(0, wv.pendingIntentID)
427
428	wv.mu.RLock()
429	defer wv.mu.RUnlock()
430	require.True(t, wv.pendingIssued)
431}
432
433func TestWebViewReplayPendingNavigation_ClearsIssuanceWhenBrowserReplacedAfterClaim(t *testing.T) {
434	const uri = "https://example.com/replaced-after-claim"
435	browserA := cefmocks.NewMockBrowser(t)
436	browserB := cefmocks.NewMockBrowser(t)
437	frameA := cefmocks.NewMockFrame(t)
438	frameB := cefmocks.NewMockFrame(t)
439
440	browserA.EXPECT().GetIdentifier().Return(int32(7)).Twice()
441	browserA.EXPECT().GetMainFrame().Return(frameA).Once()
442	frameA.EXPECT().GetURL().Return("").Once()
443	entered := make(chan struct{})
444	var enterOnce sync.Once
445	proceed := make(chan struct{})
446	frameA.EXPECT().LoadURL(uri).RunAndReturn(func(string) {
447		enterOnce.Do(func() { close(entered) })
448		<-proceed
449	}).Once()
450
451	browserB.EXPECT().GetIdentifier().Return(int32(7)).Twice()
452	browserB.EXPECT().GetMainFrame().Return(frameB).Once()
453	frameB.EXPECT().GetURL().Return("").Once()
454	frameB.EXPECT().LoadURL(uri).Once()
455
456	oldTask := cefNewTask
457	oldDelayed := cefPostDelayedTask
458	defer func() {
459		cefNewTask = oldTask
460		cefPostDelayedTask = oldDelayed
461	}()
462	cefNewTask = func(task purecef.Task) purecef.Task { return task }
463	var scheduled purecef.Task
464	// The post-claim retry schedules with attempt+1, which takes the
465	// delayed-task path; capture it to drive the retry synchronously.
466	cefPostDelayedTask = func(_ purecef.ThreadID, task purecef.Task, _ int64) int32 {
467		scheduled = task
468		return 1
469	}
470
471	wv := &WebView{ctx: context.Background(), browser: browserA}
472	wv.setPendingNavigationLocked(uri, time.Now())
473	intentID := wv.pendingIntentID
474
475	done := make(chan struct{})
476	go func() {
477		defer close(done)
478		wv.replayPendingNavigationForIntent(0, intentID)
479	}()
480
481	select {
482	case <-entered:
483	case <-time.After(10 * time.Second):
484		t.Fatal("replay did not reach LoadURL")
485	}
486
487	// Replace the attached browser while LoadURL is in flight on the stale
488	// frame: the claim already marked the intent issued.
489	wv.mu.Lock()
490	wv.browser = browserB
491	wv.mu.Unlock()
492	close(proceed)
493
494	select {
495	case <-done:
496	case <-time.After(10 * time.Second):
497		t.Fatal("replay did not return after LoadURL")
498	}
499
500	wv.mu.RLock()
501	require.False(t, wv.pendingIssued, "post-claim replacement must reopen the issuance for retry")
502	require.Equal(t, intentID, wv.pendingIntentID, "the intent itself must survive the replacement")
503	wv.mu.RUnlock()
504	require.NotNil(t, scheduled, "a retry against the current browser must be scheduled")
505
506	// Driving the retry submits the surviving intent to the new browser.
507	scheduled.Execute()
508	wv.mu.RLock()
509	defer wv.mu.RUnlock()
510	require.True(t, wv.pendingIssued, "retry must issue the intent on the current browser")
511}

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