1package content
2
3import (
4 "bytes"
5 "context"
6 "errors"
7 "sync"
8 "testing"
9
10 "github.com/rs/zerolog"
11
12 "github.com/stretchr/testify/assert"
13 "github.com/stretchr/testify/mock"
14 "github.com/stretchr/testify/require"
15
16 "github.com/bnema/dumber/internal/application/dto"
17 "github.com/bnema/dumber/internal/application/port"
18 "github.com/bnema/dumber/internal/application/port/mocks"
19 "github.com/bnema/dumber/internal/domain/entity"
20 "github.com/bnema/dumber/internal/logging"
21)
22
23// ---------------------------------------------------------------------------
24// DetectPopupType
25// ---------------------------------------------------------------------------
26
27func TestTrackOAuthPopup_StoresState(t *testing.T) {
28 t.Parallel()
29
30 c := &Coordinator{
31 webViews: make(map[entity.PaneID]port.WebView),
32 popups: newPopupManager(),
33 }
34
35 popupID := port.WebViewID(1)
36 parentPaneID := entity.PaneID("parent-pane")
37
38 c.trackOAuthPopup(popupID, parentPaneID, "https://parent.example.com/login")
39
40 c.popups.mu.RLock()
41 state, ok := c.popups.popupOAuth[popupID]
42 c.popups.mu.RUnlock()
43
44 require.True(t, ok, "oauth state should be tracked after trackOAuthPopup")
45 require.NotNil(t, state)
46 assert.Equal(t, parentPaneID, state.ParentPaneID)
47 assert.Equal(t, "https://parent.example.com/login", state.ParentURIAtOpen)
48 assert.False(t, state.Seen, "Seen should be false before capturing callback URI")
49}
50
51func TestCapturePopupOAuthState_SuccessCallback(t *testing.T) {
52 t.Parallel()
53
54 c := &Coordinator{
55 webViews: make(map[entity.PaneID]port.WebView),
56 popups: newPopupManager(),
57 }
58
59 popupID := port.WebViewID(2)
60 c.trackOAuthPopup(popupID, entity.PaneID("parent"), "https://parent.example.com/login")
61 c.capturePopupOAuthState(popupID, "https://app.example.com/callback?code=abc123")
62
63 c.popups.mu.RLock()
64 state := c.popups.popupOAuth[popupID]
65 c.popups.mu.RUnlock()
66
67 require.NotNil(t, state)
68 assert.True(t, state.Seen)
69 assert.True(t, state.Success)
70 assert.False(t, state.Error)
71 assert.Equal(t, "https://app.example.com/callback?code=abc123", state.CallbackURI)
72}
73
74func TestCapturePopupOAuthState_ErrorCallback(t *testing.T) {
75 t.Parallel()
76
77 c := &Coordinator{
78 webViews: make(map[entity.PaneID]port.WebView),
79 popups: newPopupManager(),
80 }
81
82 popupID := port.WebViewID(3)
83 c.trackOAuthPopup(popupID, entity.PaneID("parent"), "https://parent.example.com/login")
84 c.capturePopupOAuthState(popupID, "https://app.example.com/callback?error=access_denied")
85
86 c.popups.mu.RLock()
87 state := c.popups.popupOAuth[popupID]
88 c.popups.mu.RUnlock()
89
90 require.NotNil(t, state)
91 assert.True(t, state.Seen)
92 assert.False(t, state.Success)
93 assert.True(t, state.Error)
94}
95
96func TestCapturePopupOAuthState_UnknownPopupIsNoop(t *testing.T) {
97 t.Parallel()
98
99 c := &Coordinator{
100 webViews: make(map[entity.PaneID]port.WebView),
101 popups: newPopupManager(),
102 }
103
104 // Should not panic when no tracking entry exists.
105 c.capturePopupOAuthState(port.WebViewID(999), "https://example.com/callback?code=x")
106
107 c.popups.mu.RLock()
108 _, ok := c.popups.popupOAuth[port.WebViewID(999)]
109 c.popups.mu.RUnlock()
110
111 assert.False(t, ok)
112}
113
114// ---------------------------------------------------------------------------
115// SetPopupConfig
116// ---------------------------------------------------------------------------
117
118func TestSetPopupConfig_SetsFields(t *testing.T) {
119 t.Parallel()
120
121 c := &Coordinator{}
122
123 factory := &stubFactory{}
124 cfg := &entity.BrowsingContextConfig{Behavior: entity.PopupBehaviorSplit}
125 genID := func() string { return "test-id" }
126
127 c.SetPopupConfig(factory, cfg, genID)
128
129 assert.Equal(t, factory, c.popups.factory)
130 assert.True(t, c.popups.hasPopupConfig)
131 assert.Equal(t, *cfg, c.popups.popupConfig)
132 cfg.Behavior = entity.PopupBehaviorWindowed
133 assert.Equal(t, entity.PopupBehaviorSplit, c.popups.popupConfig.Behavior)
134 assert.NotNil(t, c.popups.generatePaneID)
135 assert.Equal(t, "test-id", c.popups.generatePaneID())
136}
137
138func TestSetPopupConfig_NilConfigAllowed(t *testing.T) {
139 t.Parallel()
140
141 c := &Coordinator{}
142 c.SetPopupConfig(nil, nil, nil)
143
144 assert.Nil(t, c.popups.factory)
145 assert.False(t, c.popups.hasPopupConfig)
146 assert.Equal(t, entity.BrowsingContextConfig{}, c.popups.popupConfig)
147 assert.Nil(t, c.popups.generatePaneID)
148}
149
150func TestUpdatePopupConfig_CopiesLatestValue(t *testing.T) {
151 t.Parallel()
152
153 c := &Coordinator{}
154 initial := &entity.BrowsingContextConfig{OpenInNewPane: true, OAuthAutoClose: false}
155 c.SetPopupConfig(nil, initial, nil)
156
157 c.UpdatePopupConfig(entity.BrowsingContextConfig{OpenInNewPane: false, OAuthAutoClose: true})
158
159 assert.True(t, c.popups.hasPopupConfig)
160 assert.False(t, c.popups.popupConfig.OpenInNewPane)
161 assert.True(t, c.popups.popupConfig.OAuthAutoClose)
162 assert.True(t, initial.OpenInNewPane)
163 assert.False(t, initial.OAuthAutoClose)
164}
165
166type popupOpenerWebViewStub struct {
167 *popupNavigationWebViewStub
168}
169
170func (*popupOpenerWebViewStub) EnablePopupOpenerBridge(port.WebView, bool) {}
171func (*popupOpenerWebViewStub) AddOpenerMessageCallback(func()) {}
172func (*popupOpenerWebViewStub) AddOpenerNavigationCallback(func(string)) {}
173func (*popupOpenerWebViewStub) HasActivePopupOpenerBridge() bool { return true }
174
175type popupNavigationWebViewStub struct {
176 *mocks.MockWebView
177 primed []string
178 browsingContextDecision dto.HostDecision
179 hasBrowsingContextDecision bool
180}
181
182func (s *popupNavigationWebViewStub) PrimePopupNavigation(uri string) {
183 s.primed = append(s.primed, uri)
184}
185
186func (s *popupNavigationWebViewStub) SetBrowsingContextHostDecision(decision dto.HostDecision) {
187 s.browsingContextDecision = decision
188 s.hasBrowsingContextDecision = true
189}
190
191func (s *popupNavigationWebViewStub) BrowsingContextHostDecision() (dto.HostDecision, bool) {
192 return s.browsingContextDecision, s.hasBrowsingContextDecision
193}
194
195func (*popupNavigationWebViewStub) SetOnReadyToShow(func()) {}
196func (*popupNavigationWebViewStub) SetOnClose(func()) {}
197func (*popupNavigationWebViewStub) Show() {}
198
199// stubFactory satisfies port.WebViewFactory without importing the mocks package.
200type stubFactory struct{}
201
202func (s *stubFactory) Create(_ context.Context) (port.WebView, error) {
203 return nil, nil
204}
205
206func (s *stubFactory) CreateRelated(_ context.Context, _ port.WebViewID) (port.WebView, error) {
207 return nil, nil
208}
209
210// ---------------------------------------------------------------------------
211// Concurrent access to pendingPopups
212// ---------------------------------------------------------------------------
213
214func TestPendingPopups_ConcurrentAccess(t *testing.T) {
215 t.Parallel()
216
217 c := &Coordinator{
218 popups: newPopupManager(),
219 }
220
221 const workers = 20
222 var wg sync.WaitGroup
223 wg.Add(workers * 2)
224
225 // Writers
226 for i := range workers {
227 go func(id int) {
228 defer wg.Done()
229 popupID := port.WebViewID(id)
230 c.popups.mu.Lock()
231 c.popups.pendingPopups[popupID] = &PendingPopup{
232 FrameName: "_blank",
233 PopupType: PopupTypeTab,
234 }
235 c.popups.mu.Unlock()
236 }(i)
237 }
238
239 // Readers
240 for i := range workers {
241 go func(id int) {
242 defer wg.Done()
243 popupID := port.WebViewID(id)
244 c.popups.mu.RLock()
245 _ = c.popups.pendingPopups[popupID]
246 c.popups.mu.RUnlock()
247 }(i)
248 }
249
250 wg.Wait()
251
252 c.popups.mu.RLock()
253 count := len(c.popups.pendingPopups)
254 c.popups.mu.RUnlock()
255
256 assert.Equal(t, workers, count, "each writer uses a unique key, so all inserts should be present")
257}
258
259func TestPendingPopups_ConcurrentDeleteAndRead(t *testing.T) {
260 t.Parallel()
261
262 c := &Coordinator{
263 popups: newPopupManager(),
264 }
265
266 // Pre-populate
267 for i := range 10 {
268 c.popups.pendingPopups[port.WebViewID(i)] = &PendingPopup{PopupType: PopupTypePopup}
269 }
270
271 var wg sync.WaitGroup
272 wg.Add(20)
273
274 // Deleters
275 for i := range 10 {
276 go func(id int) {
277 defer wg.Done()
278 c.popups.mu.Lock()
279 delete(c.popups.pendingPopups, port.WebViewID(id))
280 c.popups.mu.Unlock()
281 }(i)
282 }
283
284 // Readers
285 for i := range 10 {
286 go func(id int) {
287 defer wg.Done()
288 c.popups.mu.RLock()
289 _ = c.popups.pendingPopups[port.WebViewID(id)]
290 c.popups.mu.RUnlock()
291 }(i)
292 }
293
294 wg.Wait()
295
296 c.popups.mu.RLock()
297 defer c.popups.mu.RUnlock()
298 assert.Empty(t, c.popups.pendingPopups, "all preloaded popups should have been deleted")
299}
300
301func TestPopupDeferredFeaturelessBlankNavigatesSourceAndCleansStagingExactlyOnce(t *testing.T) {
302 ctx := context.Background()
303 parent := mocks.NewMockWebView(t)
304 parent.EXPECT().ID().Return(port.WebViewID(101)).Once()
305 parent.EXPECT().LoadURI(mock.Anything, "https://example.com/open").Return(nil).Once()
306 popup := &deferredPopupWebViewStub{MockWebView: mocks.NewMockWebView(t), features: dto.PopupFeatures{State: dto.PopupFeaturesNone}}
307 popup.EXPECT().ID().Return(port.WebViewID(201)).Maybe()
308 popup.EXPECT().Destroy().Once()
309 factory := mocks.NewMockWebViewFactory(t)
310 factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(popup, nil).Once()
311 staging := &popupStagingHostStub{}
312 c := &Coordinator{popups: newPopupManager()}
313 c.SetPopupConfig(factory, &entity.BrowsingContextConfig{OpenInNewPane: false}, func() string { return "deferred" })
314 c.SetPopupSourceHostResolver(func(entity.PaneID) dto.SourceHostKind { return dto.SourceHostFloating })
315 c.SetOnStagePopup(func(context.Context, StagePopupInput) (PopupStagingHost, error) {
316 staging.attached = true
317 return staging, nil
318 })
319 c.SetOnOpenBrowserWindow(func(context.Context, BrowserWindowInput) (BrowserWindowResult, error) {
320 t.Fatal("featureless _blank must not open a browser window")
321 return BrowserWindowResult{}, nil
322 })
323 c.SetOnOpenNativePopup(func(context.Context, NativePopupInput) error {
324 t.Fatal("featureless _blank must not use native host")
325 return nil
326 })
327
328 got := c.handlePopupCreate(ctx, "floating", parent, port.PopupRequest{
329 Engine: dto.BrowserEngineWebKit, TargetURI: "https://example.com/open", FrameName: "_blank",
330 TargetDisposition: dto.WindowDispositionNewPopup, PopupFeatures: dto.PopupFeatures{State: dto.PopupFeaturesUnknown},
331 })
332 require.Same(t, popup, got)
333 assert.True(t, staging.attached)
334 assert.False(t, staging.detached)
335 popup.ready()
336 popup.ready()
337 assert.True(t, staging.detached)
338 assert.Equal(t, 1, staging.destroyCalls)
339}
340
341func TestPopupDeferredFeaturedDetachesBeforeNativeHost(t *testing.T) {
342 ctx := context.Background()
343 parent := mocks.NewMockWebView(t)
344 parent.EXPECT().ID().Return(port.WebViewID(101)).Once()
345 popup := &deferredPopupWebViewStub{MockWebView: mocks.NewMockWebView(t), features: dto.PopupFeatures{State: dto.PopupFeaturesSpecified, Width: 640, WidthSet: true}}
346 popup.EXPECT().ID().Return(port.WebViewID(202)).Maybe()
347 factory := mocks.NewMockWebViewFactory(t)
348 factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(popup, nil).Once()
349 staging := &popupStagingHostStub{}
350 c := &Coordinator{popups: newPopupManager()}
351 c.SetPopupConfig(factory, &entity.BrowsingContextConfig{OpenInNewPane: false}, func() string { return "deferred" })
352 c.SetPopupSourceHostResolver(func(entity.PaneID) dto.SourceHostKind { return dto.SourceHostFloating })
353 c.SetOnStagePopup(func(context.Context, StagePopupInput) (PopupStagingHost, error) {
354 staging.attached = true
355 return staging, nil
356 })
357 c.SetOnOpenNativePopup(func(_ context.Context, input NativePopupInput) error {
358 assert.True(t, staging.detached)
359 assert.Equal(t, dto.PopupFeaturesSpecified, input.Request.PopupFeatures.State)
360 assert.Same(t, popup, input.PopupWebView)
361 assert.True(t, input.AlreadyReadyToShow, "deferred popups have already fired ready-to-show")
362 return nil
363 })
364
365 require.Same(t, popup, c.handlePopupCreate(ctx, "floating", parent, port.PopupRequest{
366 Engine: dto.BrowserEngineWebKit, TargetURI: "https://example.com/open", FrameName: "_blank",
367 TargetDisposition: dto.WindowDispositionNewPopup, PopupFeatures: dto.PopupFeatures{State: dto.PopupFeaturesUnknown},
368 }))
369 popup.ready()
370 decision, ok := popup.BrowsingContextHostDecision()
371 require.True(t, ok)
372 assert.Equal(t, dto.HostDecisionCreateNativePopup, decision.Kind)
373}
374
375func TestPopupDeferredNamedReuseDestroysNewlyStagedWebViewExactlyOnce(t *testing.T) {
376 ctx := context.Background()
377 parentPaneID := entity.PaneID("floating")
378 existingPaneID := entity.PaneID("existing")
379 parent := mocks.NewMockWebView(t)
380 parent.EXPECT().ID().Return(port.WebViewID(101)).Once()
381 staged := &deferredPopupWebViewStub{MockWebView: mocks.NewMockWebView(t), features: dto.PopupFeatures{State: dto.PopupFeaturesNone}}
382 staged.EXPECT().ID().Return(port.WebViewID(205)).Maybe()
383 staged.EXPECT().Destroy().Once()
384 existing := mocks.NewMockWebView(t)
385 existing.EXPECT().ID().Return(port.WebViewID(301)).Times(3)
386 existing.EXPECT().IsDestroyed().Return(false).Twice()
387 existing.EXPECT().LoadURI(mock.Anything, "https://example.com/reused").Return(nil).Once()
388 factory := mocks.NewMockWebViewFactory(t)
389 factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(staged, nil).Once()
390 staging := &popupStagingHostStub{}
391 c := &Coordinator{webViews: make(map[entity.PaneID]port.WebView), popups: newPopupManager()}
392 c.SetPopupConfig(factory, &entity.BrowsingContextConfig{OpenInNewPane: false}, nil)
393 c.SetPopupSourceHostResolver(func(entity.PaneID) dto.SourceHostKind { return dto.SourceHostFloating })
394 c.SetPopupWindowIDResolver(func(paneID entity.PaneID) (string, bool) {
395 windowByPane := map[entity.PaneID]string{parentPaneID: "owner", existingPaneID: "host"}
396 windowID, ok := windowByPane[paneID]
397 return windowID, ok
398 })
399 c.SetOnStagePopup(func(context.Context, StagePopupInput) (PopupStagingHost, error) { return staging, nil })
400
401 require.Same(t, staged, c.handlePopupCreate(ctx, parentPaneID, parent, port.PopupRequest{
402 Engine: dto.BrowserEngineWebKit, TargetURI: "https://example.com/reused", FrameName: "shared",
403 PopupFeatures: dto.PopupFeatures{State: dto.PopupFeaturesUnknown},
404 }))
405 c.RegisterPopupWebView(existingPaneID, existing)
406 c.popups.namedContexts.Register("owner", "host", "shared", existingPaneID, port.WebViewID(301))
407 staged.ready()
408 staged.ready()
409
410 assert.True(t, staging.detached)
411 assert.Equal(t, 1, staging.destroyCalls)
412}
413
414func TestPopupDeferredNamedReuseRaceLogsHostUnavailableAndCleansStagingExactlyOnce(t *testing.T) {
415 var output bytes.Buffer
416 logger := zerolog.New(&output).Level(zerolog.WarnLevel)
417 ctx := logging.WithContext(context.Background(), logger)
418 parentPaneID := entity.PaneID("floating")
419 existingPaneID := entity.PaneID("existing")
420 parent := mocks.NewMockWebView(t)
421 parent.EXPECT().ID().Return(port.WebViewID(101)).Once()
422 staged := &deferredPopupWebViewStub{MockWebView: mocks.NewMockWebView(t), features: dto.PopupFeatures{State: dto.PopupFeaturesNone}}
423 staged.EXPECT().ID().Return(port.WebViewID(208)).Maybe()
424 staged.EXPECT().Destroy().Once()
425 existing := mocks.NewMockWebView(t)
426 destroyedChecks := 0
427 existing.EXPECT().IsDestroyed().RunAndReturn(func() bool {
428 destroyedChecks++
429 return destroyedChecks > 1
430 }).Twice()
431 existing.EXPECT().ID().Return(port.WebViewID(301)).Once()
432 factory := mocks.NewMockWebViewFactory(t)
433 factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(staged, nil).Once()
434 staging := &popupStagingHostStub{}
435 c := &Coordinator{webViews: make(map[entity.PaneID]port.WebView), popups: newPopupManager()}
436 c.SetPopupConfig(factory, &entity.BrowsingContextConfig{OpenInNewPane: false}, nil)
437 c.SetPopupSourceHostResolver(func(entity.PaneID) dto.SourceHostKind { return dto.SourceHostFloating })
438 c.SetPopupWindowIDResolver(func(paneID entity.PaneID) (string, bool) {
439 windowByPane := map[entity.PaneID]string{parentPaneID: "owner", existingPaneID: "host"}
440 windowID, ok := windowByPane[paneID]
441 return windowID, ok
442 })
443 c.SetOnStagePopup(func(context.Context, StagePopupInput) (PopupStagingHost, error) { return staging, nil })
444
445 require.Same(t, staged, c.handlePopupCreate(ctx, parentPaneID, parent, port.PopupRequest{
446 Engine: dto.BrowserEngineWebKit, TargetURI: "https://example.com/reused", FrameName: "shared",
447 PopupFeatures: dto.PopupFeatures{State: dto.PopupFeaturesUnknown},
448 }))
449 c.RegisterPopupWebView(existingPaneID, existing)
450 c.popups.namedContexts.Register("owner", "host", "shared", existingPaneID, port.WebViewID(301))
451 staged.ready()
452 staged.ready()
453
454 assert.Equal(t, 2, destroyedChecks)
455 assert.Equal(t, 1, staging.destroyCalls)
456 record := decodeBrowsingContextLog(t, output.Bytes())
457 assert.Equal(t, "host-unavailable", record["reason_code"])
458}
459
460func TestPopupDeferredNativeAbortWithoutFallbackLogsStructuredFailure(t *testing.T) {
461 var output bytes.Buffer
462 logger := zerolog.New(&output).Level(zerolog.WarnLevel)
463 ctx := logging.WithContext(context.Background(), logger)
464 parent := mocks.NewMockWebView(t)
465 parent.EXPECT().ID().Return(port.WebViewID(101)).Once()
466 popup := &deferredPopupWebViewStub{MockWebView: mocks.NewMockWebView(t), features: dto.PopupFeatures{State: dto.PopupFeaturesSpecified, Width: 640, WidthSet: true}}
467 popup.EXPECT().ID().Return(port.WebViewID(206)).Maybe()
468 popup.EXPECT().Destroy().Once()
469 factory := mocks.NewMockWebViewFactory(t)
470 factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(popup, nil).Once()
471 c := &Coordinator{popups: newPopupManager()}
472 c.SetPopupConfig(factory, &entity.BrowsingContextConfig{OpenInNewPane: false}, nil)
473 c.SetPopupSourceHostResolver(func(entity.PaneID) dto.SourceHostKind { return dto.SourceHostFloating })
474 c.SetOnStagePopup(func(context.Context, StagePopupInput) (PopupStagingHost, error) { return &popupStagingHostStub{}, nil })
475 c.SetOnOpenNativePopup(func(abortCtx context.Context, input NativePopupInput) error {
476 assert.False(t, input.AllowBrowserWindowFallback)
477 if !input.OnNativeHostAbort(abortCtx, input.PopupWebView) {
478 input.PopupWebView.Destroy()
479 }
480 return nil
481 })
482
483 require.Same(t, popup, c.handlePopupCreate(ctx, "floating", parent, port.PopupRequest{
484 Engine: dto.BrowserEngineWebKit, TargetURI: "https://example.com/visual", TargetDisposition: dto.WindowDispositionNewPopup,
485 PopupFeatures: dto.PopupFeatures{State: dto.PopupFeaturesUnknown},
486 }))
487 popup.ready()
488
489 record := decodeBrowsingContextLog(t, output.Bytes())
490 assert.Equal(t, "webkit", record["engine"])
491 assert.Equal(t, "floating", record["source_host"])
492 assert.Equal(t, "create-native-popup", record["decision"])
493 assert.Equal(t, "new-popup", record["target_disposition"])
494 assert.Equal(t, "native-arm-failed", record["reason_code"])
495}
496
497func TestPopupDeferredBrowserHostFailureCleansUpExactlyOnce(t *testing.T) {
498 parent := mocks.NewMockWebView(t)
499 parent.EXPECT().ID().Return(port.WebViewID(101)).Once()
500 popup := &deferredPopupWebViewStub{MockWebView: mocks.NewMockWebView(t), features: dto.PopupFeatures{State: dto.PopupFeaturesNone}}
501 popup.EXPECT().ID().Return(port.WebViewID(204)).Maybe()
502 popup.EXPECT().Generation().Return(uint64(1)).Maybe()
503 popup.EXPECT().SetCallbacks(mock.Anything).Once()
504 popup.EXPECT().Destroy().Once()
505 factory := mocks.NewMockWebViewFactory(t)
506 factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(popup, nil).Once()
507 staging := &popupStagingHostStub{}
508 c := &Coordinator{popups: newPopupManager()}
509 c.SetPopupConfig(factory, &entity.BrowsingContextConfig{OpenInNewPane: false}, nil)
510 c.SetPopupSourceHostResolver(func(entity.PaneID) dto.SourceHostKind { return dto.SourceHostFloating })
511 c.SetOnStagePopup(func(context.Context, StagePopupInput) (PopupStagingHost, error) { return staging, nil })
512 c.SetOnOpenBrowserWindow(func(context.Context, BrowserWindowInput) (BrowserWindowResult, error) {
513 return BrowserWindowResult{}, errors.New("host failed")
514 })
515
516 require.Same(t, popup, c.handlePopupCreate(context.Background(), "floating", parent, port.PopupRequest{
517 Engine: dto.BrowserEngineWebKit, TargetURI: "https://example.com/open", FrameName: "shared",
518 TargetDisposition: dto.WindowDispositionNewPopup, PopupFeatures: dto.PopupFeatures{State: dto.PopupFeaturesUnknown},
519 }))
520 popup.ready()
521 popup.ready()
522 assert.True(t, staging.detached)
523 assert.Equal(t, 1, staging.destroyCalls)
524}
525
526func TestPopupDeferredStagingDetachFailureCleansUpExactlyOnce(t *testing.T) {
527 parent := mocks.NewMockWebView(t)
528 parent.EXPECT().ID().Return(port.WebViewID(101)).Once()
529 popup := &deferredPopupWebViewStub{MockWebView: mocks.NewMockWebView(t), features: dto.PopupFeatures{State: dto.PopupFeaturesNone}}
530 popup.EXPECT().ID().Return(port.WebViewID(209)).Maybe()
531 popup.EXPECT().Destroy().Once()
532 factory := mocks.NewMockWebViewFactory(t)
533 factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(popup, nil).Once()
534 staging := &popupStagingHostStub{detachErr: errors.New("detach failed")}
535 c := &Coordinator{popups: newPopupManager()}
536 c.SetPopupConfig(factory, &entity.BrowsingContextConfig{OpenInNewPane: false}, nil)
537 c.SetPopupSourceHostResolver(func(entity.PaneID) dto.SourceHostKind { return dto.SourceHostFloating })
538 c.SetOnStagePopup(func(context.Context, StagePopupInput) (PopupStagingHost, error) { return staging, nil })
539
540 require.Same(t, popup, c.handlePopupCreate(context.Background(), "floating", parent, port.PopupRequest{
541 Engine: dto.BrowserEngineWebKit, TargetURI: "https://example.com/open",
542 PopupFeatures: dto.PopupFeatures{State: dto.PopupFeaturesUnknown},
543 }))
544 popup.ready()
545 popup.ready()
546
547 assert.True(t, staging.detached)
548 assert.Equal(t, 1, staging.destroyCalls)
549}
550
551func TestPopupDeferredStagingFailureDestroysCallerOwnedWebViewOnce(t *testing.T) {
552 parent := mocks.NewMockWebView(t)
553 parent.EXPECT().ID().Return(port.WebViewID(101)).Once()
554 popup := &deferredPopupWebViewStub{MockWebView: mocks.NewMockWebView(t)}
555 popup.EXPECT().Destroy().Once()
556 factory := mocks.NewMockWebViewFactory(t)
557 factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(popup, nil).Once()
558 c := &Coordinator{popups: newPopupManager()}
559 c.SetPopupConfig(factory, &entity.BrowsingContextConfig{OpenInNewPane: false}, nil)
560 c.SetPopupSourceHostResolver(func(entity.PaneID) dto.SourceHostKind { return dto.SourceHostFloating })
561 c.SetOnStagePopup(func(context.Context, StagePopupInput) (PopupStagingHost, error) {
562 return nil, errors.New("stage failed")
563 })
564
565 assert.Nil(t, c.handlePopupCreate(context.Background(), "floating", parent, port.PopupRequest{Engine: dto.BrowserEngineWebKit, TargetURI: "https://example.com/open", PopupFeatures: dto.PopupFeatures{State: dto.PopupFeaturesUnknown}}))
566}
567
568func TestPopupDeferredExplicitTeardownCleansUpWithoutLifecycleSignals(t *testing.T) {
569 for _, tc := range []struct {
570 name string
571 prepare func(*Coordinator)
572 trigger func(*Coordinator)
573 }{
574 {name: "parent release", trigger: func(c *Coordinator) { c.ReleaseWebView(context.Background(), "floating") }},
575 {name: "owner window teardown", prepare: func(c *Coordinator) {
576 c.SetPopupWindowIDResolver(func(entity.PaneID) (string, bool) { return "owner", true })
577 }, trigger: func(c *Coordinator) { c.ClearPopupNamedContextsForWindow("owner") }},
578 {name: "coordinator shutdown", trigger: func(c *Coordinator) { c.ShutdownPopups() }},
579 } {
580 t.Run(tc.name, func(t *testing.T) {
581 parent := mocks.NewMockWebView(t)
582 parent.EXPECT().ID().Return(port.WebViewID(101)).Once()
583 popup := &deferredPopupWebViewStub{MockWebView: mocks.NewMockWebView(t), features: dto.PopupFeatures{State: dto.PopupFeaturesNone}}
584 popup.EXPECT().ID().Return(port.WebViewID(207)).Maybe()
585 popup.EXPECT().Destroy().Once()
586 factory := mocks.NewMockWebViewFactory(t)
587 factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(popup, nil).Once()
588 staging := &popupStagingHostStub{}
589 c := &Coordinator{popups: newPopupManager()}
590 c.SetPopupConfig(factory, &entity.BrowsingContextConfig{OpenInNewPane: false}, nil)
591 c.SetPopupSourceHostResolver(func(entity.PaneID) dto.SourceHostKind { return dto.SourceHostFloating })
592 c.SetOnStagePopup(func(context.Context, StagePopupInput) (PopupStagingHost, error) { return staging, nil })
593 if tc.prepare != nil {
594 tc.prepare(c)
595 }
596
597 require.Same(t, popup, c.handlePopupCreate(context.Background(), "floating", parent, port.PopupRequest{
598 Engine: dto.BrowserEngineWebKit, TargetURI: "https://example.com/open",
599 PopupFeatures: dto.PopupFeatures{State: dto.PopupFeaturesUnknown},
600 }))
601 require.Len(t, c.popups.deferredPopups, 1)
602 tc.trigger(c)
603 tc.trigger(c)
604
605 assert.Empty(t, c.popups.deferredPopups)
606 assert.Empty(t, c.popups.pendingPopups)
607 assert.Equal(t, 1, staging.destroyCalls)
608 assert.Nil(t, popup.onReady)
609 assert.Nil(t, popup.onClose)
610 })
611 }
612}
613
614func TestPopupDeferredUnknownAndCloseCleanupExactlyOnce(t *testing.T) {
615 for _, tc := range []struct {
616 name string
617 close bool
618 }{{name: "unknown at ready"}, {name: "close before ready", close: true}} {
619 t.Run(tc.name, func(t *testing.T) {
620 parent := mocks.NewMockWebView(t)
621 parent.EXPECT().ID().Return(port.WebViewID(101)).Once()
622 popup := &deferredPopupWebViewStub{MockWebView: mocks.NewMockWebView(t), features: dto.PopupFeatures{State: dto.PopupFeaturesUnknown}}
623 popup.EXPECT().ID().Return(port.WebViewID(203)).Maybe()
624 popup.EXPECT().Destroy().Once()
625 factory := mocks.NewMockWebViewFactory(t)
626 factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(popup, nil).Once()
627 staging := &popupStagingHostStub{}
628 c := &Coordinator{popups: newPopupManager()}
629 c.SetPopupConfig(factory, &entity.BrowsingContextConfig{OpenInNewPane: false}, nil)
630 c.SetPopupSourceHostResolver(func(entity.PaneID) dto.SourceHostKind { return dto.SourceHostFloating })
631 c.SetOnStagePopup(func(context.Context, StagePopupInput) (PopupStagingHost, error) { return staging, nil })
632 require.Same(t, popup, c.handlePopupCreate(context.Background(), "floating", parent, port.PopupRequest{Engine: dto.BrowserEngineWebKit, TargetURI: "https://example.com/open", PopupFeatures: dto.PopupFeatures{State: dto.PopupFeaturesUnknown}}))
633 if tc.close {
634 popup.close()
635 popup.close()
636 } else {
637 popup.ready()
638 popup.ready()
639 }
640 assert.Equal(t, 1, staging.destroyCalls)
641 })
642 }
643}
644
645// deferredPopupWebViewStub exposes the optional late-feature and lifecycle ports.
646type deferredPopupWebViewStub struct {
647 *mocks.MockWebView
648 features dto.PopupFeatures
649 onReady func()
650 onClose func()
651 browsingContextDecision dto.HostDecision
652 hasBrowsingContextDecision bool
653}
654
655func (s *deferredPopupWebViewStub) ResolvePopupFeatures() dto.PopupFeatures { return s.features }
656func (s *deferredPopupWebViewStub) PrimePopupNavigation(string) {}
657func (s *deferredPopupWebViewStub) SetOnReadyToShow(fn func()) { s.onReady = fn }
658func (s *deferredPopupWebViewStub) SetOnClose(fn func()) { s.onClose = fn }
659func (s *deferredPopupWebViewStub) Show() {}
660func (s *deferredPopupWebViewStub) SetBrowsingContextHostDecision(decision dto.HostDecision) {
661 s.browsingContextDecision = decision
662 s.hasBrowsingContextDecision = true
663}
664func (s *deferredPopupWebViewStub) BrowsingContextHostDecision() (dto.HostDecision, bool) {
665 return s.browsingContextDecision, s.hasBrowsingContextDecision
666}
667func (s *deferredPopupWebViewStub) ready() {
668 if s.onReady != nil {
669 s.onReady()
670 }
671}
672func (s *deferredPopupWebViewStub) close() {
673 if s.onClose != nil {
674 s.onClose()
675 }
676}
677
678type popupStagingHostStub struct {
679 attached, detached bool
680 destroyCalls int
681 detachErr error
682}
683
684func (s *popupStagingHostStub) Detach() error { s.detached = true; return s.detachErr }
685func (s *popupStagingHostStub) Destroy() { s.destroyCalls++ }
686
687// ---------------------------------------------------------------------------
688// Popup config concurrency / aborted popup cleanup
689// ---------------------------------------------------------------------------
690
691func TestPopupConfig_ConcurrentUpdateAndRead(t *testing.T) {
692 t.Parallel()
693
694 pm := newPopupManager()
695 var wg sync.WaitGroup
696 for i := range 20 {
697 wg.Add(3)
698 go func() {
699 defer wg.Done()
700 pm.updatePopupConfig(entity.BrowsingContextConfig{OpenInNewPane: i%2 == 0})
701 }()
702 go func() {
703 defer wg.Done()
704 pm.setPopupConfig(&entity.BrowsingContextConfig{OAuthAutoClose: i%2 == 0})
705 }()
706 go func() {
707 defer wg.Done()
708 if cfg := pm.currentPopupConfig(); cfg != nil {
709 _ = cfg.OpenInNewPane
710 }
711 }()
712 }
713 wg.Wait()
714}
715
716func TestCurrentPopupConfig_ReturnsSnapshot(t *testing.T) {
717 t.Parallel()
718
719 pm := newPopupManager()
720 pm.updatePopupConfig(entity.BrowsingContextConfig{OpenInNewPane: true})
721 snapshot := pm.currentPopupConfig()
722 require.NotNil(t, snapshot)
723 snapshot.OpenInNewPane = false
724
725 require.True(t, pm.currentPopupConfig().OpenInNewPane)
726}
727
728func TestPopupClose_AbortedPendingPopupCleansPendingOAuthAndPane(t *testing.T) {
729 ctx := context.Background()
730 parent := mocks.NewMockWebView(t)
731 parent.EXPECT().ID().Return(port.WebViewID(101)).Once()
732 popup := &closableLifecyclePopupStub{popupNavigationWebViewStub: &popupNavigationWebViewStub{MockWebView: mocks.NewMockWebView(t)}}
733 popup.EXPECT().ID().Return(port.WebViewID(260)).Maybe()
734 popup.EXPECT().URI().Return("").Maybe()
735 popup.EXPECT().IsLoading().Return(false).Maybe()
736 popup.EXPECT().Generation().Return(uint64(1)).Maybe()
737 popup.EXPECT().SetCallbacks(mock.Anything).Once()
738 popup.EXPECT().Destroy().Maybe()
739 factory := mocks.NewMockWebViewFactory(t)
740 factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(popup, nil).Once()
741
742 c := &Coordinator{webViews: make(map[entity.PaneID]port.WebView), popups: newPopupManager()}
743 c.SetPopupConfig(factory, &entity.BrowsingContextConfig{OpenInNewPane: true, OAuthAutoClose: true}, func() string { return "popup-pane" })
744 var closedPanes []entity.PaneID
745 c.SetOnInsertPopup(func(context.Context, InsertPopupInput) error { return nil })
746 c.SetOnClosePane(func(_ context.Context, paneID entity.PaneID) error {
747 closedPanes = append(closedPanes, paneID)
748 return nil
749 })
750
751 created := c.handlePopupCreate(ctx, "parent-pane", parent, port.PopupRequest{
752 TargetURI: "https://example.com/popup", FrameName: "popup", IsUserGesture: true,
753 })
754 require.Same(t, popup, created)
755 require.Len(t, c.popups.pendingPopups, 1)
756 // OAuth tracking is normally armed for auth-intent popups; seed it so the
757 // cleanup contract is covered regardless of the policy's current routing.
758 c.popups.trackOAuthPopup(260, "parent-pane", "https://app.example.com/login")
759 require.Len(t, c.popups.popupOAuth, 1)
760 require.NotNil(t, c.getWebViewLocked("popup-pane"))
761 require.NotNil(t, popup.onClose)
762
763 // CEF aborts the popup before ready-to-show: the engine fires the lifecycle
764 // close callback exactly as it does for a destroyed shell.
765 popup.close()
766
767 assert.Empty(t, c.popups.pendingPopups)
768 assert.Empty(t, c.popups.popupOAuth)
769 assert.Equal(t, []entity.PaneID{"popup-pane"}, closedPanes)
770 assert.Nil(t, c.getWebViewLocked("popup-pane"))
771}
772
773// closableLifecyclePopupStub records the lifecycle close callback the content
774// layer installs so tests can simulate the engine tearing the shell down.
775type closableLifecyclePopupStub struct {
776 *popupNavigationWebViewStub
777 onClose func()
778}
779
780func (s *closableLifecyclePopupStub) SetOnClose(fn func()) { s.onClose = fn }
781func (s *closableLifecyclePopupStub) close() {
782 if s.onClose != nil {
783 s.onClose()
784 }
785}