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}