fix(browsing): keep featureless links in floating panes
- commit
- 5992583c1b59ebfaa06cf9a47ecc989b2ea95ff9
- author
- brice · · 2026-07-27 07:46 UTC
- parent
- 7fae873
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
9 files · +140 -71
M docs/reference/floating-pane.md +2 -2
| @@ -18,9 +18,9 @@ The floating pane is an overlay workspace pane for quick-access pages like searc | |||
| 18 | 18 | ||
| 19 | 19 | ### Links and popups | |
| 20 | 20 | ||
| 21 | - | New-page links, middle-clicks, and featureless `window.open()` calls from a floating pane open in a new Dumber window. OAuth flows and scripts that request popup dimensions or restricted browser chrome open in a related popup window so authentication and opener messaging continue to work. | |
| 21 | + | New-page links, middle-clicks, and featureless `_blank` `window.open()` calls from a floating pane navigate that floating pane's current page. They do not open a separate Dumber window. | |
| 22 | 22 | ||
| 23 | - | Detached Dumber windows remain open when the source floating pane is hidden or closed. | |
| 23 | + | OAuth flows and scripts that request popup dimensions or restricted browser chrome open in a related popup window so authentication and opener messaging continue to work. Named browsing contexts continue to reuse an existing live context when one is available. | |
| 24 | 24 | ||
| 25 | 25 | ## Configuration | |
| 26 | 26 | ||
| @@ -18,9 +18,9 @@ The floating pane is an overlay workspace pane for quick-access pages like searc | |||||
| 18 | 18 | ||||
| 19 | ### Links and popups | 19 | ### Links and popups | ||
| 20 | 20 | ||||
| 21 | - | New-page links, middle-clicks, and featureless `window.open()` calls from a floating pane open in a new Dumber window. OAuth flows and scripts that request popup dimensions or restricted browser chrome open in a related popup window so authentication and opener messaging continue to work. | 21 | + | New-page links, middle-clicks, and featureless `_blank` `window.open()` calls from a floating pane navigate that floating pane's current page. They do not open a separate Dumber window. |
| 22 | 22 | ||||
| 23 | - | Detached Dumber windows remain open when the source floating pane is hidden or closed. | 23 | + | OAuth flows and scripts that request popup dimensions or restricted browser chrome open in a related popup window so authentication and opener messaging continue to work. Named browsing contexts continue to reuse an existing live context when one is available. |
| 24 | 24 | ||||
| 25 | ## Configuration | 25 | ## Configuration | ||
| 26 | 26 | ||||
M internal/application/dto/browsing_context.go +11 -9
| @@ -111,6 +111,7 @@ type HostDecisionKind string | |||
| 111 | 111 | ||
| 112 | 112 | const ( | |
| 113 | 113 | HostDecisionReuseNamedPane HostDecisionKind = "reuse-named-pane" | |
| 114 | + | HostDecisionNavigateSource HostDecisionKind = "navigate-source" | |
| 114 | 115 | HostDecisionCreatePane HostDecisionKind = "create-pane" | |
| 115 | 116 | HostDecisionCreateBrowserWindow HostDecisionKind = "create-browser-window" | |
| 116 | 117 | HostDecisionCreateNativePopup HostDecisionKind = "create-native-popup" | |
| @@ -122,15 +123,16 @@ const ( | |||
| 122 | 123 | type HostDecisionReasonCode string | |
| 123 | 124 | ||
| 124 | 125 | const ( | |
| 125 | - | HostDecisionReasonEmptyTarget HostDecisionReasonCode = "empty-target" | |
| 126 | - | HostDecisionReasonAuthNativePopup HostDecisionReasonCode = "auth-native-popup" | |
| 127 | - | HostDecisionReasonNativeOpenerRequired HostDecisionReasonCode = "native-opener-required" | |
| 128 | - | HostDecisionReasonAmbiguousOpenerNative HostDecisionReasonCode = "ambiguous-opener-native" | |
| 129 | - | HostDecisionReasonNamedContextReuse HostDecisionReasonCode = "named-context-reuse" | |
| 130 | - | HostDecisionReasonFloatingFeaturesPending HostDecisionReasonCode = "floating-features-pending" | |
| 131 | - | HostDecisionReasonFloatingPopupFeatures HostDecisionReasonCode = "floating-popup-features" | |
| 132 | - | HostDecisionReasonFloatingBrowserWindow HostDecisionReasonCode = "floating-browser-window" | |
| 133 | - | HostDecisionReasonWorkspacePane HostDecisionReasonCode = "workspace-pane" | |
| 126 | + | HostDecisionReasonEmptyTarget HostDecisionReasonCode = "empty-target" | |
| 127 | + | HostDecisionReasonAuthNativePopup HostDecisionReasonCode = "auth-native-popup" | |
| 128 | + | HostDecisionReasonNativeOpenerRequired HostDecisionReasonCode = "native-opener-required" | |
| 129 | + | HostDecisionReasonAmbiguousOpenerNative HostDecisionReasonCode = "ambiguous-opener-native" | |
| 130 | + | HostDecisionReasonNamedContextReuse HostDecisionReasonCode = "named-context-reuse" | |
| 131 | + | HostDecisionReasonFloatingFeaturesPending HostDecisionReasonCode = "floating-features-pending" | |
| 132 | + | HostDecisionReasonFloatingPopupFeatures HostDecisionReasonCode = "floating-popup-features" | |
| 133 | + | HostDecisionReasonFloatingFeaturelessBlank HostDecisionReasonCode = "floating-featureless-blank" | |
| 134 | + | HostDecisionReasonFloatingBrowserWindow HostDecisionReasonCode = "floating-browser-window" | |
| 135 | + | HostDecisionReasonWorkspacePane HostDecisionReasonCode = "workspace-pane" | |
| 134 | 136 | ) | |
| 135 | 137 | ||
| 136 | 138 | // BrowsingContextFailureCode is a stable machine-readable operational failure. | |
| @@ -111,6 +111,7 @@ type HostDecisionKind string | |||||
| 111 | 111 | ||||
| 112 | const ( | 112 | const ( | ||
| 113 | HostDecisionReuseNamedPane HostDecisionKind = "reuse-named-pane" | 113 | HostDecisionReuseNamedPane HostDecisionKind = "reuse-named-pane" | ||
| 114 | + | HostDecisionNavigateSource HostDecisionKind = "navigate-source" | |||
| 114 | HostDecisionCreatePane HostDecisionKind = "create-pane" | 115 | HostDecisionCreatePane HostDecisionKind = "create-pane" | ||
| 115 | HostDecisionCreateBrowserWindow HostDecisionKind = "create-browser-window" | 116 | HostDecisionCreateBrowserWindow HostDecisionKind = "create-browser-window" | ||
| 116 | HostDecisionCreateNativePopup HostDecisionKind = "create-native-popup" | 117 | HostDecisionCreateNativePopup HostDecisionKind = "create-native-popup" | ||
| @@ -122,15 +123,16 @@ const ( | |||||
| 122 | type HostDecisionReasonCode string | 123 | type HostDecisionReasonCode string | ||
| 123 | 124 | ||||
| 124 | const ( | 125 | const ( | ||
| 125 | - | HostDecisionReasonEmptyTarget HostDecisionReasonCode = "empty-target" | 126 | + | HostDecisionReasonEmptyTarget HostDecisionReasonCode = "empty-target" |
| 126 | - | HostDecisionReasonAuthNativePopup HostDecisionReasonCode = "auth-native-popup" | 127 | + | HostDecisionReasonAuthNativePopup HostDecisionReasonCode = "auth-native-popup" |
| 127 | - | HostDecisionReasonNativeOpenerRequired HostDecisionReasonCode = "native-opener-required" | 128 | + | HostDecisionReasonNativeOpenerRequired HostDecisionReasonCode = "native-opener-required" |
| 128 | - | HostDecisionReasonAmbiguousOpenerNative HostDecisionReasonCode = "ambiguous-opener-native" | 129 | + | HostDecisionReasonAmbiguousOpenerNative HostDecisionReasonCode = "ambiguous-opener-native" |
| 129 | - | HostDecisionReasonNamedContextReuse HostDecisionReasonCode = "named-context-reuse" | 130 | + | HostDecisionReasonNamedContextReuse HostDecisionReasonCode = "named-context-reuse" |
| 130 | - | HostDecisionReasonFloatingFeaturesPending HostDecisionReasonCode = "floating-features-pending" | 131 | + | HostDecisionReasonFloatingFeaturesPending HostDecisionReasonCode = "floating-features-pending" |
| 131 | - | HostDecisionReasonFloatingPopupFeatures HostDecisionReasonCode = "floating-popup-features" | 132 | + | HostDecisionReasonFloatingPopupFeatures HostDecisionReasonCode = "floating-popup-features" |
| 132 | - | HostDecisionReasonFloatingBrowserWindow HostDecisionReasonCode = "floating-browser-window" | 133 | + | HostDecisionReasonFloatingFeaturelessBlank HostDecisionReasonCode = "floating-featureless-blank" |
| 133 | - | HostDecisionReasonWorkspacePane HostDecisionReasonCode = "workspace-pane" | 134 | + | HostDecisionReasonFloatingBrowserWindow HostDecisionReasonCode = "floating-browser-window" |
| 135 | + | HostDecisionReasonWorkspacePane HostDecisionReasonCode = "workspace-pane" | |||
| 134 | ) | 136 | ) | ||
| 135 | 137 | ||||
| 136 | // BrowsingContextFailureCode is a stable machine-readable operational failure. | 138 | // BrowsingContextFailureCode is a stable machine-readable operational failure. | ||
M internal/infrastructure/webkit/webview.go +16 -10
| @@ -51,10 +51,11 @@ const ( | |||
| 51 | 51 | ||
| 52 | 52 | // PopupRequest contains information about a popup window request from the create signal. | |
| 53 | 53 | type PopupRequest struct { | |
| 54 | - | TargetURI string | |
| 55 | - | FrameName string // e.g., "_blank", custom name, or empty | |
| 56 | - | IsUserGesture bool | |
| 57 | - | ParentID WebViewID | |
| 54 | + | TargetURI string | |
| 55 | + | FrameName string // e.g., "_blank", custom name, or empty | |
| 56 | + | NavigationType webkit.NavigationType | |
| 57 | + | IsUserGesture bool | |
| 58 | + | ParentID WebViewID | |
| 58 | 59 | } | |
| 59 | 60 | ||
| 60 | 61 | // webViewRegistry tracks all active WebViews. | |
| @@ -606,10 +607,11 @@ func (wv *WebView) connectCreateSignal() { | |||
| 606 | 607 | } | |
| 607 | 608 | ||
| 608 | 609 | popupReq := PopupRequest{ | |
| 609 | - | TargetURI: targetURI, | |
| 610 | - | FrameName: navAction.GetFrameName(), | |
| 611 | - | IsUserGesture: navAction.IsUserGesture(), | |
| 612 | - | ParentID: wv.id, | |
| 610 | + | TargetURI: targetURI, | |
| 611 | + | FrameName: navAction.GetFrameName(), | |
| 612 | + | NavigationType: navAction.GetNavigationType(), | |
| 613 | + | IsUserGesture: navAction.IsUserGesture(), | |
| 614 | + | ParentID: wv.id, | |
| 613 | 615 | } | |
| 614 | 616 | ||
| 615 | 617 | wv.logger.Debug().Msg("create signal: invoking OnCreate handler") | |
| @@ -1731,8 +1733,12 @@ func mapPopupRequest(req PopupRequest) port.PopupRequest { | |||
| 1731 | 1733 | PopupFeatures: dto.PopupFeatures{State: dto.PopupFeaturesUnknown}, | |
| 1732 | 1734 | } | |
| 1733 | 1735 | if strings.EqualFold(strings.TrimSpace(req.FrameName), "_blank") { | |
| 1734 | - | mapped.TargetDisposition = dto.WindowDispositionNewTab | |
| 1735 | - | mapped.PopupFeatures.State = dto.PopupFeaturesNone | |
| 1736 | + | if req.NavigationType == webkit.NavigationTypeLinkClickedValue { | |
| 1737 | + | mapped.TargetDisposition = dto.WindowDispositionNewTab | |
| 1738 | + | mapped.PopupFeatures.State = dto.PopupFeaturesNone | |
| 1739 | + | } else { | |
| 1740 | + | mapped.TargetDisposition = dto.WindowDispositionNewPopup | |
| 1741 | + | } | |
| 1736 | 1742 | } | |
| 1737 | 1743 | return mapped | |
| 1738 | 1744 | } | |
| @@ -51,10 +51,11 @@ const ( | |||||
| 51 | 51 | ||||
| 52 | // PopupRequest contains information about a popup window request from the create signal. | 52 | // PopupRequest contains information about a popup window request from the create signal. | ||
| 53 | type PopupRequest struct { | 53 | type PopupRequest struct { | ||
| 54 | - | TargetURI string | 54 | + | TargetURI string |
| 55 | - | FrameName string // e.g., "_blank", custom name, or empty | 55 | + | FrameName string // e.g., "_blank", custom name, or empty |
| 56 | - | IsUserGesture bool | 56 | + | NavigationType webkit.NavigationType |
| 57 | - | ParentID WebViewID | 57 | + | IsUserGesture bool |
| 58 | + | ParentID WebViewID | |||
| 58 | } | 59 | } | ||
| 59 | 60 | ||||
| 60 | // webViewRegistry tracks all active WebViews. | 61 | // webViewRegistry tracks all active WebViews. | ||
| @@ -606,10 +607,11 @@ func (wv *WebView) connectCreateSignal() { | |||||
| 606 | } | 607 | } | ||
| 607 | 608 | ||||
| 608 | popupReq := PopupRequest{ | 609 | popupReq := PopupRequest{ | ||
| 609 | - | TargetURI: targetURI, | 610 | + | TargetURI: targetURI, |
| 610 | - | FrameName: navAction.GetFrameName(), | 611 | + | FrameName: navAction.GetFrameName(), |
| 611 | - | IsUserGesture: navAction.IsUserGesture(), | 612 | + | NavigationType: navAction.GetNavigationType(), |
| 612 | - | ParentID: wv.id, | 613 | + | IsUserGesture: navAction.IsUserGesture(), |
| 614 | + | ParentID: wv.id, | |||
| 613 | } | 615 | } | ||
| 614 | 616 | ||||
| 615 | wv.logger.Debug().Msg("create signal: invoking OnCreate handler") | 617 | wv.logger.Debug().Msg("create signal: invoking OnCreate handler") | ||
| @@ -1731,8 +1733,12 @@ func mapPopupRequest(req PopupRequest) port.PopupRequest { | |||||
| 1731 | PopupFeatures: dto.PopupFeatures{State: dto.PopupFeaturesUnknown}, | 1733 | PopupFeatures: dto.PopupFeatures{State: dto.PopupFeaturesUnknown}, | ||
| 1732 | } | 1734 | } | ||
| 1733 | if strings.EqualFold(strings.TrimSpace(req.FrameName), "_blank") { | 1735 | if strings.EqualFold(strings.TrimSpace(req.FrameName), "_blank") { | ||
| 1734 | - | mapped.TargetDisposition = dto.WindowDispositionNewTab | 1736 | + | if req.NavigationType == webkit.NavigationTypeLinkClickedValue { |
| 1735 | - | mapped.PopupFeatures.State = dto.PopupFeaturesNone | 1737 | + | mapped.TargetDisposition = dto.WindowDispositionNewTab |
| 1738 | + | mapped.PopupFeatures.State = dto.PopupFeaturesNone | |||
| 1739 | + | } else { | |||
| 1740 | + | mapped.TargetDisposition = dto.WindowDispositionNewPopup | |||
| 1741 | + | } | |||
| 1736 | } | 1742 | } | ||
| 1737 | return mapped | 1743 | return mapped | ||
| 1738 | } | 1744 | } | ||
M internal/infrastructure/webkit/webview_popup_test.go +15 -3
| @@ -4,6 +4,7 @@ import ( | |||
| 4 | 4 | "testing" | |
| 5 | 5 | ||
| 6 | 6 | "github.com/bnema/dumber/internal/application/dto" | |
| 7 | + | purewebkit "github.com/bnema/puregotk/v4/webkit" | |
| 7 | 8 | "github.com/stretchr/testify/assert" | |
| 8 | 9 | ) | |
| 9 | 10 | ||
| @@ -41,16 +42,27 @@ func TestResolvePopupFeatures(t *testing.T) { | |||
| 41 | 42 | } | |
| 42 | 43 | } | |
| 43 | 44 | ||
| 44 | - | func TestMapPopupRequestClassifiesBlankAndDefersAmbiguousScript(t *testing.T) { | |
| 45 | + | func TestMapPopupRequestClassifiesLinkBlankAsFeatureless(t *testing.T) { | |
| 45 | 46 | t.Parallel() | |
| 46 | 47 | ||
| 47 | - | blank := mapPopupRequest(PopupRequest{TargetURI: "https://example.com/new", FrameName: " _BLANK ", ParentID: 7}) | |
| 48 | + | blank := mapPopupRequest(PopupRequest{ | |
| 49 | + | TargetURI: "https://example.com/new", FrameName: " _BLANK ", ParentID: 7, | |
| 50 | + | NavigationType: purewebkit.NavigationTypeLinkClickedValue, | |
| 51 | + | }) | |
| 48 | 52 | assert.Equal(t, dto.BrowserEngineWebKit, blank.Engine) | |
| 49 | 53 | assert.Equal(t, dto.WindowDispositionNewTab, blank.TargetDisposition) | |
| 50 | 54 | assert.Equal(t, dto.PopupFeaturesNone, blank.PopupFeatures.State) | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | func TestMapPopupRequestDefersScriptBlankFeatures(t *testing.T) { | |
| 58 | + | t.Parallel() | |
| 51 | 59 | ||
| 52 | - | script := mapPopupRequest(PopupRequest{TargetURI: "https://example.com/popup", FrameName: "oauth", ParentID: 7}) | |
| 60 | + | script := mapPopupRequest(PopupRequest{ | |
| 61 | + | TargetURI: "https://example.com/popup", FrameName: "_blank", ParentID: 7, | |
| 62 | + | NavigationType: purewebkit.NavigationTypeOtherValue, | |
| 63 | + | }) | |
| 53 | 64 | assert.Equal(t, dto.BrowserEngineWebKit, script.Engine) | |
| 65 | + | assert.Equal(t, dto.WindowDispositionNewPopup, script.TargetDisposition) | |
| 54 | 66 | assert.Equal(t, dto.PopupFeaturesUnknown, script.PopupFeatures.State) | |
| 55 | 67 | } | |
| 56 | 68 | ||
| @@ -4,6 +4,7 @@ import ( | |||||
| 4 | "testing" | 4 | "testing" | ||
| 5 | 5 | ||||
| 6 | "github.com/bnema/dumber/internal/application/dto" | 6 | "github.com/bnema/dumber/internal/application/dto" | ||
| 7 | + | purewebkit "github.com/bnema/puregotk/v4/webkit" | |||
| 7 | "github.com/stretchr/testify/assert" | 8 | "github.com/stretchr/testify/assert" | ||
| 8 | ) | 9 | ) | ||
| 9 | 10 | ||||
| @@ -41,16 +42,27 @@ func TestResolvePopupFeatures(t *testing.T) { | |||||
| 41 | } | 42 | } | ||
| 42 | } | 43 | } | ||
| 43 | 44 | ||||
| 44 | - | func TestMapPopupRequestClassifiesBlankAndDefersAmbiguousScript(t *testing.T) { | 45 | + | func TestMapPopupRequestClassifiesLinkBlankAsFeatureless(t *testing.T) { |
| 45 | t.Parallel() | 46 | t.Parallel() | ||
| 46 | 47 | ||||
| 47 | - | blank := mapPopupRequest(PopupRequest{TargetURI: "https://example.com/new", FrameName: " _BLANK ", ParentID: 7}) | 48 | + | blank := mapPopupRequest(PopupRequest{ |
| 49 | + | TargetURI: "https://example.com/new", FrameName: " _BLANK ", ParentID: 7, | |||
| 50 | + | NavigationType: purewebkit.NavigationTypeLinkClickedValue, | |||
| 51 | + | }) | |||
| 48 | assert.Equal(t, dto.BrowserEngineWebKit, blank.Engine) | 52 | assert.Equal(t, dto.BrowserEngineWebKit, blank.Engine) | ||
| 49 | assert.Equal(t, dto.WindowDispositionNewTab, blank.TargetDisposition) | 53 | assert.Equal(t, dto.WindowDispositionNewTab, blank.TargetDisposition) | ||
| 50 | assert.Equal(t, dto.PopupFeaturesNone, blank.PopupFeatures.State) | 54 | assert.Equal(t, dto.PopupFeaturesNone, blank.PopupFeatures.State) | ||
| 55 | + | } | |||
| 56 | + | ||||
| 57 | + | func TestMapPopupRequestDefersScriptBlankFeatures(t *testing.T) { | |||
| 58 | + | t.Parallel() | |||
| 51 | 59 | ||||
| 52 | - | script := mapPopupRequest(PopupRequest{TargetURI: "https://example.com/popup", FrameName: "oauth", ParentID: 7}) | 60 | + | script := mapPopupRequest(PopupRequest{ |
| 61 | + | TargetURI: "https://example.com/popup", FrameName: "_blank", ParentID: 7, | |||
| 62 | + | NavigationType: purewebkit.NavigationTypeOtherValue, | |||
| 63 | + | }) | |||
| 53 | assert.Equal(t, dto.BrowserEngineWebKit, script.Engine) | 64 | assert.Equal(t, dto.BrowserEngineWebKit, script.Engine) | ||
| 65 | + | assert.Equal(t, dto.WindowDispositionNewPopup, script.TargetDisposition) | |||
| 54 | assert.Equal(t, dto.PopupFeaturesUnknown, script.PopupFeatures.State) | 66 | assert.Equal(t, dto.PopupFeaturesUnknown, script.PopupFeatures.State) | ||
| 55 | } | 67 | } | ||
| 56 | 68 | ||||
M internal/ui/coordinator/content/browsing_context_policy.go +5 -0
| @@ -72,6 +72,11 @@ func decideFloatingHost(decision dto.HostDecision, req dto.NewBrowsingContextReq | |||
| 72 | 72 | decision, dto.HostDecisionCreateNativePopup, dto.HostDecisionReasonFloatingPopupFeatures, | |
| 73 | 73 | "explicit popup features requested", | |
| 74 | 74 | ) | |
| 75 | + | case reusableBrowsingContextName(req.TargetFrameName) == "": | |
| 76 | + | return completeHostDecision( | |
| 77 | + | decision, dto.HostDecisionNavigateSource, dto.HostDecisionReasonFloatingFeaturelessBlank, | |
| 78 | + | "featureless blank request navigates the source floating pane", | |
| 79 | + | ) | |
| 75 | 80 | default: | |
| 76 | 81 | return completeHostDecision( | |
| 77 | 82 | decision, dto.HostDecisionCreateBrowserWindow, dto.HostDecisionReasonFloatingBrowserWindow, | |
| @@ -72,6 +72,11 @@ func decideFloatingHost(decision dto.HostDecision, req dto.NewBrowsingContextReq | |||||
| 72 | decision, dto.HostDecisionCreateNativePopup, dto.HostDecisionReasonFloatingPopupFeatures, | 72 | decision, dto.HostDecisionCreateNativePopup, dto.HostDecisionReasonFloatingPopupFeatures, | ||
| 73 | "explicit popup features requested", | 73 | "explicit popup features requested", | ||
| 74 | ) | 74 | ) | ||
| 75 | + | case reusableBrowsingContextName(req.TargetFrameName) == "": | |||
| 76 | + | return completeHostDecision( | |||
| 77 | + | decision, dto.HostDecisionNavigateSource, dto.HostDecisionReasonFloatingFeaturelessBlank, | |||
| 78 | + | "featureless blank request navigates the source floating pane", | |||
| 79 | + | ) | |||
| 75 | default: | 80 | default: | ||
| 76 | return completeHostDecision( | 81 | return completeHostDecision( | ||
| 77 | decision, dto.HostDecisionCreateBrowserWindow, dto.HostDecisionReasonFloatingBrowserWindow, | 82 | decision, dto.HostDecisionCreateBrowserWindow, dto.HostDecisionReasonFloatingBrowserWindow, | ||
M internal/ui/coordinator/content/browsing_context_policy_test.go +2 -2
| @@ -50,8 +50,8 @@ func TestBrowsingContextPolicyDecide(t *testing.T) { | |||
| 50 | 50 | }{ | |
| 51 | 51 | {name: "workspace behavior remains pane hosted", request: workspace, wantKind: dto.HostDecisionCreatePane, wantReason: dto.HostDecisionReasonWorkspacePane}, | |
| 52 | 52 | {name: "zero source preserves workspace behavior", request: dto.NewBrowsingContextRequest{TargetURI: "https://example.com/docs", TriggerKind: dto.TriggerLinkNewPage}, wantKind: dto.HostDecisionCreatePane, wantReason: dto.HostDecisionReasonWorkspacePane}, | |
| 53 | - | {name: "floating blank target opens browser window", request: dto.NewBrowsingContextRequest{SourceHost: dto.SourceHostFloating, TargetURI: "https://example.com/docs", TargetDisposition: dto.WindowDispositionNewTab, TriggerKind: dto.TriggerLinkNewPage, PopupFeatures: dto.PopupFeatures{State: dto.PopupFeaturesNone}}, wantKind: dto.HostDecisionCreateBrowserWindow, wantReason: dto.HostDecisionReasonFloatingBrowserWindow}, | |
| 54 | - | {name: "floating featureless script opens browser window", request: dto.NewBrowsingContextRequest{SourceHost: dto.SourceHostFloating, TargetURI: "https://example.com/help", TargetDisposition: dto.WindowDispositionNewPopup, TriggerKind: dto.TriggerScriptWindowOpen, PopupFeatures: dto.PopupFeatures{State: dto.PopupFeaturesNone}}, wantKind: dto.HostDecisionCreateBrowserWindow, wantReason: dto.HostDecisionReasonFloatingBrowserWindow}, | |
| 53 | + | {name: "floating blank target navigates source", request: dto.NewBrowsingContextRequest{SourceHost: dto.SourceHostFloating, TargetURI: "https://example.com/docs", TargetFrameName: "_blank", TargetDisposition: dto.WindowDispositionNewTab, TriggerKind: dto.TriggerLinkNewPage, PopupFeatures: dto.PopupFeatures{State: dto.PopupFeaturesNone}}, wantKind: dto.HostDecisionNavigateSource, wantReason: dto.HostDecisionReasonFloatingFeaturelessBlank}, | |
| 54 | + | {name: "floating featureless script navigates source", request: dto.NewBrowsingContextRequest{SourceHost: dto.SourceHostFloating, TargetURI: "https://example.com/help", TargetFrameName: "_blank", TargetDisposition: dto.WindowDispositionNewPopup, TriggerKind: dto.TriggerScriptWindowOpen, PopupFeatures: dto.PopupFeatures{State: dto.PopupFeaturesNone}}, wantKind: dto.HostDecisionNavigateSource, wantReason: dto.HostDecisionReasonFloatingFeaturelessBlank}, | |
| 55 | 55 | {name: "floating featured script opens native popup", request: dto.NewBrowsingContextRequest{SourceHost: dto.SourceHostFloating, TargetURI: "https://example.com/help", TargetDisposition: dto.WindowDispositionNewPopup, TriggerKind: dto.TriggerScriptWindowOpen, PopupFeatures: dto.PopupFeatures{State: dto.PopupFeaturesSpecified, Width: 640, WidthSet: true}}, wantKind: dto.HostDecisionCreateNativePopup, wantReason: dto.HostDecisionReasonFloatingPopupFeatures}, | |
| 56 | 56 | {name: "floating unknown script waits", request: dto.NewBrowsingContextRequest{SourceHost: dto.SourceHostFloating, TargetURI: "https://example.com/help", TargetDisposition: dto.WindowDispositionNewPopup, TriggerKind: dto.TriggerScriptWindowOpen, PopupFeatures: dto.PopupFeatures{State: dto.PopupFeaturesUnknown}}, wantKind: dto.HostDecisionAwaitPopupFeatures, wantReason: dto.HostDecisionReasonFloatingFeaturesPending}, | |
| 57 | 57 | {name: "floating named unknown waits", request: dto.NewBrowsingContextRequest{SourceHost: dto.SourceHostFloating, TargetURI: "https://example.com/help", TargetFrameName: "oauth", TargetDisposition: dto.WindowDispositionNewPopup, TriggerKind: dto.TriggerNamedTargetNavigation, PopupFeatures: dto.PopupFeatures{State: dto.PopupFeaturesUnknown}}, wantKind: dto.HostDecisionAwaitPopupFeatures, wantName: "oauth", wantReason: dto.HostDecisionReasonFloatingFeaturesPending}, | |
| @@ -50,8 +50,8 @@ func TestBrowsingContextPolicyDecide(t *testing.T) { | |||||
| 50 | }{ | 50 | }{ | ||
| 51 | {name: "workspace behavior remains pane hosted", request: workspace, wantKind: dto.HostDecisionCreatePane, wantReason: dto.HostDecisionReasonWorkspacePane}, | 51 | {name: "workspace behavior remains pane hosted", request: workspace, wantKind: dto.HostDecisionCreatePane, wantReason: dto.HostDecisionReasonWorkspacePane}, | ||
| 52 | {name: "zero source preserves workspace behavior", request: dto.NewBrowsingContextRequest{TargetURI: "https://example.com/docs", TriggerKind: dto.TriggerLinkNewPage}, wantKind: dto.HostDecisionCreatePane, wantReason: dto.HostDecisionReasonWorkspacePane}, | 52 | {name: "zero source preserves workspace behavior", request: dto.NewBrowsingContextRequest{TargetURI: "https://example.com/docs", TriggerKind: dto.TriggerLinkNewPage}, wantKind: dto.HostDecisionCreatePane, wantReason: dto.HostDecisionReasonWorkspacePane}, | ||
| 53 | - | {name: "floating blank target opens browser window", request: dto.NewBrowsingContextRequest{SourceHost: dto.SourceHostFloating, TargetURI: "https://example.com/docs", TargetDisposition: dto.WindowDispositionNewTab, TriggerKind: dto.TriggerLinkNewPage, PopupFeatures: dto.PopupFeatures{State: dto.PopupFeaturesNone}}, wantKind: dto.HostDecisionCreateBrowserWindow, wantReason: dto.HostDecisionReasonFloatingBrowserWindow}, | 53 | + | {name: "floating blank target navigates source", request: dto.NewBrowsingContextRequest{SourceHost: dto.SourceHostFloating, TargetURI: "https://example.com/docs", TargetFrameName: "_blank", TargetDisposition: dto.WindowDispositionNewTab, TriggerKind: dto.TriggerLinkNewPage, PopupFeatures: dto.PopupFeatures{State: dto.PopupFeaturesNone}}, wantKind: dto.HostDecisionNavigateSource, wantReason: dto.HostDecisionReasonFloatingFeaturelessBlank}, |
| 54 | - | {name: "floating featureless script opens browser window", request: dto.NewBrowsingContextRequest{SourceHost: dto.SourceHostFloating, TargetURI: "https://example.com/help", TargetDisposition: dto.WindowDispositionNewPopup, TriggerKind: dto.TriggerScriptWindowOpen, PopupFeatures: dto.PopupFeatures{State: dto.PopupFeaturesNone}}, wantKind: dto.HostDecisionCreateBrowserWindow, wantReason: dto.HostDecisionReasonFloatingBrowserWindow}, | 54 | + | {name: "floating featureless script navigates source", request: dto.NewBrowsingContextRequest{SourceHost: dto.SourceHostFloating, TargetURI: "https://example.com/help", TargetFrameName: "_blank", TargetDisposition: dto.WindowDispositionNewPopup, TriggerKind: dto.TriggerScriptWindowOpen, PopupFeatures: dto.PopupFeatures{State: dto.PopupFeaturesNone}}, wantKind: dto.HostDecisionNavigateSource, wantReason: dto.HostDecisionReasonFloatingFeaturelessBlank}, |
| 55 | {name: "floating featured script opens native popup", request: dto.NewBrowsingContextRequest{SourceHost: dto.SourceHostFloating, TargetURI: "https://example.com/help", TargetDisposition: dto.WindowDispositionNewPopup, TriggerKind: dto.TriggerScriptWindowOpen, PopupFeatures: dto.PopupFeatures{State: dto.PopupFeaturesSpecified, Width: 640, WidthSet: true}}, wantKind: dto.HostDecisionCreateNativePopup, wantReason: dto.HostDecisionReasonFloatingPopupFeatures}, | 55 | {name: "floating featured script opens native popup", request: dto.NewBrowsingContextRequest{SourceHost: dto.SourceHostFloating, TargetURI: "https://example.com/help", TargetDisposition: dto.WindowDispositionNewPopup, TriggerKind: dto.TriggerScriptWindowOpen, PopupFeatures: dto.PopupFeatures{State: dto.PopupFeaturesSpecified, Width: 640, WidthSet: true}}, wantKind: dto.HostDecisionCreateNativePopup, wantReason: dto.HostDecisionReasonFloatingPopupFeatures}, | ||
| 56 | {name: "floating unknown script waits", request: dto.NewBrowsingContextRequest{SourceHost: dto.SourceHostFloating, TargetURI: "https://example.com/help", TargetDisposition: dto.WindowDispositionNewPopup, TriggerKind: dto.TriggerScriptWindowOpen, PopupFeatures: dto.PopupFeatures{State: dto.PopupFeaturesUnknown}}, wantKind: dto.HostDecisionAwaitPopupFeatures, wantReason: dto.HostDecisionReasonFloatingFeaturesPending}, | 56 | {name: "floating unknown script waits", request: dto.NewBrowsingContextRequest{SourceHost: dto.SourceHostFloating, TargetURI: "https://example.com/help", TargetDisposition: dto.WindowDispositionNewPopup, TriggerKind: dto.TriggerScriptWindowOpen, PopupFeatures: dto.PopupFeatures{State: dto.PopupFeaturesUnknown}}, wantKind: dto.HostDecisionAwaitPopupFeatures, wantReason: dto.HostDecisionReasonFloatingFeaturesPending}, | ||
| 57 | {name: "floating named unknown waits", request: dto.NewBrowsingContextRequest{SourceHost: dto.SourceHostFloating, TargetURI: "https://example.com/help", TargetFrameName: "oauth", TargetDisposition: dto.WindowDispositionNewPopup, TriggerKind: dto.TriggerNamedTargetNavigation, PopupFeatures: dto.PopupFeatures{State: dto.PopupFeaturesUnknown}}, wantKind: dto.HostDecisionAwaitPopupFeatures, wantName: "oauth", wantReason: dto.HostDecisionReasonFloatingFeaturesPending}, | 57 | {name: "floating named unknown waits", request: dto.NewBrowsingContextRequest{SourceHost: dto.SourceHostFloating, TargetURI: "https://example.com/help", TargetFrameName: "oauth", TargetDisposition: dto.WindowDispositionNewPopup, TriggerKind: dto.TriggerNamedTargetNavigation, PopupFeatures: dto.PopupFeatures{State: dto.PopupFeaturesUnknown}}, wantKind: dto.HostDecisionAwaitPopupFeatures, wantName: "oauth", wantReason: dto.HostDecisionReasonFloatingFeaturesPending}, | ||
M internal/ui/coordinator/content/popup_deferred.go +10 -0
| @@ -16,6 +16,7 @@ func (pm *popupManager) awaitPopupFeatures( | |||
| 16 | 16 | hooks popupCoordinatorHooks, | |
| 17 | 17 | parentPaneID entity.PaneID, | |
| 18 | 18 | parentWebViewID port.WebViewID, | |
| 19 | + | parentWebView port.WebView, | |
| 19 | 20 | parentURIAtOpen string, | |
| 20 | 21 | req port.PopupRequest, | |
| 21 | 22 | decision dto.HostDecision, | |
| @@ -54,6 +55,7 @@ func (pm *popupManager) awaitPopupFeatures( | |||
| 54 | 55 | IsUserGesture: req.IsUserGesture, PopupType: DetectPopupType(req.FrameName), CreatedAt: time.Now(), | |
| 55 | 56 | }, | |
| 56 | 57 | Request: req, Decision: decision, StagingHost: staging, OwnerWindowID: ownerWindowID, | |
| 58 | + | ParentWebView: parentWebView, | |
| 57 | 59 | } | |
| 58 | 60 | pm.storeDeferredPopup(popupWV.ID(), pending) | |
| 59 | 61 | callbackCtx := logging.WithContext(context.Background(), *log) | |
| @@ -180,6 +182,14 @@ func (pm *popupManager) resolvePendingPopupFeatures( | |||
| 180 | 182 | ||
| 181 | 183 | transferred := false | |
| 182 | 184 | switch decision.Kind { | |
| 185 | + | case dto.HostDecisionNavigateSource: | |
| 186 | + | if pending.ParentWebView == nil { | |
| 187 | + | logBrowsingContextFailure(*logging.FromContext(ctx), normalized, decision, dto.BrowsingContextFailureHostUnavailable, nil) | |
| 188 | + | break | |
| 189 | + | } | |
| 190 | + | if err := pending.ParentWebView.LoadURI(ctx, request.TargetURI); err != nil { | |
| 191 | + | logBrowsingContextFailure(*logging.FromContext(ctx), normalized, decision, dto.BrowsingContextFailureHostFailed, err) | |
| 192 | + | } | |
| 183 | 193 | case dto.HostDecisionCreateBrowserWindow: | |
| 184 | 194 | transferred = pm.openExistingPopupInBrowserWindow( | |
| 185 | 195 | ctx, hooks, pending.ParentPaneID, pending.ParentWebViewID, | |
| @@ -16,6 +16,7 @@ func (pm *popupManager) awaitPopupFeatures( | |||||
| 16 | hooks popupCoordinatorHooks, | 16 | hooks popupCoordinatorHooks, | ||
| 17 | parentPaneID entity.PaneID, | 17 | parentPaneID entity.PaneID, | ||
| 18 | parentWebViewID port.WebViewID, | 18 | parentWebViewID port.WebViewID, | ||
| 19 | + | parentWebView port.WebView, | |||
| 19 | parentURIAtOpen string, | 20 | parentURIAtOpen string, | ||
| 20 | req port.PopupRequest, | 21 | req port.PopupRequest, | ||
| 21 | decision dto.HostDecision, | 22 | decision dto.HostDecision, | ||
| @@ -54,6 +55,7 @@ func (pm *popupManager) awaitPopupFeatures( | |||||
| 54 | IsUserGesture: req.IsUserGesture, PopupType: DetectPopupType(req.FrameName), CreatedAt: time.Now(), | 55 | IsUserGesture: req.IsUserGesture, PopupType: DetectPopupType(req.FrameName), CreatedAt: time.Now(), | ||
| 55 | }, | 56 | }, | ||
| 56 | Request: req, Decision: decision, StagingHost: staging, OwnerWindowID: ownerWindowID, | 57 | Request: req, Decision: decision, StagingHost: staging, OwnerWindowID: ownerWindowID, | ||
| 58 | + | ParentWebView: parentWebView, | |||
| 57 | } | 59 | } | ||
| 58 | pm.storeDeferredPopup(popupWV.ID(), pending) | 60 | pm.storeDeferredPopup(popupWV.ID(), pending) | ||
| 59 | callbackCtx := logging.WithContext(context.Background(), *log) | 61 | callbackCtx := logging.WithContext(context.Background(), *log) | ||
| @@ -180,6 +182,14 @@ func (pm *popupManager) resolvePendingPopupFeatures( | |||||
| 180 | 182 | ||||
| 181 | transferred := false | 183 | transferred := false | ||
| 182 | switch decision.Kind { | 184 | switch decision.Kind { | ||
| 185 | + | case dto.HostDecisionNavigateSource: | |||
| 186 | + | if pending.ParentWebView == nil { | |||
| 187 | + | logBrowsingContextFailure(*logging.FromContext(ctx), normalized, decision, dto.BrowsingContextFailureHostUnavailable, nil) | |||
| 188 | + | break | |||
| 189 | + | } | |||
| 190 | + | if err := pending.ParentWebView.LoadURI(ctx, request.TargetURI); err != nil { | |||
| 191 | + | logBrowsingContextFailure(*logging.FromContext(ctx), normalized, decision, dto.BrowsingContextFailureHostFailed, err) | |||
| 192 | + | } | |||
| 183 | case dto.HostDecisionCreateBrowserWindow: | 193 | case dto.HostDecisionCreateBrowserWindow: | ||
| 184 | transferred = pm.openExistingPopupInBrowserWindow( | 194 | transferred = pm.openExistingPopupInBrowserWindow( | ||
| 185 | ctx, hooks, pending.ParentPaneID, pending.ParentWebViewID, | 195 | ctx, hooks, pending.ParentPaneID, pending.ParentWebViewID, | ||
M internal/ui/coordinator/content/popup_manager.go +23 -1
| @@ -43,6 +43,7 @@ type deferredPendingPopup struct { | |||
| 43 | 43 | Decision dto.HostDecision | |
| 44 | 44 | StagingHost PopupStagingHost | |
| 45 | 45 | OwnerWindowID string | |
| 46 | + | ParentWebView port.WebView | |
| 46 | 47 | cleanupOnce sync.Once | |
| 47 | 48 | } | |
| 48 | 49 | ||
| @@ -590,12 +591,24 @@ func (pm *popupManager) handlePopupCreate( | |||
| 590 | 591 | decision.Kind = dto.HostDecisionCreatePane | |
| 591 | 592 | decision.ReuseContextName = "" | |
| 592 | 593 | decision.Reason = "named browsing context unavailable; creating replacement pane" | |
| 594 | + | case dto.HostDecisionNavigateSource: | |
| 595 | + | popupWV, err := pm.createPopupWebView(ctx, parentID, req.TargetURI, false) | |
| 596 | + | if err != nil { | |
| 597 | + | logBrowsingContextFailure(*log, request, decision, dto.BrowsingContextFailureHostFailed, err) | |
| 598 | + | return nil | |
| 599 | + | } | |
| 600 | + | pm.setBrowsingContextDecision(popupWV, decision) | |
| 601 | + | popupWV.Destroy() | |
| 602 | + | if err := parentWV.LoadURI(ctx, req.TargetURI); err != nil { | |
| 603 | + | logBrowsingContextFailure(*log, request, decision, dto.BrowsingContextFailureHostFailed, err) | |
| 604 | + | } | |
| 605 | + | return nil | |
| 593 | 606 | case dto.HostDecisionCreateBrowserWindow: | |
| 594 | 607 | return pm.openBrowserWindow(ctx, hooks, parentPaneID, parentID, req, decision, pm.readyOnCreate(req.Engine)) | |
| 595 | 608 | case dto.HostDecisionCreateNativePopup: | |
| 596 | 609 | return pm.openNativePopup(ctx, hooks, parentPaneID, parentID, parentURIAtOpen, req, decision) | |
| 597 | 610 | case dto.HostDecisionAwaitPopupFeatures: | |
| 598 | - | return pm.awaitPopupFeatures(ctx, hooks, parentPaneID, parentID, parentURIAtOpen, req, decision) | |
| 611 | + | return pm.awaitPopupFeatures(ctx, hooks, parentPaneID, parentID, parentWV, parentURIAtOpen, req, decision) | |
| 599 | 612 | case dto.HostDecisionCreatePane: | |
| 600 | 613 | // Continue below. | |
| 601 | 614 | break | |
| @@ -918,6 +931,15 @@ func (pm *popupManager) handleLinkMiddleClick( | |||
| 918 | 931 | Str("decision", string(decision.Kind)). | |
| 919 | 932 | Str("reason", decision.Reason). | |
| 920 | 933 | Msg("middle-click browsing context host decision") | |
| 934 | + | if decision.Kind == dto.HostDecisionNavigateSource { | |
| 935 | + | if err := parentWV.LoadURI(ctx, uri); err != nil { | |
| 936 | + | log.Error().Err(err). | |
| 937 | + | Str("uri", logging.TruncateURL(uri, logURLMaxLen)). | |
| 938 | + | Msg("failed to load URI in source floating pane") | |
| 939 | + | return false | |
| 940 | + | } | |
| 941 | + | return true | |
| 942 | + | } | |
| 921 | 943 | if decision.Kind == dto.HostDecisionCreateBrowserWindow { | |
| 922 | 944 | return pm.openMiddleClickBrowserWindow(ctx, hooks, parentPaneID, parentWV, uri, decision) | |
| 923 | 945 | } | |
| @@ -43,6 +43,7 @@ type deferredPendingPopup struct { | |||||
| 43 | Decision dto.HostDecision | 43 | Decision dto.HostDecision | ||
| 44 | StagingHost PopupStagingHost | 44 | StagingHost PopupStagingHost | ||
| 45 | OwnerWindowID string | 45 | OwnerWindowID string | ||
| 46 | + | ParentWebView port.WebView | |||
| 46 | cleanupOnce sync.Once | 47 | cleanupOnce sync.Once | ||
| 47 | } | 48 | } | ||
| 48 | 49 | ||||
| @@ -590,12 +591,24 @@ func (pm *popupManager) handlePopupCreate( | |||||
| 590 | decision.Kind = dto.HostDecisionCreatePane | 591 | decision.Kind = dto.HostDecisionCreatePane | ||
| 591 | decision.ReuseContextName = "" | 592 | decision.ReuseContextName = "" | ||
| 592 | decision.Reason = "named browsing context unavailable; creating replacement pane" | 593 | decision.Reason = "named browsing context unavailable; creating replacement pane" | ||
| 594 | + | case dto.HostDecisionNavigateSource: | |||
| 595 | + | popupWV, err := pm.createPopupWebView(ctx, parentID, req.TargetURI, false) | |||
| 596 | + | if err != nil { | |||
| 597 | + | logBrowsingContextFailure(*log, request, decision, dto.BrowsingContextFailureHostFailed, err) | |||
| 598 | + | return nil | |||
| 599 | + | } | |||
| 600 | + | pm.setBrowsingContextDecision(popupWV, decision) | |||
| 601 | + | popupWV.Destroy() | |||
| 602 | + | if err := parentWV.LoadURI(ctx, req.TargetURI); err != nil { | |||
| 603 | + | logBrowsingContextFailure(*log, request, decision, dto.BrowsingContextFailureHostFailed, err) | |||
| 604 | + | } | |||
| 605 | + | return nil | |||
| 593 | case dto.HostDecisionCreateBrowserWindow: | 606 | case dto.HostDecisionCreateBrowserWindow: | ||
| 594 | return pm.openBrowserWindow(ctx, hooks, parentPaneID, parentID, req, decision, pm.readyOnCreate(req.Engine)) | 607 | return pm.openBrowserWindow(ctx, hooks, parentPaneID, parentID, req, decision, pm.readyOnCreate(req.Engine)) | ||
| 595 | case dto.HostDecisionCreateNativePopup: | 608 | case dto.HostDecisionCreateNativePopup: | ||
| 596 | return pm.openNativePopup(ctx, hooks, parentPaneID, parentID, parentURIAtOpen, req, decision) | 609 | return pm.openNativePopup(ctx, hooks, parentPaneID, parentID, parentURIAtOpen, req, decision) | ||
| 597 | case dto.HostDecisionAwaitPopupFeatures: | 610 | case dto.HostDecisionAwaitPopupFeatures: | ||
| 598 | - | return pm.awaitPopupFeatures(ctx, hooks, parentPaneID, parentID, parentURIAtOpen, req, decision) | 611 | + | return pm.awaitPopupFeatures(ctx, hooks, parentPaneID, parentID, parentWV, parentURIAtOpen, req, decision) |
| 599 | case dto.HostDecisionCreatePane: | 612 | case dto.HostDecisionCreatePane: | ||
| 600 | // Continue below. | 613 | // Continue below. | ||
| 601 | break | 614 | break | ||
| @@ -918,6 +931,15 @@ func (pm *popupManager) handleLinkMiddleClick( | |||||
| 918 | Str("decision", string(decision.Kind)). | 931 | Str("decision", string(decision.Kind)). | ||
| 919 | Str("reason", decision.Reason). | 932 | Str("reason", decision.Reason). | ||
| 920 | Msg("middle-click browsing context host decision") | 933 | Msg("middle-click browsing context host decision") | ||
| 934 | + | if decision.Kind == dto.HostDecisionNavigateSource { | |||
| 935 | + | if err := parentWV.LoadURI(ctx, uri); err != nil { | |||
| 936 | + | log.Error().Err(err). | |||
| 937 | + | Str("uri", logging.TruncateURL(uri, logURLMaxLen)). | |||
| 938 | + | Msg("failed to load URI in source floating pane") | |||
| 939 | + | return false | |||
| 940 | + | } | |||
| 941 | + | return true | |||
| 942 | + | } | |||
| 921 | if decision.Kind == dto.HostDecisionCreateBrowserWindow { | 943 | if decision.Kind == dto.HostDecisionCreateBrowserWindow { | ||
| 922 | return pm.openMiddleClickBrowserWindow(ctx, hooks, parentPaneID, parentWV, uri, decision) | 944 | return pm.openMiddleClickBrowserWindow(ctx, hooks, parentPaneID, parentWV, uri, decision) | ||
| 923 | } | 945 | } | ||
M internal/ui/coordinator/content/popup_test.go +56 -44
| @@ -155,15 +155,14 @@ func newPopupCreateCoordinatorForTest(t *testing.T, popupID port.WebViewID) (con | |||
| 155 | 155 | return ctx, parentPaneID, parentWV, popupWV, c | |
| 156 | 156 | } | |
| 157 | 157 | ||
| 158 | - | func TestPopupFloatingBlankOpensBrowserWindowWhenWorkspacePopupsDisabled(t *testing.T) { | |
| 158 | + | func TestPopupFloatingWebKitLinkBlankNavigatesSourceAndDestroysCandidateExactlyOnce(t *testing.T) { | |
| 159 | 159 | ctx := context.Background() | |
| 160 | 160 | parentPaneID := entity.PaneID("floating") | |
| 161 | 161 | parentWV := mocks.NewMockWebView(t) | |
| 162 | 162 | parentWV.EXPECT().ID().Return(port.WebViewID(101)).Once() | |
| 163 | - | popupWV := mocks.NewMockWebView(t) | |
| 164 | - | popupWV.EXPECT().ID().Return(port.WebViewID(201)).Maybe() | |
| 165 | - | popupWV.EXPECT().Generation().Return(uint64(1)).Maybe() | |
| 166 | - | popupWV.EXPECT().SetCallbacks(mock.Anything).Once() | |
| 163 | + | parentWV.EXPECT().LoadURI(mock.Anything, "https://example.com/new").Return(nil).Once() | |
| 164 | + | popupWV := &popupNavigationWebViewStub{MockWebView: mocks.NewMockWebView(t)} | |
| 165 | + | popupWV.EXPECT().Destroy().Once() | |
| 167 | 166 | ||
| 168 | 167 | factory := mocks.NewMockWebViewFactory(t) | |
| 169 | 168 | factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(popupWV, nil).Once() | |
| @@ -174,39 +173,32 @@ func TestPopupFloatingBlankOpensBrowserWindowWhenWorkspacePopupsDisabled(t *test | |||
| 174 | 173 | t.Fatal("floating request must not use workspace insertion") | |
| 175 | 174 | return nil | |
| 176 | 175 | }) | |
| 177 | - | called := false | |
| 178 | - | c.SetOnOpenBrowserWindow(func(_ context.Context, input BrowserWindowInput) (BrowserWindowResult, error) { | |
| 179 | - | called = true | |
| 180 | - | assert.Equal(t, parentPaneID, input.ParentPaneID) | |
| 181 | - | assert.Same(t, popupWV, input.PopupWebView) | |
| 182 | - | assert.Equal(t, "https://example.com/new", input.TargetURI) | |
| 183 | - | return BrowserWindowResult{WindowID: "detached-window"}, nil | |
| 176 | + | c.SetOnOpenBrowserWindow(func(context.Context, BrowserWindowInput) (BrowserWindowResult, error) { | |
| 177 | + | t.Fatal("featureless _blank must not open a browser window") | |
| 178 | + | return BrowserWindowResult{}, nil | |
| 184 | 179 | }) | |
| 185 | 180 | ||
| 186 | 181 | got := c.handlePopupCreate(ctx, parentPaneID, parentWV, port.PopupRequest{ | |
| 187 | - | Engine: dto.BrowserEngineCEF, | |
| 182 | + | Engine: dto.BrowserEngineWebKit, | |
| 188 | 183 | TargetURI: "https://example.com/new", | |
| 189 | 184 | FrameName: "_blank", | |
| 190 | 185 | TargetDisposition: dto.WindowDispositionNewTab, | |
| 191 | 186 | PopupFeatures: dto.PopupFeatures{State: dto.PopupFeaturesNone}, | |
| 192 | 187 | }) | |
| 193 | 188 | ||
| 194 | - | assert.Same(t, popupWV, got) | |
| 195 | - | assert.True(t, called) | |
| 189 | + | assert.Nil(t, got, "featureless link popup must be discarded after source navigation") | |
| 190 | + | decision, ok := popupWV.BrowsingContextHostDecision() | |
| 191 | + | require.True(t, ok) | |
| 192 | + | assert.Equal(t, dto.HostDecisionNavigateSource, decision.Kind) | |
| 196 | 193 | } | |
| 197 | 194 | ||
| 198 | - | func TestPopupFloatingMiddleClickOpensBrowserWindowWhenWorkspacePopupsDisabled(t *testing.T) { | |
| 195 | + | func TestPopupFloatingMiddleClickNavigatesSourceWhenWorkspacePopupsDisabled(t *testing.T) { | |
| 199 | 196 | ctx := context.Background() | |
| 200 | 197 | parentPaneID := entity.PaneID("floating") | |
| 201 | 198 | parentWV := mocks.NewMockWebView(t) | |
| 202 | - | parentWV.EXPECT().ID().Return(port.WebViewID(101)).Maybe() | |
| 203 | - | popupWV := mocks.NewMockWebView(t) | |
| 204 | - | popupWV.EXPECT().ID().Return(port.WebViewID(202)).Maybe() | |
| 205 | - | popupWV.EXPECT().Generation().Return(uint64(1)).Maybe() | |
| 206 | - | popupWV.EXPECT().SetCallbacks(mock.Anything).Once() | |
| 207 | - | popupWV.EXPECT().LoadURI(mock.Anything, "https://example.com/middle").Return(nil).Once() | |
| 199 | + | parentWV.EXPECT().ID().Return(port.WebViewID(101)).Once() | |
| 200 | + | parentWV.EXPECT().LoadURI(mock.Anything, "https://example.com/middle").Return(nil).Once() | |
| 208 | 201 | factory := mocks.NewMockWebViewFactory(t) | |
| 209 | - | factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(popupWV, nil).Once() | |
| 210 | 202 | c := &Coordinator{webViews: map[entity.PaneID]port.WebView{parentPaneID: parentWV}, popups: newPopupManager()} | |
| 211 | 203 | c.SetPopupConfig(factory, &entity.BrowsingContextConfig{OpenInNewPane: false}, func() string { return "detached-pane" }) | |
| 212 | 204 | c.SetPopupSourceHostResolver(func(entity.PaneID) dto.SourceHostKind { return dto.SourceHostFloating }) | |
| @@ -214,9 +206,9 @@ func TestPopupFloatingMiddleClickOpensBrowserWindowWhenWorkspacePopupsDisabled(t | |||
| 214 | 206 | t.Fatal("floating middle-click must not use workspace insertion") | |
| 215 | 207 | return nil | |
| 216 | 208 | }) | |
| 217 | - | c.SetOnOpenBrowserWindow(func(_ context.Context, input BrowserWindowInput) (BrowserWindowResult, error) { | |
| 218 | - | assert.Same(t, popupWV, input.PopupWebView) | |
| 219 | - | return BrowserWindowResult{WindowID: "detached-window"}, nil | |
| 209 | + | c.SetOnOpenBrowserWindow(func(context.Context, BrowserWindowInput) (BrowserWindowResult, error) { | |
| 210 | + | t.Fatal("floating middle-click must not open a browser window") | |
| 211 | + | return BrowserWindowResult{}, nil | |
| 220 | 212 | }) | |
| 221 | 213 | ||
| 222 | 214 | assert.True(t, c.handleLinkMiddleClick(ctx, parentPaneID, "https://example.com/middle")) | |
| @@ -342,8 +334,8 @@ func TestPopupFloatingBrowserHostFailureDestroysWebViewExactlyOnce(t *testing.T) | |||
| 342 | 334 | }) | |
| 343 | 335 | ||
| 344 | 336 | got := c.handlePopupCreate(context.Background(), "floating", parentWV, port.PopupRequest{ | |
| 345 | - | Engine: dto.BrowserEngineCEF, TargetURI: "https://example.com/new", FrameName: "_blank", | |
| 346 | - | TargetDisposition: dto.WindowDispositionNewTab, PopupFeatures: dto.PopupFeatures{State: dto.PopupFeaturesNone}, | |
| 337 | + | Engine: dto.BrowserEngineCEF, TargetURI: "https://example.com/new", FrameName: "shared", | |
| 338 | + | TargetDisposition: dto.WindowDispositionNewPopup, PopupFeatures: dto.PopupFeatures{State: dto.PopupFeaturesNone}, | |
| 347 | 339 | }) | |
| 348 | 340 | ||
| 349 | 341 | assert.Nil(t, got) | |
| @@ -1233,14 +1225,14 @@ func TestPendingPopups_ConcurrentDeleteAndRead(t *testing.T) { | |||
| 1233 | 1225 | assert.Empty(t, c.popups.pendingPopups, "all preloaded popups should have been deleted") | |
| 1234 | 1226 | } | |
| 1235 | 1227 | ||
| 1236 | - | func TestPopupDeferredFeaturelessDetachesBeforeBrowserHost(t *testing.T) { | |
| 1228 | + | func TestPopupDeferredFeaturelessBlankNavigatesSourceAndCleansStagingExactlyOnce(t *testing.T) { | |
| 1237 | 1229 | ctx := context.Background() | |
| 1238 | 1230 | parent := mocks.NewMockWebView(t) | |
| 1239 | 1231 | parent.EXPECT().ID().Return(port.WebViewID(101)).Once() | |
| 1232 | + | parent.EXPECT().LoadURI(mock.Anything, "https://example.com/open").Return(nil).Once() | |
| 1240 | 1233 | popup := &deferredPopupWebViewStub{MockWebView: mocks.NewMockWebView(t), features: dto.PopupFeatures{State: dto.PopupFeaturesNone}} | |
| 1241 | 1234 | popup.EXPECT().ID().Return(port.WebViewID(201)).Maybe() | |
| 1242 | - | popup.EXPECT().Generation().Return(uint64(1)).Maybe() | |
| 1243 | - | popup.EXPECT().SetCallbacks(mock.Anything).Once() | |
| 1235 | + | popup.EXPECT().Destroy().Once() | |
| 1244 | 1236 | factory := mocks.NewMockWebViewFactory(t) | |
| 1245 | 1237 | factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(popup, nil).Once() | |
| 1246 | 1238 | staging := &popupStagingHostStub{} | |
| @@ -1251,24 +1243,26 @@ func TestPopupDeferredFeaturelessDetachesBeforeBrowserHost(t *testing.T) { | |||
| 1251 | 1243 | staging.attached = true | |
| 1252 | 1244 | return staging, nil | |
| 1253 | 1245 | }) | |
| 1254 | - | c.SetOnOpenBrowserWindow(func(_ context.Context, input BrowserWindowInput) (BrowserWindowResult, error) { | |
| 1255 | - | assert.True(t, staging.detached, "staging must detach before final host") | |
| 1256 | - | assert.True(t, input.Ready) | |
| 1257 | - | assert.Same(t, popup, input.PopupWebView) | |
| 1258 | - | return BrowserWindowResult{WindowID: "browser"}, nil | |
| 1246 | + | c.SetOnOpenBrowserWindow(func(context.Context, BrowserWindowInput) (BrowserWindowResult, error) { | |
| 1247 | + | t.Fatal("featureless _blank must not open a browser window") | |
| 1248 | + | return BrowserWindowResult{}, nil | |
| 1259 | 1249 | }) | |
| 1260 | 1250 | c.SetOnOpenNativePopup(func(context.Context, NativePopupInput) error { | |
| 1261 | - | t.Fatal("featureless popup must not use native host") | |
| 1251 | + | t.Fatal("featureless _blank must not use native host") | |
| 1262 | 1252 | return nil | |
| 1263 | 1253 | }) | |
| 1264 | 1254 | ||
| 1265 | - | got := c.handlePopupCreate(ctx, "floating", parent, port.PopupRequest{Engine: dto.BrowserEngineWebKit, TargetURI: "https://example.com/open", PopupFeatures: dto.PopupFeatures{State: dto.PopupFeaturesUnknown}}) | |
| 1255 | + | got := c.handlePopupCreate(ctx, "floating", parent, port.PopupRequest{ | |
| 1256 | + | Engine: dto.BrowserEngineWebKit, TargetURI: "https://example.com/open", FrameName: "_blank", | |
| 1257 | + | TargetDisposition: dto.WindowDispositionNewPopup, PopupFeatures: dto.PopupFeatures{State: dto.PopupFeaturesUnknown}, | |
| 1258 | + | }) | |
| 1266 | 1259 | require.Same(t, popup, got) | |
| 1267 | 1260 | assert.True(t, staging.attached) | |
| 1268 | 1261 | assert.False(t, staging.detached) | |
| 1269 | 1262 | popup.ready() | |
| 1263 | + | popup.ready() | |
| 1270 | 1264 | assert.True(t, staging.detached) | |
| 1271 | - | assert.Zero(t, staging.destroyCalls) | |
| 1265 | + | assert.Equal(t, 1, staging.destroyCalls) | |
| 1272 | 1266 | } | |
| 1273 | 1267 | ||
| 1274 | 1268 | func TestPopupDeferredFeaturedDetachesBeforeNativeHost(t *testing.T) { | |
| @@ -1294,8 +1288,14 @@ func TestPopupDeferredFeaturedDetachesBeforeNativeHost(t *testing.T) { | |||
| 1294 | 1288 | return nil | |
| 1295 | 1289 | }) | |
| 1296 | 1290 | ||
| 1297 | - | require.Same(t, popup, c.handlePopupCreate(ctx, "floating", parent, port.PopupRequest{Engine: dto.BrowserEngineWebKit, TargetURI: "https://example.com/open", PopupFeatures: dto.PopupFeatures{State: dto.PopupFeaturesUnknown}})) | |
| 1291 | + | require.Same(t, popup, c.handlePopupCreate(ctx, "floating", parent, port.PopupRequest{ | |
| 1292 | + | Engine: dto.BrowserEngineWebKit, TargetURI: "https://example.com/open", FrameName: "_blank", | |
| 1293 | + | TargetDisposition: dto.WindowDispositionNewPopup, PopupFeatures: dto.PopupFeatures{State: dto.PopupFeaturesUnknown}, | |
| 1294 | + | })) | |
| 1298 | 1295 | popup.ready() | |
| 1296 | + | decision, ok := popup.BrowsingContextHostDecision() | |
| 1297 | + | require.True(t, ok) | |
| 1298 | + | assert.Equal(t, dto.HostDecisionCreateNativePopup, decision.Kind) | |
| 1299 | 1299 | } | |
| 1300 | 1300 | ||
| 1301 | 1301 | func TestPopupDeferredNamedReuseDestroysNewlyStagedWebViewExactlyOnce(t *testing.T) { | |
| @@ -1393,7 +1393,10 @@ func TestPopupDeferredBrowserHostFailureCleansUpExactlyOnce(t *testing.T) { | |||
| 1393 | 1393 | return BrowserWindowResult{}, errors.New("host failed") | |
| 1394 | 1394 | }) | |
| 1395 | 1395 | ||
| 1396 | - | 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}})) | |
| 1396 | + | require.Same(t, popup, c.handlePopupCreate(context.Background(), "floating", parent, port.PopupRequest{ | |
| 1397 | + | Engine: dto.BrowserEngineWebKit, TargetURI: "https://example.com/open", FrameName: "shared", | |
| 1398 | + | TargetDisposition: dto.WindowDispositionNewPopup, PopupFeatures: dto.PopupFeatures{State: dto.PopupFeaturesUnknown}, | |
| 1399 | + | })) | |
| 1397 | 1400 | popup.ready() | |
| 1398 | 1401 | popup.ready() | |
| 1399 | 1402 | assert.True(t, staging.detached) | |
| @@ -1497,9 +1500,11 @@ func TestPopupDeferredUnknownAndCloseCleanupExactlyOnce(t *testing.T) { | |||
| 1497 | 1500 | // deferredPopupWebViewStub exposes the optional late-feature and lifecycle ports. | |
| 1498 | 1501 | type deferredPopupWebViewStub struct { | |
| 1499 | 1502 | *mocks.MockWebView | |
| 1500 | - | features dto.PopupFeatures | |
| 1501 | - | onReady func() | |
| 1502 | - | onClose func() | |
| 1503 | + | features dto.PopupFeatures | |
| 1504 | + | onReady func() | |
| 1505 | + | onClose func() | |
| 1506 | + | browsingContextDecision dto.HostDecision | |
| 1507 | + | hasBrowsingContextDecision bool | |
| 1503 | 1508 | } | |
| 1504 | 1509 | ||
| 1505 | 1510 | func (s *deferredPopupWebViewStub) ResolvePopupFeatures() dto.PopupFeatures { return s.features } | |
| @@ -1507,6 +1512,13 @@ func (s *deferredPopupWebViewStub) PrimePopupNavigation(string) {} | |||
| 1507 | 1512 | func (s *deferredPopupWebViewStub) SetOnReadyToShow(fn func()) { s.onReady = fn } | |
| 1508 | 1513 | func (s *deferredPopupWebViewStub) SetOnClose(fn func()) { s.onClose = fn } | |
| 1509 | 1514 | func (s *deferredPopupWebViewStub) Show() {} | |
| 1515 | + | func (s *deferredPopupWebViewStub) SetBrowsingContextHostDecision(decision dto.HostDecision) { | |
| 1516 | + | s.browsingContextDecision = decision | |
| 1517 | + | s.hasBrowsingContextDecision = true | |
| 1518 | + | } | |
| 1519 | + | func (s *deferredPopupWebViewStub) BrowsingContextHostDecision() (dto.HostDecision, bool) { | |
| 1520 | + | return s.browsingContextDecision, s.hasBrowsingContextDecision | |
| 1521 | + | } | |
| 1510 | 1522 | func (s *deferredPopupWebViewStub) ready() { | |
| 1511 | 1523 | if s.onReady != nil { | |
| 1512 | 1524 | s.onReady() | |
| @@ -155,15 +155,14 @@ func newPopupCreateCoordinatorForTest(t *testing.T, popupID port.WebViewID) (con | |||||
| 155 | return ctx, parentPaneID, parentWV, popupWV, c | 155 | return ctx, parentPaneID, parentWV, popupWV, c | ||
| 156 | } | 156 | } | ||
| 157 | 157 | ||||
| 158 | - | func TestPopupFloatingBlankOpensBrowserWindowWhenWorkspacePopupsDisabled(t *testing.T) { | 158 | + | func TestPopupFloatingWebKitLinkBlankNavigatesSourceAndDestroysCandidateExactlyOnce(t *testing.T) { |
| 159 | ctx := context.Background() | 159 | ctx := context.Background() | ||
| 160 | parentPaneID := entity.PaneID("floating") | 160 | parentPaneID := entity.PaneID("floating") | ||
| 161 | parentWV := mocks.NewMockWebView(t) | 161 | parentWV := mocks.NewMockWebView(t) | ||
| 162 | parentWV.EXPECT().ID().Return(port.WebViewID(101)).Once() | 162 | parentWV.EXPECT().ID().Return(port.WebViewID(101)).Once() | ||
| 163 | - | popupWV := mocks.NewMockWebView(t) | 163 | + | parentWV.EXPECT().LoadURI(mock.Anything, "https://example.com/new").Return(nil).Once() |
| 164 | - | popupWV.EXPECT().ID().Return(port.WebViewID(201)).Maybe() | 164 | + | popupWV := &popupNavigationWebViewStub{MockWebView: mocks.NewMockWebView(t)} |
| 165 | - | popupWV.EXPECT().Generation().Return(uint64(1)).Maybe() | 165 | + | popupWV.EXPECT().Destroy().Once() |
| 166 | - | popupWV.EXPECT().SetCallbacks(mock.Anything).Once() | |||
| 167 | 166 | ||||
| 168 | factory := mocks.NewMockWebViewFactory(t) | 167 | factory := mocks.NewMockWebViewFactory(t) | ||
| 169 | factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(popupWV, nil).Once() | 168 | factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(popupWV, nil).Once() | ||
| @@ -174,39 +173,32 @@ func TestPopupFloatingBlankOpensBrowserWindowWhenWorkspacePopupsDisabled(t *test | |||||
| 174 | t.Fatal("floating request must not use workspace insertion") | 173 | t.Fatal("floating request must not use workspace insertion") | ||
| 175 | return nil | 174 | return nil | ||
| 176 | }) | 175 | }) | ||
| 177 | - | called := false | 176 | + | c.SetOnOpenBrowserWindow(func(context.Context, BrowserWindowInput) (BrowserWindowResult, error) { |
| 178 | - | c.SetOnOpenBrowserWindow(func(_ context.Context, input BrowserWindowInput) (BrowserWindowResult, error) { | 177 | + | t.Fatal("featureless _blank must not open a browser window") |
| 179 | - | called = true | 178 | + | return BrowserWindowResult{}, nil |
| 180 | - | assert.Equal(t, parentPaneID, input.ParentPaneID) | |||
| 181 | - | assert.Same(t, popupWV, input.PopupWebView) | |||
| 182 | - | assert.Equal(t, "https://example.com/new", input.TargetURI) | |||
| 183 | - | return BrowserWindowResult{WindowID: "detached-window"}, nil | |||
| 184 | }) | 179 | }) | ||
| 185 | 180 | ||||
| 186 | got := c.handlePopupCreate(ctx, parentPaneID, parentWV, port.PopupRequest{ | 181 | got := c.handlePopupCreate(ctx, parentPaneID, parentWV, port.PopupRequest{ | ||
| 187 | - | Engine: dto.BrowserEngineCEF, | 182 | + | Engine: dto.BrowserEngineWebKit, |
| 188 | TargetURI: "https://example.com/new", | 183 | TargetURI: "https://example.com/new", | ||
| 189 | FrameName: "_blank", | 184 | FrameName: "_blank", | ||
| 190 | TargetDisposition: dto.WindowDispositionNewTab, | 185 | TargetDisposition: dto.WindowDispositionNewTab, | ||
| 191 | PopupFeatures: dto.PopupFeatures{State: dto.PopupFeaturesNone}, | 186 | PopupFeatures: dto.PopupFeatures{State: dto.PopupFeaturesNone}, | ||
| 192 | }) | 187 | }) | ||
| 193 | 188 | ||||
| 194 | - | assert.Same(t, popupWV, got) | 189 | + | assert.Nil(t, got, "featureless link popup must be discarded after source navigation") |
| 195 | - | assert.True(t, called) | 190 | + | decision, ok := popupWV.BrowsingContextHostDecision() |
| 191 | + | require.True(t, ok) | |||
| 192 | + | assert.Equal(t, dto.HostDecisionNavigateSource, decision.Kind) | |||
| 196 | } | 193 | } | ||
| 197 | 194 | ||||
| 198 | - | func TestPopupFloatingMiddleClickOpensBrowserWindowWhenWorkspacePopupsDisabled(t *testing.T) { | 195 | + | func TestPopupFloatingMiddleClickNavigatesSourceWhenWorkspacePopupsDisabled(t *testing.T) { |
| 199 | ctx := context.Background() | 196 | ctx := context.Background() | ||
| 200 | parentPaneID := entity.PaneID("floating") | 197 | parentPaneID := entity.PaneID("floating") | ||
| 201 | parentWV := mocks.NewMockWebView(t) | 198 | parentWV := mocks.NewMockWebView(t) | ||
| 202 | - | parentWV.EXPECT().ID().Return(port.WebViewID(101)).Maybe() | 199 | + | parentWV.EXPECT().ID().Return(port.WebViewID(101)).Once() |
| 203 | - | popupWV := mocks.NewMockWebView(t) | 200 | + | parentWV.EXPECT().LoadURI(mock.Anything, "https://example.com/middle").Return(nil).Once() |
| 204 | - | popupWV.EXPECT().ID().Return(port.WebViewID(202)).Maybe() | |||
| 205 | - | popupWV.EXPECT().Generation().Return(uint64(1)).Maybe() | |||
| 206 | - | popupWV.EXPECT().SetCallbacks(mock.Anything).Once() | |||
| 207 | - | popupWV.EXPECT().LoadURI(mock.Anything, "https://example.com/middle").Return(nil).Once() | |||
| 208 | factory := mocks.NewMockWebViewFactory(t) | 201 | factory := mocks.NewMockWebViewFactory(t) | ||
| 209 | - | factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(popupWV, nil).Once() | |||
| 210 | c := &Coordinator{webViews: map[entity.PaneID]port.WebView{parentPaneID: parentWV}, popups: newPopupManager()} | 202 | c := &Coordinator{webViews: map[entity.PaneID]port.WebView{parentPaneID: parentWV}, popups: newPopupManager()} | ||
| 211 | c.SetPopupConfig(factory, &entity.BrowsingContextConfig{OpenInNewPane: false}, func() string { return "detached-pane" }) | 203 | c.SetPopupConfig(factory, &entity.BrowsingContextConfig{OpenInNewPane: false}, func() string { return "detached-pane" }) | ||
| 212 | c.SetPopupSourceHostResolver(func(entity.PaneID) dto.SourceHostKind { return dto.SourceHostFloating }) | 204 | c.SetPopupSourceHostResolver(func(entity.PaneID) dto.SourceHostKind { return dto.SourceHostFloating }) | ||
| @@ -214,9 +206,9 @@ func TestPopupFloatingMiddleClickOpensBrowserWindowWhenWorkspacePopupsDisabled(t | |||||
| 214 | t.Fatal("floating middle-click must not use workspace insertion") | 206 | t.Fatal("floating middle-click must not use workspace insertion") | ||
| 215 | return nil | 207 | return nil | ||
| 216 | }) | 208 | }) | ||
| 217 | - | c.SetOnOpenBrowserWindow(func(_ context.Context, input BrowserWindowInput) (BrowserWindowResult, error) { | 209 | + | c.SetOnOpenBrowserWindow(func(context.Context, BrowserWindowInput) (BrowserWindowResult, error) { |
| 218 | - | assert.Same(t, popupWV, input.PopupWebView) | 210 | + | t.Fatal("floating middle-click must not open a browser window") |
| 219 | - | return BrowserWindowResult{WindowID: "detached-window"}, nil | 211 | + | return BrowserWindowResult{}, nil |
| 220 | }) | 212 | }) | ||
| 221 | 213 | ||||
| 222 | assert.True(t, c.handleLinkMiddleClick(ctx, parentPaneID, "https://example.com/middle")) | 214 | assert.True(t, c.handleLinkMiddleClick(ctx, parentPaneID, "https://example.com/middle")) | ||
| @@ -342,8 +334,8 @@ func TestPopupFloatingBrowserHostFailureDestroysWebViewExactlyOnce(t *testing.T) | |||||
| 342 | }) | 334 | }) | ||
| 343 | 335 | ||||
| 344 | got := c.handlePopupCreate(context.Background(), "floating", parentWV, port.PopupRequest{ | 336 | got := c.handlePopupCreate(context.Background(), "floating", parentWV, port.PopupRequest{ | ||
| 345 | - | Engine: dto.BrowserEngineCEF, TargetURI: "https://example.com/new", FrameName: "_blank", | 337 | + | Engine: dto.BrowserEngineCEF, TargetURI: "https://example.com/new", FrameName: "shared", |
| 346 | - | TargetDisposition: dto.WindowDispositionNewTab, PopupFeatures: dto.PopupFeatures{State: dto.PopupFeaturesNone}, | 338 | + | TargetDisposition: dto.WindowDispositionNewPopup, PopupFeatures: dto.PopupFeatures{State: dto.PopupFeaturesNone}, |
| 347 | }) | 339 | }) | ||
| 348 | 340 | ||||
| 349 | assert.Nil(t, got) | 341 | assert.Nil(t, got) | ||
| @@ -1233,14 +1225,14 @@ func TestPendingPopups_ConcurrentDeleteAndRead(t *testing.T) { | |||||
| 1233 | assert.Empty(t, c.popups.pendingPopups, "all preloaded popups should have been deleted") | 1225 | assert.Empty(t, c.popups.pendingPopups, "all preloaded popups should have been deleted") | ||
| 1234 | } | 1226 | } | ||
| 1235 | 1227 | ||||
| 1236 | - | func TestPopupDeferredFeaturelessDetachesBeforeBrowserHost(t *testing.T) { | 1228 | + | func TestPopupDeferredFeaturelessBlankNavigatesSourceAndCleansStagingExactlyOnce(t *testing.T) { |
| 1237 | ctx := context.Background() | 1229 | ctx := context.Background() | ||
| 1238 | parent := mocks.NewMockWebView(t) | 1230 | parent := mocks.NewMockWebView(t) | ||
| 1239 | parent.EXPECT().ID().Return(port.WebViewID(101)).Once() | 1231 | parent.EXPECT().ID().Return(port.WebViewID(101)).Once() | ||
| 1232 | + | parent.EXPECT().LoadURI(mock.Anything, "https://example.com/open").Return(nil).Once() | |||
| 1240 | popup := &deferredPopupWebViewStub{MockWebView: mocks.NewMockWebView(t), features: dto.PopupFeatures{State: dto.PopupFeaturesNone}} | 1233 | popup := &deferredPopupWebViewStub{MockWebView: mocks.NewMockWebView(t), features: dto.PopupFeatures{State: dto.PopupFeaturesNone}} | ||
| 1241 | popup.EXPECT().ID().Return(port.WebViewID(201)).Maybe() | 1234 | popup.EXPECT().ID().Return(port.WebViewID(201)).Maybe() | ||
| 1242 | - | popup.EXPECT().Generation().Return(uint64(1)).Maybe() | 1235 | + | popup.EXPECT().Destroy().Once() |
| 1243 | - | popup.EXPECT().SetCallbacks(mock.Anything).Once() | |||
| 1244 | factory := mocks.NewMockWebViewFactory(t) | 1236 | factory := mocks.NewMockWebViewFactory(t) | ||
| 1245 | factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(popup, nil).Once() | 1237 | factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(popup, nil).Once() | ||
| 1246 | staging := &popupStagingHostStub{} | 1238 | staging := &popupStagingHostStub{} | ||
| @@ -1251,24 +1243,26 @@ func TestPopupDeferredFeaturelessDetachesBeforeBrowserHost(t *testing.T) { | |||||
| 1251 | staging.attached = true | 1243 | staging.attached = true | ||
| 1252 | return staging, nil | 1244 | return staging, nil | ||
| 1253 | }) | 1245 | }) | ||
| 1254 | - | c.SetOnOpenBrowserWindow(func(_ context.Context, input BrowserWindowInput) (BrowserWindowResult, error) { | 1246 | + | c.SetOnOpenBrowserWindow(func(context.Context, BrowserWindowInput) (BrowserWindowResult, error) { |
| 1255 | - | assert.True(t, staging.detached, "staging must detach before final host") | 1247 | + | t.Fatal("featureless _blank must not open a browser window") |
| 1256 | - | assert.True(t, input.Ready) | 1248 | + | return BrowserWindowResult{}, nil |
| 1257 | - | assert.Same(t, popup, input.PopupWebView) | |||
| 1258 | - | return BrowserWindowResult{WindowID: "browser"}, nil | |||
| 1259 | }) | 1249 | }) | ||
| 1260 | c.SetOnOpenNativePopup(func(context.Context, NativePopupInput) error { | 1250 | c.SetOnOpenNativePopup(func(context.Context, NativePopupInput) error { | ||
| 1261 | - | t.Fatal("featureless popup must not use native host") | 1251 | + | t.Fatal("featureless _blank must not use native host") |
| 1262 | return nil | 1252 | return nil | ||
| 1263 | }) | 1253 | }) | ||
| 1264 | 1254 | ||||
| 1265 | - | got := c.handlePopupCreate(ctx, "floating", parent, port.PopupRequest{Engine: dto.BrowserEngineWebKit, TargetURI: "https://example.com/open", PopupFeatures: dto.PopupFeatures{State: dto.PopupFeaturesUnknown}}) | 1255 | + | got := c.handlePopupCreate(ctx, "floating", parent, port.PopupRequest{ |
| 1256 | + | Engine: dto.BrowserEngineWebKit, TargetURI: "https://example.com/open", FrameName: "_blank", | |||
| 1257 | + | TargetDisposition: dto.WindowDispositionNewPopup, PopupFeatures: dto.PopupFeatures{State: dto.PopupFeaturesUnknown}, | |||
| 1258 | + | }) | |||
| 1266 | require.Same(t, popup, got) | 1259 | require.Same(t, popup, got) | ||
| 1267 | assert.True(t, staging.attached) | 1260 | assert.True(t, staging.attached) | ||
| 1268 | assert.False(t, staging.detached) | 1261 | assert.False(t, staging.detached) | ||
| 1269 | popup.ready() | 1262 | popup.ready() | ||
| 1263 | + | popup.ready() | |||
| 1270 | assert.True(t, staging.detached) | 1264 | assert.True(t, staging.detached) | ||
| 1271 | - | assert.Zero(t, staging.destroyCalls) | 1265 | + | assert.Equal(t, 1, staging.destroyCalls) |
| 1272 | } | 1266 | } | ||
| 1273 | 1267 | ||||
| 1274 | func TestPopupDeferredFeaturedDetachesBeforeNativeHost(t *testing.T) { | 1268 | func TestPopupDeferredFeaturedDetachesBeforeNativeHost(t *testing.T) { | ||
| @@ -1294,8 +1288,14 @@ func TestPopupDeferredFeaturedDetachesBeforeNativeHost(t *testing.T) { | |||||
| 1294 | return nil | 1288 | return nil | ||
| 1295 | }) | 1289 | }) | ||
| 1296 | 1290 | ||||
| 1297 | - | require.Same(t, popup, c.handlePopupCreate(ctx, "floating", parent, port.PopupRequest{Engine: dto.BrowserEngineWebKit, TargetURI: "https://example.com/open", PopupFeatures: dto.PopupFeatures{State: dto.PopupFeaturesUnknown}})) | 1291 | + | require.Same(t, popup, c.handlePopupCreate(ctx, "floating", parent, port.PopupRequest{ |
| 1292 | + | Engine: dto.BrowserEngineWebKit, TargetURI: "https://example.com/open", FrameName: "_blank", | |||
| 1293 | + | TargetDisposition: dto.WindowDispositionNewPopup, PopupFeatures: dto.PopupFeatures{State: dto.PopupFeaturesUnknown}, | |||
| 1294 | + | })) | |||
| 1298 | popup.ready() | 1295 | popup.ready() | ||
| 1296 | + | decision, ok := popup.BrowsingContextHostDecision() | |||
| 1297 | + | require.True(t, ok) | |||
| 1298 | + | assert.Equal(t, dto.HostDecisionCreateNativePopup, decision.Kind) | |||
| 1299 | } | 1299 | } | ||
| 1300 | 1300 | ||||
| 1301 | func TestPopupDeferredNamedReuseDestroysNewlyStagedWebViewExactlyOnce(t *testing.T) { | 1301 | func TestPopupDeferredNamedReuseDestroysNewlyStagedWebViewExactlyOnce(t *testing.T) { | ||
| @@ -1393,7 +1393,10 @@ func TestPopupDeferredBrowserHostFailureCleansUpExactlyOnce(t *testing.T) { | |||||
| 1393 | return BrowserWindowResult{}, errors.New("host failed") | 1393 | return BrowserWindowResult{}, errors.New("host failed") | ||
| 1394 | }) | 1394 | }) | ||
| 1395 | 1395 | ||||
| 1396 | - | 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}})) | 1396 | + | require.Same(t, popup, c.handlePopupCreate(context.Background(), "floating", parent, port.PopupRequest{ |
| 1397 | + | Engine: dto.BrowserEngineWebKit, TargetURI: "https://example.com/open", FrameName: "shared", | |||
| 1398 | + | TargetDisposition: dto.WindowDispositionNewPopup, PopupFeatures: dto.PopupFeatures{State: dto.PopupFeaturesUnknown}, | |||
| 1399 | + | })) | |||
| 1397 | popup.ready() | 1400 | popup.ready() | ||
| 1398 | popup.ready() | 1401 | popup.ready() | ||
| 1399 | assert.True(t, staging.detached) | 1402 | assert.True(t, staging.detached) | ||
| @@ -1497,9 +1500,11 @@ func TestPopupDeferredUnknownAndCloseCleanupExactlyOnce(t *testing.T) { | |||||
| 1497 | // deferredPopupWebViewStub exposes the optional late-feature and lifecycle ports. | 1500 | // deferredPopupWebViewStub exposes the optional late-feature and lifecycle ports. | ||
| 1498 | type deferredPopupWebViewStub struct { | 1501 | type deferredPopupWebViewStub struct { | ||
| 1499 | *mocks.MockWebView | 1502 | *mocks.MockWebView | ||
| 1500 | - | features dto.PopupFeatures | 1503 | + | features dto.PopupFeatures |
| 1501 | - | onReady func() | 1504 | + | onReady func() |
| 1502 | - | onClose func() | 1505 | + | onClose func() |
| 1506 | + | browsingContextDecision dto.HostDecision | |||
| 1507 | + | hasBrowsingContextDecision bool | |||
| 1503 | } | 1508 | } | ||
| 1504 | 1509 | ||||
| 1505 | func (s *deferredPopupWebViewStub) ResolvePopupFeatures() dto.PopupFeatures { return s.features } | 1510 | func (s *deferredPopupWebViewStub) ResolvePopupFeatures() dto.PopupFeatures { return s.features } | ||
| @@ -1507,6 +1512,13 @@ func (s *deferredPopupWebViewStub) PrimePopupNavigation(string) {} | |||||
| 1507 | func (s *deferredPopupWebViewStub) SetOnReadyToShow(fn func()) { s.onReady = fn } | 1512 | func (s *deferredPopupWebViewStub) SetOnReadyToShow(fn func()) { s.onReady = fn } | ||
| 1508 | func (s *deferredPopupWebViewStub) SetOnClose(fn func()) { s.onClose = fn } | 1513 | func (s *deferredPopupWebViewStub) SetOnClose(fn func()) { s.onClose = fn } | ||
| 1509 | func (s *deferredPopupWebViewStub) Show() {} | 1514 | func (s *deferredPopupWebViewStub) Show() {} | ||
| 1515 | + | func (s *deferredPopupWebViewStub) SetBrowsingContextHostDecision(decision dto.HostDecision) { | |||
| 1516 | + | s.browsingContextDecision = decision | |||
| 1517 | + | s.hasBrowsingContextDecision = true | |||
| 1518 | + | } | |||
| 1519 | + | func (s *deferredPopupWebViewStub) BrowsingContextHostDecision() (dto.HostDecision, bool) { | |||
| 1520 | + | return s.browsingContextDecision, s.hasBrowsingContextDecision | |||
| 1521 | + | } | |||
| 1510 | func (s *deferredPopupWebViewStub) ready() { | 1522 | func (s *deferredPopupWebViewStub) ready() { | ||
| 1511 | if s.onReady != nil { | 1523 | if s.onReady != nil { | ||
| 1512 | s.onReady() | 1524 | s.onReady() | ||