fix(ui): preserve opener-capable popup fallback
- commit
- c5be2543c82bb43ae02aa2cccdce76db961f4d83
- author
- brice · · 2026-07-25 07:44 UTC
- committer
- brice · · 2026-07-26 16:42 UTC
- parent
- 8b31f32
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
3 files · +121 -61
M internal/ui/coordinator/content/popup_hosting.go +66 -0
| @@ -3,6 +3,7 @@ package content | |||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | 5 | "fmt" | |
| 6 | + | "sync" | |
| 6 | 7 | ||
| 7 | 8 | "github.com/bnema/dumber/internal/application/dto" | |
| 8 | 9 | "github.com/bnema/dumber/internal/application/port" | |
| @@ -59,6 +60,71 @@ func (pm *popupManager) openBrowserWindow( | |||
| 59 | 60 | return popupWV | |
| 60 | 61 | } | |
| 61 | 62 | ||
| 63 | + | func popupSupportsOpenerBridge(wv port.WebView) bool { | |
| 64 | + | _, ok := wv.(port.PopupOpenerCapable) | |
| 65 | + | return ok | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | func (pm *popupManager) openNativePopup( | |
| 69 | + | ctx context.Context, | |
| 70 | + | hooks popupCoordinatorHooks, | |
| 71 | + | parentPaneID entity.PaneID, | |
| 72 | + | parentID port.WebViewID, | |
| 73 | + | parentURIAtOpen string, | |
| 74 | + | req port.PopupRequest, | |
| 75 | + | decision dto.HostDecision, | |
| 76 | + | ) port.WebView { | |
| 77 | + | log := logging.FromContext(ctx) | |
| 78 | + | normalized := buildPopupBrowsingContextRequest(req) | |
| 79 | + | normalized.SourceHost = pm.resolveSourceHost(parentPaneID) | |
| 80 | + | if pm.onOpenNativePopup == nil { | |
| 81 | + | logBrowsingContextFailure(*log, normalized, decision, dto.BrowsingContextFailureHostUnavailable, nil) | |
| 82 | + | return nil | |
| 83 | + | } | |
| 84 | + | popupWV, err := pm.createPopupWebView(ctx, parentID, req.TargetURI, false) | |
| 85 | + | if err != nil { | |
| 86 | + | logBrowsingContextFailure(*log, normalized, decision, dto.BrowsingContextFailureHostFailed, err) | |
| 87 | + | return nil | |
| 88 | + | } | |
| 89 | + | cfg := pm.currentPopupConfig() | |
| 90 | + | pm.setBrowsingContextDecision(popupWV, decision) | |
| 91 | + | allowFallback := !normalized.AuthIntent && !decision.RequiresNativeOpener && | |
| 92 | + | (req.NoJavaScriptAccess || popupSupportsOpenerBridge(popupWV)) | |
| 93 | + | var abortOnce sync.Once | |
| 94 | + | abortResult := false | |
| 95 | + | onAbort := func(abortCtx context.Context, abortedWV port.WebView) bool { | |
| 96 | + | abortOnce.Do(func() { | |
| 97 | + | if !allowFallback { | |
| 98 | + | logBrowsingContextFailure(*log, normalized, decision, dto.BrowsingContextFailureNativeArm, nil) | |
| 99 | + | return | |
| 100 | + | } | |
| 101 | + | abortResult = pm.openExistingPopupInBrowserWindow( | |
| 102 | + | abortCtx, hooks, parentPaneID, parentID, abortedWV, req, decision, true, | |
| 103 | + | ) | |
| 104 | + | }) | |
| 105 | + | return abortResult | |
| 106 | + | } | |
| 107 | + | if err := pm.onOpenNativePopup(ctx, NativePopupInput{ | |
| 108 | + | ParentPaneID: parentPaneID, | |
| 109 | + | ParentWebViewID: parentID, | |
| 110 | + | ParentURIAtOpen: parentURIAtOpen, | |
| 111 | + | PopupWebView: popupWV, | |
| 112 | + | TargetURI: req.TargetURI, | |
| 113 | + | Request: req, | |
| 114 | + | ObserveOAuthAutoClose: cfg != nil && cfg.OAuthAutoClose && IsOAuthURL(req.TargetURI), | |
| 115 | + | AllowBrowserWindowFallback: allowFallback, | |
| 116 | + | OnNativeHostAbort: onAbort, | |
| 117 | + | }); err != nil { | |
| 118 | + | logBrowsingContextFailure(*log, normalized, decision, dto.BrowsingContextFailureNativeArm, err) | |
| 119 | + | popupWV.Destroy() | |
| 120 | + | return nil | |
| 121 | + | } | |
| 122 | + | if lifecycle, ok := popupWV.(port.PopupLifecycleCapable); ok { | |
| 123 | + | lifecycle.PrimePopupNavigation(req.TargetURI) | |
| 124 | + | } | |
| 125 | + | return popupWV | |
| 126 | + | } | |
| 127 | + | ||
| 62 | 128 | // openExistingPopupInBrowserWindow attempts to transfer an already-created | |
| 63 | 129 | // WebView. On failure ownership remains with the caller. | |
| 64 | 130 | func (pm *popupManager) openExistingPopupInBrowserWindow( | |
| @@ -3,6 +3,7 @@ package content | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | "fmt" | 5 | "fmt" | ||
| 6 | + | "sync" | |||
| 6 | 7 | ||||
| 7 | "github.com/bnema/dumber/internal/application/dto" | 8 | "github.com/bnema/dumber/internal/application/dto" | ||
| 8 | "github.com/bnema/dumber/internal/application/port" | 9 | "github.com/bnema/dumber/internal/application/port" | ||
| @@ -59,6 +60,71 @@ func (pm *popupManager) openBrowserWindow( | |||||
| 59 | return popupWV | 60 | return popupWV | ||
| 60 | } | 61 | } | ||
| 61 | 62 | ||||
| 63 | + | func popupSupportsOpenerBridge(wv port.WebView) bool { | |||
| 64 | + | _, ok := wv.(port.PopupOpenerCapable) | |||
| 65 | + | return ok | |||
| 66 | + | } | |||
| 67 | + | ||||
| 68 | + | func (pm *popupManager) openNativePopup( | |||
| 69 | + | ctx context.Context, | |||
| 70 | + | hooks popupCoordinatorHooks, | |||
| 71 | + | parentPaneID entity.PaneID, | |||
| 72 | + | parentID port.WebViewID, | |||
| 73 | + | parentURIAtOpen string, | |||
| 74 | + | req port.PopupRequest, | |||
| 75 | + | decision dto.HostDecision, | |||
| 76 | + | ) port.WebView { | |||
| 77 | + | log := logging.FromContext(ctx) | |||
| 78 | + | normalized := buildPopupBrowsingContextRequest(req) | |||
| 79 | + | normalized.SourceHost = pm.resolveSourceHost(parentPaneID) | |||
| 80 | + | if pm.onOpenNativePopup == nil { | |||
| 81 | + | logBrowsingContextFailure(*log, normalized, decision, dto.BrowsingContextFailureHostUnavailable, nil) | |||
| 82 | + | return nil | |||
| 83 | + | } | |||
| 84 | + | popupWV, err := pm.createPopupWebView(ctx, parentID, req.TargetURI, false) | |||
| 85 | + | if err != nil { | |||
| 86 | + | logBrowsingContextFailure(*log, normalized, decision, dto.BrowsingContextFailureHostFailed, err) | |||
| 87 | + | return nil | |||
| 88 | + | } | |||
| 89 | + | cfg := pm.currentPopupConfig() | |||
| 90 | + | pm.setBrowsingContextDecision(popupWV, decision) | |||
| 91 | + | allowFallback := !normalized.AuthIntent && !decision.RequiresNativeOpener && | |||
| 92 | + | (req.NoJavaScriptAccess || popupSupportsOpenerBridge(popupWV)) | |||
| 93 | + | var abortOnce sync.Once | |||
| 94 | + | abortResult := false | |||
| 95 | + | onAbort := func(abortCtx context.Context, abortedWV port.WebView) bool { | |||
| 96 | + | abortOnce.Do(func() { | |||
| 97 | + | if !allowFallback { | |||
| 98 | + | logBrowsingContextFailure(*log, normalized, decision, dto.BrowsingContextFailureNativeArm, nil) | |||
| 99 | + | return | |||
| 100 | + | } | |||
| 101 | + | abortResult = pm.openExistingPopupInBrowserWindow( | |||
| 102 | + | abortCtx, hooks, parentPaneID, parentID, abortedWV, req, decision, true, | |||
| 103 | + | ) | |||
| 104 | + | }) | |||
| 105 | + | return abortResult | |||
| 106 | + | } | |||
| 107 | + | if err := pm.onOpenNativePopup(ctx, NativePopupInput{ | |||
| 108 | + | ParentPaneID: parentPaneID, | |||
| 109 | + | ParentWebViewID: parentID, | |||
| 110 | + | ParentURIAtOpen: parentURIAtOpen, | |||
| 111 | + | PopupWebView: popupWV, | |||
| 112 | + | TargetURI: req.TargetURI, | |||
| 113 | + | Request: req, | |||
| 114 | + | ObserveOAuthAutoClose: cfg != nil && cfg.OAuthAutoClose && IsOAuthURL(req.TargetURI), | |||
| 115 | + | AllowBrowserWindowFallback: allowFallback, | |||
| 116 | + | OnNativeHostAbort: onAbort, | |||
| 117 | + | }); err != nil { | |||
| 118 | + | logBrowsingContextFailure(*log, normalized, decision, dto.BrowsingContextFailureNativeArm, err) | |||
| 119 | + | popupWV.Destroy() | |||
| 120 | + | return nil | |||
| 121 | + | } | |||
| 122 | + | if lifecycle, ok := popupWV.(port.PopupLifecycleCapable); ok { | |||
| 123 | + | lifecycle.PrimePopupNavigation(req.TargetURI) | |||
| 124 | + | } | |||
| 125 | + | return popupWV | |||
| 126 | + | } | |||
| 127 | + | ||||
| 62 | // openExistingPopupInBrowserWindow attempts to transfer an already-created | 128 | // openExistingPopupInBrowserWindow attempts to transfer an already-created | ||
| 63 | // WebView. On failure ownership remains with the caller. | 129 | // WebView. On failure ownership remains with the caller. | ||
| 64 | func (pm *popupManager) openExistingPopupInBrowserWindow( | 130 | func (pm *popupManager) openExistingPopupInBrowserWindow( | ||
M internal/ui/coordinator/content/popup_manager.go +0 -59
| @@ -512,65 +512,6 @@ func (pm *popupManager) popupParentURIAtOpen( | |||
| 512 | 512 | return parentURIAtOpen | |
| 513 | 513 | } | |
| 514 | 514 | ||
| 515 | - | func (pm *popupManager) openNativePopup( | |
| 516 | - | ctx context.Context, | |
| 517 | - | hooks popupCoordinatorHooks, | |
| 518 | - | parentPaneID entity.PaneID, | |
| 519 | - | parentID port.WebViewID, | |
| 520 | - | parentURIAtOpen string, | |
| 521 | - | req port.PopupRequest, | |
| 522 | - | decision dto.HostDecision, | |
| 523 | - | ) port.WebView { | |
| 524 | - | log := logging.FromContext(ctx) | |
| 525 | - | normalized := buildPopupBrowsingContextRequest(req) | |
| 526 | - | normalized.SourceHost = pm.resolveSourceHost(parentPaneID) | |
| 527 | - | if pm.onOpenNativePopup == nil { | |
| 528 | - | logBrowsingContextFailure(*log, normalized, decision, dto.BrowsingContextFailureHostUnavailable, nil) | |
| 529 | - | return nil | |
| 530 | - | } | |
| 531 | - | popupWV, err := pm.createPopupWebView(ctx, parentID, req.TargetURI, false) | |
| 532 | - | if err != nil { | |
| 533 | - | logBrowsingContextFailure(*log, normalized, decision, dto.BrowsingContextFailureHostFailed, err) | |
| 534 | - | return nil | |
| 535 | - | } | |
| 536 | - | cfg := pm.currentPopupConfig() | |
| 537 | - | pm.setBrowsingContextDecision(popupWV, decision) | |
| 538 | - | allowFallback := !normalized.AuthIntent && !decision.RequiresNativeOpener && req.NoJavaScriptAccess | |
| 539 | - | var abortOnce sync.Once | |
| 540 | - | abortResult := false | |
| 541 | - | onAbort := func(abortCtx context.Context, abortedWV port.WebView) bool { | |
| 542 | - | abortOnce.Do(func() { | |
| 543 | - | if !allowFallback { | |
| 544 | - | logBrowsingContextFailure(*log, normalized, decision, dto.BrowsingContextFailureNativeArm, nil) | |
| 545 | - | return | |
| 546 | - | } | |
| 547 | - | abortResult = pm.openExistingPopupInBrowserWindow( | |
| 548 | - | abortCtx, hooks, parentPaneID, parentID, abortedWV, req, decision, true, | |
| 549 | - | ) | |
| 550 | - | }) | |
| 551 | - | return abortResult | |
| 552 | - | } | |
| 553 | - | if err := pm.onOpenNativePopup(ctx, NativePopupInput{ | |
| 554 | - | ParentPaneID: parentPaneID, | |
| 555 | - | ParentWebViewID: parentID, | |
| 556 | - | ParentURIAtOpen: parentURIAtOpen, | |
| 557 | - | PopupWebView: popupWV, | |
| 558 | - | TargetURI: req.TargetURI, | |
| 559 | - | Request: req, | |
| 560 | - | ObserveOAuthAutoClose: cfg != nil && cfg.OAuthAutoClose && IsOAuthURL(req.TargetURI), | |
| 561 | - | AllowBrowserWindowFallback: allowFallback, | |
| 562 | - | OnNativeHostAbort: onAbort, | |
| 563 | - | }); err != nil { | |
| 564 | - | logBrowsingContextFailure(*log, normalized, decision, dto.BrowsingContextFailureNativeArm, err) | |
| 565 | - | popupWV.Destroy() | |
| 566 | - | return nil | |
| 567 | - | } | |
| 568 | - | if lifecycle, ok := popupWV.(port.PopupLifecycleCapable); ok { | |
| 569 | - | lifecycle.PrimePopupNavigation(req.TargetURI) | |
| 570 | - | } | |
| 571 | - | return popupWV | |
| 572 | - | } | |
| 573 | - | ||
| 574 | 515 | func (pm *popupManager) handlePopupCreate( | |
| 575 | 516 | ctx context.Context, | |
| 576 | 517 | hooks popupCoordinatorHooks, | |
| @@ -512,65 +512,6 @@ func (pm *popupManager) popupParentURIAtOpen( | |||||
| 512 | return parentURIAtOpen | 512 | return parentURIAtOpen | ||
| 513 | } | 513 | } | ||
| 514 | 514 | ||||
| 515 | - | func (pm *popupManager) openNativePopup( | |||
| 516 | - | ctx context.Context, | |||
| 517 | - | hooks popupCoordinatorHooks, | |||
| 518 | - | parentPaneID entity.PaneID, | |||
| 519 | - | parentID port.WebViewID, | |||
| 520 | - | parentURIAtOpen string, | |||
| 521 | - | req port.PopupRequest, | |||
| 522 | - | decision dto.HostDecision, | |||
| 523 | - | ) port.WebView { | |||
| 524 | - | log := logging.FromContext(ctx) | |||
| 525 | - | normalized := buildPopupBrowsingContextRequest(req) | |||
| 526 | - | normalized.SourceHost = pm.resolveSourceHost(parentPaneID) | |||
| 527 | - | if pm.onOpenNativePopup == nil { | |||
| 528 | - | logBrowsingContextFailure(*log, normalized, decision, dto.BrowsingContextFailureHostUnavailable, nil) | |||
| 529 | - | return nil | |||
| 530 | - | } | |||
| 531 | - | popupWV, err := pm.createPopupWebView(ctx, parentID, req.TargetURI, false) | |||
| 532 | - | if err != nil { | |||
| 533 | - | logBrowsingContextFailure(*log, normalized, decision, dto.BrowsingContextFailureHostFailed, err) | |||
| 534 | - | return nil | |||
| 535 | - | } | |||
| 536 | - | cfg := pm.currentPopupConfig() | |||
| 537 | - | pm.setBrowsingContextDecision(popupWV, decision) | |||
| 538 | - | allowFallback := !normalized.AuthIntent && !decision.RequiresNativeOpener && req.NoJavaScriptAccess | |||
| 539 | - | var abortOnce sync.Once | |||
| 540 | - | abortResult := false | |||
| 541 | - | onAbort := func(abortCtx context.Context, abortedWV port.WebView) bool { | |||
| 542 | - | abortOnce.Do(func() { | |||
| 543 | - | if !allowFallback { | |||
| 544 | - | logBrowsingContextFailure(*log, normalized, decision, dto.BrowsingContextFailureNativeArm, nil) | |||
| 545 | - | return | |||
| 546 | - | } | |||
| 547 | - | abortResult = pm.openExistingPopupInBrowserWindow( | |||
| 548 | - | abortCtx, hooks, parentPaneID, parentID, abortedWV, req, decision, true, | |||
| 549 | - | ) | |||
| 550 | - | }) | |||
| 551 | - | return abortResult | |||
| 552 | - | } | |||
| 553 | - | if err := pm.onOpenNativePopup(ctx, NativePopupInput{ | |||
| 554 | - | ParentPaneID: parentPaneID, | |||
| 555 | - | ParentWebViewID: parentID, | |||
| 556 | - | ParentURIAtOpen: parentURIAtOpen, | |||
| 557 | - | PopupWebView: popupWV, | |||
| 558 | - | TargetURI: req.TargetURI, | |||
| 559 | - | Request: req, | |||
| 560 | - | ObserveOAuthAutoClose: cfg != nil && cfg.OAuthAutoClose && IsOAuthURL(req.TargetURI), | |||
| 561 | - | AllowBrowserWindowFallback: allowFallback, | |||
| 562 | - | OnNativeHostAbort: onAbort, | |||
| 563 | - | }); err != nil { | |||
| 564 | - | logBrowsingContextFailure(*log, normalized, decision, dto.BrowsingContextFailureNativeArm, err) | |||
| 565 | - | popupWV.Destroy() | |||
| 566 | - | return nil | |||
| 567 | - | } | |||
| 568 | - | if lifecycle, ok := popupWV.(port.PopupLifecycleCapable); ok { | |||
| 569 | - | lifecycle.PrimePopupNavigation(req.TargetURI) | |||
| 570 | - | } | |||
| 571 | - | return popupWV | |||
| 572 | - | } | |||
| 573 | - | ||||
| 574 | func (pm *popupManager) handlePopupCreate( | 515 | func (pm *popupManager) handlePopupCreate( | ||
| 575 | ctx context.Context, | 516 | ctx context.Context, | ||
| 576 | hooks popupCoordinatorHooks, | 517 | hooks popupCoordinatorHooks, | ||
M internal/ui/coordinator/content/popup_test.go +55 -2
| @@ -1,11 +1,14 @@ | |||
| 1 | 1 | package content | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "bytes" | |
| 4 | 5 | "context" | |
| 5 | 6 | "errors" | |
| 6 | 7 | "sync" | |
| 7 | 8 | "testing" | |
| 8 | 9 | ||
| 10 | + | "github.com/rs/zerolog" | |
| 11 | + | ||
| 9 | 12 | "github.com/stretchr/testify/assert" | |
| 10 | 13 | "github.com/stretchr/testify/mock" | |
| 11 | 14 | "github.com/stretchr/testify/require" | |
| @@ -15,6 +18,7 @@ import ( | |||
| 15 | 18 | "github.com/bnema/dumber/internal/application/port/mocks" | |
| 16 | 19 | "github.com/bnema/dumber/internal/domain/entity" | |
| 17 | 20 | domainerrors "github.com/bnema/dumber/internal/domain/errors" | |
| 21 | + | "github.com/bnema/dumber/internal/logging" | |
| 18 | 22 | ) | |
| 19 | 23 | ||
| 20 | 24 | // --------------------------------------------------------------------------- | |
| @@ -674,6 +678,35 @@ func TestPopupNativeAbortFallsBackOnceForNoOpenerVisualPopup(t *testing.T) { | |||
| 674 | 678 | assert.Equal(t, 1, browserCalls) | |
| 675 | 679 | } | |
| 676 | 680 | ||
| 681 | + | func TestPopupNativeAbortFallsBackForOpenerCapableVisualPopup(t *testing.T) { | |
| 682 | + | ctx := context.Background() | |
| 683 | + | popupWV := &popupOpenerWebViewStub{popupNavigationWebViewStub: &popupNavigationWebViewStub{MockWebView: mocks.NewMockWebView(t)}} | |
| 684 | + | popupWV.EXPECT().ID().Return(port.WebViewID(703)).Once() | |
| 685 | + | factory := mocks.NewMockWebViewFactory(t) | |
| 686 | + | factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(popupWV, nil).Once() | |
| 687 | + | ||
| 688 | + | pm := newPopupManager() | |
| 689 | + | pm.setConfig(factory, nil, nil) | |
| 690 | + | browserCalls := 0 | |
| 691 | + | pm.setOnOpenBrowserWindow(func(_ context.Context, input BrowserWindowInput) (BrowserWindowResult, error) { | |
| 692 | + | browserCalls++ | |
| 693 | + | assert.Same(t, popupWV, input.PopupWebView) | |
| 694 | + | return BrowserWindowResult{WindowID: "fallback-window"}, nil | |
| 695 | + | }) | |
| 696 | + | pm.setOnOpenNativePopup(func(ctx context.Context, input NativePopupInput) error { | |
| 697 | + | assert.True(t, input.AllowBrowserWindowFallback) | |
| 698 | + | assert.True(t, input.OnNativeHostAbort(ctx, input.PopupWebView)) | |
| 699 | + | return nil | |
| 700 | + | }) | |
| 701 | + | ||
| 702 | + | got := pm.openNativePopup(ctx, popupCoordinatorHooks{}, "floating", 101, "", port.PopupRequest{ | |
| 703 | + | TargetURI: "https://example.com/visual", NoJavaScriptAccess: false, | |
| 704 | + | }, dto.HostDecision{Kind: dto.HostDecisionCreateNativePopup}) | |
| 705 | + | ||
| 706 | + | require.Same(t, popupWV, got) | |
| 707 | + | assert.Equal(t, 1, browserCalls) | |
| 708 | + | } | |
| 709 | + | ||
| 677 | 710 | func TestPopupNativeAbortDoesNotFallbackForAuthOrRequiredOpener(t *testing.T) { | |
| 678 | 711 | for _, tc := range []struct { | |
| 679 | 712 | name string | |
| @@ -711,6 +744,9 @@ func TestPopupNativeAbortDoesNotFallbackForAuthOrRequiredOpener(t *testing.T) { | |||
| 711 | 744 | } | |
| 712 | 745 | ||
| 713 | 746 | func TestPopupNativeFallbackFailureLeavesDestructionToNativeHost(t *testing.T) { | |
| 747 | + | var output bytes.Buffer | |
| 748 | + | logger := zerolog.New(&output) | |
| 749 | + | ctx := logging.WithContext(context.Background(), logger) | |
| 714 | 750 | popupWV := &popupNavigationWebViewStub{MockWebView: mocks.NewMockWebView(t)} | |
| 715 | 751 | popupWV.EXPECT().ID().Return(port.WebViewID(702)).Once() | |
| 716 | 752 | popupWV.EXPECT().Destroy().Once() | |
| @@ -718,6 +754,7 @@ func TestPopupNativeFallbackFailureLeavesDestructionToNativeHost(t *testing.T) { | |||
| 718 | 754 | factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(popupWV, nil).Once() | |
| 719 | 755 | pm := newPopupManager() | |
| 720 | 756 | pm.setConfig(factory, nil, nil) | |
| 757 | + | pm.setSourceHostResolver(func(entity.PaneID) dto.SourceHostKind { return dto.SourceHostFloating }) | |
| 721 | 758 | pm.setOnOpenBrowserWindow(func(context.Context, BrowserWindowInput) (BrowserWindowResult, error) { | |
| 722 | 759 | return BrowserWindowResult{}, errors.New("fallback failed") | |
| 723 | 760 | }) | |
| @@ -728,10 +765,17 @@ func TestPopupNativeFallbackFailureLeavesDestructionToNativeHost(t *testing.T) { | |||
| 728 | 765 | return nil | |
| 729 | 766 | }) | |
| 730 | 767 | ||
| 731 | - | got := pm.openNativePopup(context.Background(), popupCoordinatorHooks{}, "floating", 101, "", port.PopupRequest{ | |
| 732 | - | TargetURI: "https://example.com/visual", NoJavaScriptAccess: true, | |
| 768 | + | got := pm.openNativePopup(ctx, popupCoordinatorHooks{}, "floating", 101, "", port.PopupRequest{ | |
| 769 | + | Engine: dto.BrowserEngineCEF, TargetURI: "https://example.com/visual", NoJavaScriptAccess: true, | |
| 770 | + | TargetDisposition: dto.WindowDispositionNewPopup, | |
| 733 | 771 | }, dto.HostDecision{Kind: dto.HostDecisionCreateNativePopup}) | |
| 734 | 772 | require.Same(t, popupWV, got) | |
| 773 | + | record := decodeBrowsingContextLog(t, output.Bytes()) | |
| 774 | + | assert.Equal(t, "cef", record["engine"]) | |
| 775 | + | assert.Equal(t, "floating", record["source_host"]) | |
| 776 | + | assert.Equal(t, "create-native-popup", record["decision"]) | |
| 777 | + | assert.Equal(t, "new-popup", record["target_disposition"]) | |
| 778 | + | assert.Equal(t, "fallback-failed", record["reason_code"]) | |
| 735 | 779 | } | |
| 736 | 780 | ||
| 737 | 781 | func TestPopupNativeSetupFailureDestroysCoordinatorOwnedWebViewOnce(t *testing.T) { | |
| @@ -1054,6 +1098,15 @@ func TestUpdatePopupConfig_CopiesLatestValue(t *testing.T) { | |||
| 1054 | 1098 | assert.False(t, initial.OAuthAutoClose) | |
| 1055 | 1099 | } | |
| 1056 | 1100 | ||
| 1101 | + | type popupOpenerWebViewStub struct { | |
| 1102 | + | *popupNavigationWebViewStub | |
| 1103 | + | } | |
| 1104 | + | ||
| 1105 | + | func (*popupOpenerWebViewStub) EnablePopupOpenerBridge(port.WebView, bool) {} | |
| 1106 | + | func (*popupOpenerWebViewStub) AddOpenerMessageCallback(func()) {} | |
| 1107 | + | func (*popupOpenerWebViewStub) AddOpenerNavigationCallback(func(string)) {} | |
| 1108 | + | func (*popupOpenerWebViewStub) HasActivePopupOpenerBridge() bool { return true } | |
| 1109 | + | ||
| 1057 | 1110 | type popupNavigationWebViewStub struct { | |
| 1058 | 1111 | *mocks.MockWebView | |
| 1059 | 1112 | primed []string | |
| @@ -1,11 +1,14 @@ | |||||
| 1 | package content | 1 | package content | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "bytes" | |||
| 4 | "context" | 5 | "context" | ||
| 5 | "errors" | 6 | "errors" | ||
| 6 | "sync" | 7 | "sync" | ||
| 7 | "testing" | 8 | "testing" | ||
| 8 | 9 | ||||
| 10 | + | "github.com/rs/zerolog" | |||
| 11 | + | ||||
| 9 | "github.com/stretchr/testify/assert" | 12 | "github.com/stretchr/testify/assert" | ||
| 10 | "github.com/stretchr/testify/mock" | 13 | "github.com/stretchr/testify/mock" | ||
| 11 | "github.com/stretchr/testify/require" | 14 | "github.com/stretchr/testify/require" | ||
| @@ -15,6 +18,7 @@ import ( | |||||
| 15 | "github.com/bnema/dumber/internal/application/port/mocks" | 18 | "github.com/bnema/dumber/internal/application/port/mocks" | ||
| 16 | "github.com/bnema/dumber/internal/domain/entity" | 19 | "github.com/bnema/dumber/internal/domain/entity" | ||
| 17 | domainerrors "github.com/bnema/dumber/internal/domain/errors" | 20 | domainerrors "github.com/bnema/dumber/internal/domain/errors" | ||
| 21 | + | "github.com/bnema/dumber/internal/logging" | |||
| 18 | ) | 22 | ) | ||
| 19 | 23 | ||||
| 20 | // --------------------------------------------------------------------------- | 24 | // --------------------------------------------------------------------------- | ||
| @@ -674,6 +678,35 @@ func TestPopupNativeAbortFallsBackOnceForNoOpenerVisualPopup(t *testing.T) { | |||||
| 674 | assert.Equal(t, 1, browserCalls) | 678 | assert.Equal(t, 1, browserCalls) | ||
| 675 | } | 679 | } | ||
| 676 | 680 | ||||
| 681 | + | func TestPopupNativeAbortFallsBackForOpenerCapableVisualPopup(t *testing.T) { | |||
| 682 | + | ctx := context.Background() | |||
| 683 | + | popupWV := &popupOpenerWebViewStub{popupNavigationWebViewStub: &popupNavigationWebViewStub{MockWebView: mocks.NewMockWebView(t)}} | |||
| 684 | + | popupWV.EXPECT().ID().Return(port.WebViewID(703)).Once() | |||
| 685 | + | factory := mocks.NewMockWebViewFactory(t) | |||
| 686 | + | factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(popupWV, nil).Once() | |||
| 687 | + | ||||
| 688 | + | pm := newPopupManager() | |||
| 689 | + | pm.setConfig(factory, nil, nil) | |||
| 690 | + | browserCalls := 0 | |||
| 691 | + | pm.setOnOpenBrowserWindow(func(_ context.Context, input BrowserWindowInput) (BrowserWindowResult, error) { | |||
| 692 | + | browserCalls++ | |||
| 693 | + | assert.Same(t, popupWV, input.PopupWebView) | |||
| 694 | + | return BrowserWindowResult{WindowID: "fallback-window"}, nil | |||
| 695 | + | }) | |||
| 696 | + | pm.setOnOpenNativePopup(func(ctx context.Context, input NativePopupInput) error { | |||
| 697 | + | assert.True(t, input.AllowBrowserWindowFallback) | |||
| 698 | + | assert.True(t, input.OnNativeHostAbort(ctx, input.PopupWebView)) | |||
| 699 | + | return nil | |||
| 700 | + | }) | |||
| 701 | + | ||||
| 702 | + | got := pm.openNativePopup(ctx, popupCoordinatorHooks{}, "floating", 101, "", port.PopupRequest{ | |||
| 703 | + | TargetURI: "https://example.com/visual", NoJavaScriptAccess: false, | |||
| 704 | + | }, dto.HostDecision{Kind: dto.HostDecisionCreateNativePopup}) | |||
| 705 | + | ||||
| 706 | + | require.Same(t, popupWV, got) | |||
| 707 | + | assert.Equal(t, 1, browserCalls) | |||
| 708 | + | } | |||
| 709 | + | ||||
| 677 | func TestPopupNativeAbortDoesNotFallbackForAuthOrRequiredOpener(t *testing.T) { | 710 | func TestPopupNativeAbortDoesNotFallbackForAuthOrRequiredOpener(t *testing.T) { | ||
| 678 | for _, tc := range []struct { | 711 | for _, tc := range []struct { | ||
| 679 | name string | 712 | name string | ||
| @@ -711,6 +744,9 @@ func TestPopupNativeAbortDoesNotFallbackForAuthOrRequiredOpener(t *testing.T) { | |||||
| 711 | } | 744 | } | ||
| 712 | 745 | ||||
| 713 | func TestPopupNativeFallbackFailureLeavesDestructionToNativeHost(t *testing.T) { | 746 | func TestPopupNativeFallbackFailureLeavesDestructionToNativeHost(t *testing.T) { | ||
| 747 | + | var output bytes.Buffer | |||
| 748 | + | logger := zerolog.New(&output) | |||
| 749 | + | ctx := logging.WithContext(context.Background(), logger) | |||
| 714 | popupWV := &popupNavigationWebViewStub{MockWebView: mocks.NewMockWebView(t)} | 750 | popupWV := &popupNavigationWebViewStub{MockWebView: mocks.NewMockWebView(t)} | ||
| 715 | popupWV.EXPECT().ID().Return(port.WebViewID(702)).Once() | 751 | popupWV.EXPECT().ID().Return(port.WebViewID(702)).Once() | ||
| 716 | popupWV.EXPECT().Destroy().Once() | 752 | popupWV.EXPECT().Destroy().Once() | ||
| @@ -718,6 +754,7 @@ func TestPopupNativeFallbackFailureLeavesDestructionToNativeHost(t *testing.T) { | |||||
| 718 | factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(popupWV, nil).Once() | 754 | factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(popupWV, nil).Once() | ||
| 719 | pm := newPopupManager() | 755 | pm := newPopupManager() | ||
| 720 | pm.setConfig(factory, nil, nil) | 756 | pm.setConfig(factory, nil, nil) | ||
| 757 | + | pm.setSourceHostResolver(func(entity.PaneID) dto.SourceHostKind { return dto.SourceHostFloating }) | |||
| 721 | pm.setOnOpenBrowserWindow(func(context.Context, BrowserWindowInput) (BrowserWindowResult, error) { | 758 | pm.setOnOpenBrowserWindow(func(context.Context, BrowserWindowInput) (BrowserWindowResult, error) { | ||
| 722 | return BrowserWindowResult{}, errors.New("fallback failed") | 759 | return BrowserWindowResult{}, errors.New("fallback failed") | ||
| 723 | }) | 760 | }) | ||
| @@ -728,10 +765,17 @@ func TestPopupNativeFallbackFailureLeavesDestructionToNativeHost(t *testing.T) { | |||||
| 728 | return nil | 765 | return nil | ||
| 729 | }) | 766 | }) | ||
| 730 | 767 | ||||
| 731 | - | got := pm.openNativePopup(context.Background(), popupCoordinatorHooks{}, "floating", 101, "", port.PopupRequest{ | 768 | + | got := pm.openNativePopup(ctx, popupCoordinatorHooks{}, "floating", 101, "", port.PopupRequest{ |
| 732 | - | TargetURI: "https://example.com/visual", NoJavaScriptAccess: true, | 769 | + | Engine: dto.BrowserEngineCEF, TargetURI: "https://example.com/visual", NoJavaScriptAccess: true, |
| 770 | + | TargetDisposition: dto.WindowDispositionNewPopup, | |||
| 733 | }, dto.HostDecision{Kind: dto.HostDecisionCreateNativePopup}) | 771 | }, dto.HostDecision{Kind: dto.HostDecisionCreateNativePopup}) | ||
| 734 | require.Same(t, popupWV, got) | 772 | require.Same(t, popupWV, got) | ||
| 773 | + | record := decodeBrowsingContextLog(t, output.Bytes()) | |||
| 774 | + | assert.Equal(t, "cef", record["engine"]) | |||
| 775 | + | assert.Equal(t, "floating", record["source_host"]) | |||
| 776 | + | assert.Equal(t, "create-native-popup", record["decision"]) | |||
| 777 | + | assert.Equal(t, "new-popup", record["target_disposition"]) | |||
| 778 | + | assert.Equal(t, "fallback-failed", record["reason_code"]) | |||
| 735 | } | 779 | } | ||
| 736 | 780 | ||||
| 737 | func TestPopupNativeSetupFailureDestroysCoordinatorOwnedWebViewOnce(t *testing.T) { | 781 | func TestPopupNativeSetupFailureDestroysCoordinatorOwnedWebViewOnce(t *testing.T) { | ||
| @@ -1054,6 +1098,15 @@ func TestUpdatePopupConfig_CopiesLatestValue(t *testing.T) { | |||||
| 1054 | assert.False(t, initial.OAuthAutoClose) | 1098 | assert.False(t, initial.OAuthAutoClose) | ||
| 1055 | } | 1099 | } | ||
| 1056 | 1100 | ||||
| 1101 | + | type popupOpenerWebViewStub struct { | |||
| 1102 | + | *popupNavigationWebViewStub | |||
| 1103 | + | } | |||
| 1104 | + | ||||
| 1105 | + | func (*popupOpenerWebViewStub) EnablePopupOpenerBridge(port.WebView, bool) {} | |||
| 1106 | + | func (*popupOpenerWebViewStub) AddOpenerMessageCallback(func()) {} | |||
| 1107 | + | func (*popupOpenerWebViewStub) AddOpenerNavigationCallback(func(string)) {} | |||
| 1108 | + | func (*popupOpenerWebViewStub) HasActivePopupOpenerBridge() bool { return true } | |||
| 1109 | + | ||||
| 1057 | type popupNavigationWebViewStub struct { | 1110 | type popupNavigationWebViewStub struct { | ||
| 1058 | *mocks.MockWebView | 1111 | *mocks.MockWebView | ||
| 1059 | primed []string | 1112 | primed []string | ||