refactor(browsing): model floating context host decisions
- commit
- a6c7a7f9539d2811840969205da4dfbbe66f2b19
- author
- brice · · 2026-07-25 06:10 UTC
- committer
- brice · · 2026-07-26 16:42 UTC
- parent
- 5c62115
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
11 files · +359 -189
M .mockery.yaml +1 -0
| @@ -85,6 +85,7 @@ packages: | |||
| 85 | 85 | FaviconDatabase: {} | |
| 86 | 86 | IdleInhibitor: {} | |
| 87 | 87 | PopupLifecycleCapable: {} | |
| 88 | + | PopupFeatureResolver: {} | |
| 88 | 89 | PopupOpenerCapable: {} | |
| 89 | 90 | OAuthCallbackCapable: {} | |
| 90 | 91 | DevToolsOpener: {} | |
| @@ -85,6 +85,7 @@ packages: | |||||
| 85 | FaviconDatabase: {} | 85 | FaviconDatabase: {} | ||
| 86 | IdleInhibitor: {} | 86 | IdleInhibitor: {} | ||
| 87 | PopupLifecycleCapable: {} | 87 | PopupLifecycleCapable: {} | ||
| 88 | + | PopupFeatureResolver: {} | |||
| 88 | PopupOpenerCapable: {} | 89 | PopupOpenerCapable: {} | ||
| 89 | OAuthCallbackCapable: {} | 90 | OAuthCallbackCapable: {} | ||
| 90 | DevToolsOpener: {} | 91 | DevToolsOpener: {} | ||
M internal/application/dto/browsing_context.go +98 -23
| @@ -1,19 +1,65 @@ | |||
| 1 | 1 | package dto | |
| 2 | 2 | ||
| 3 | - | // WindowDisposition describes how the browsing context should be presented | |
| 4 | - | // relative to the source frame. Values mirror Chromium/CEF/WebKit navigation | |
| 5 | - | // dispositions at a normalized product seam. | |
| 3 | + | // SourceHostKind identifies the product host that originated a browsing-context request. | |
| 4 | + | type SourceHostKind string | |
| 5 | + | ||
| 6 | + | const ( | |
| 7 | + | // SourceHostWorkspace is the default persisted workspace pane host. | |
| 8 | + | SourceHostWorkspace SourceHostKind = "workspace" | |
| 9 | + | // SourceHostFloating is a transient floating pane outside the workspace tree. | |
| 10 | + | SourceHostFloating SourceHostKind = "floating" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | // BrowserEngineKind identifies the browser engine that produced a normalized request. | |
| 14 | + | type BrowserEngineKind string | |
| 15 | + | ||
| 16 | + | const ( | |
| 17 | + | BrowserEngineUnknown BrowserEngineKind = "unknown" | |
| 18 | + | BrowserEngineCEF BrowserEngineKind = "cef" | |
| 19 | + | BrowserEngineWebKit BrowserEngineKind = "webkit" | |
| 20 | + | ) | |
| 21 | + | ||
| 22 | + | // PopupFeatureState describes whether popup metadata is available and explicit. | |
| 23 | + | type PopupFeatureState string | |
| 24 | + | ||
| 25 | + | const ( | |
| 26 | + | PopupFeaturesUnknown PopupFeatureState = "unknown" | |
| 27 | + | PopupFeaturesNone PopupFeatureState = "none" | |
| 28 | + | PopupFeaturesSpecified PopupFeatureState = "specified" | |
| 29 | + | ) | |
| 30 | + | ||
| 31 | + | // PopupFeatures is the engine-independent subset of window.open features used by host policy. | |
| 32 | + | type PopupFeatures struct { | |
| 33 | + | State PopupFeatureState | |
| 34 | + | ||
| 35 | + | X, Y, Width, Height int | |
| 36 | + | XSet, YSet, WidthSet, HeightSet bool | |
| 37 | + | ToolbarVisible, LocationbarVisible bool | |
| 38 | + | ToolbarVisibilitySet bool | |
| 39 | + | LocationbarVisibilitySet bool | |
| 40 | + | Resizable, ResizableSet bool | |
| 41 | + | IsPopup, IsPopupSet bool | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | // RequestsPopupHost reports whether explicit features require a constrained native popup. | |
| 45 | + | func (f PopupFeatures) RequestsPopupHost() bool { | |
| 46 | + | return f.State == PopupFeaturesSpecified && | |
| 47 | + | ((f.WidthSet && f.Width > 0) || | |
| 48 | + | (f.HeightSet && f.Height > 0) || | |
| 49 | + | (f.ToolbarVisibilitySet && !f.ToolbarVisible) || | |
| 50 | + | (f.LocationbarVisibilitySet && !f.LocationbarVisible) || | |
| 51 | + | (f.ResizableSet && !f.Resizable) || | |
| 52 | + | (f.IsPopupSet && f.IsPopup)) | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | // WindowDisposition describes how the browsing context should be presented. | |
| 6 | 56 | type WindowDisposition string | |
| 7 | 57 | ||
| 8 | 58 | const ( | |
| 9 | - | // WindowDispositionCurrentTab replaces the current document in the same browsing context. | |
| 10 | 59 | WindowDispositionCurrentTab WindowDisposition = "current-tab" | |
| 11 | - | // WindowDispositionNewTab opens in a new tab-like context. | |
| 12 | - | WindowDispositionNewTab WindowDisposition = "new-tab" | |
| 13 | - | // WindowDispositionNewPopup opens in a popup-style context. | |
| 14 | - | WindowDispositionNewPopup WindowDisposition = "new-popup" | |
| 15 | - | // WindowDispositionNewWindow opens in a new top-level window. | |
| 16 | - | WindowDispositionNewWindow WindowDisposition = "new-window" | |
| 60 | + | WindowDispositionNewTab WindowDisposition = "new-tab" | |
| 61 | + | WindowDispositionNewPopup WindowDisposition = "new-popup" | |
| 62 | + | WindowDispositionNewWindow WindowDisposition = "new-window" | |
| 17 | 63 | ) | |
| 18 | 64 | ||
| 19 | 65 | // TriggerKind categorizes what triggered a browsing-context request. | |
| @@ -27,22 +73,21 @@ const ( | |||
| 27 | 73 | TriggerAuthPopupRequest TriggerKind = "auth-popup-request" | |
| 28 | 74 | ) | |
| 29 | 75 | ||
| 30 | - | // RequestContextDisposition makes request-context inheritance explicit at the | |
| 31 | - | // policy seam without leaking infrastructure request-context types upward. | |
| 76 | + | // RequestContextDisposition makes request-context inheritance explicit at the policy seam. | |
| 32 | 77 | type RequestContextDisposition string | |
| 33 | 78 | ||
| 34 | 79 | const ( | |
| 35 | - | // RequestContextInheritParent preserves the parent request context/session. | |
| 36 | 80 | RequestContextInheritParent RequestContextDisposition = "inherit-parent" | |
| 37 | - | // RequestContextIsolate starts from a fresh unrelated request context. | |
| 38 | - | RequestContextIsolate RequestContextDisposition = "isolate" | |
| 81 | + | RequestContextIsolate RequestContextDisposition = "isolate" | |
| 39 | 82 | ) | |
| 40 | 83 | ||
| 41 | - | // NewBrowsingContextRequest is the normalized request captured from engine/UI | |
| 42 | - | // signals before policy decides how it should be hosted. | |
| 84 | + | // NewBrowsingContextRequest is the normalized request captured before host policy runs. | |
| 43 | 85 | type NewBrowsingContextRequest struct { | |
| 44 | 86 | ParentWebViewID uint64 | |
| 45 | 87 | ||
| 88 | + | Engine BrowserEngineKind | |
| 89 | + | SourceHost SourceHostKind | |
| 90 | + | ||
| 46 | 91 | SourceBrowserID int32 | |
| 47 | 92 | SourceFrameID string | |
| 48 | 93 | SourceFrameURL string | |
| @@ -53,7 +98,7 @@ type NewBrowsingContextRequest struct { | |||
| 53 | 98 | ||
| 54 | 99 | IsUserGesture bool | |
| 55 | 100 | NoJavaScriptAccess bool | |
| 56 | - | WindowFeatures string | |
| 101 | + | PopupFeatures PopupFeatures | |
| 57 | 102 | ||
| 58 | 103 | TriggerKind TriggerKind | |
| 59 | 104 | AuthIntent bool | |
| @@ -61,14 +106,43 @@ type NewBrowsingContextRequest struct { | |||
| 61 | 106 | RequestContextDisposition RequestContextDisposition | |
| 62 | 107 | } | |
| 63 | 108 | ||
| 64 | - | // HostDecisionKind categorizes the approved host choices for a new browsing context. | |
| 109 | + | // HostDecisionKind categorizes approved hosts for a new browsing context. | |
| 65 | 110 | type HostDecisionKind string | |
| 66 | 111 | ||
| 67 | 112 | const ( | |
| 68 | - | HostDecisionReuseNamedPane HostDecisionKind = "reuse-named-pane" | |
| 69 | - | HostDecisionCreatePane HostDecisionKind = "create-pane" | |
| 70 | - | HostDecisionCreateNativeWin HostDecisionKind = "create-native-window" | |
| 71 | - | HostDecisionDeny HostDecisionKind = "deny" | |
| 113 | + | HostDecisionReuseNamedPane HostDecisionKind = "reuse-named-pane" | |
| 114 | + | HostDecisionCreatePane HostDecisionKind = "create-pane" | |
| 115 | + | HostDecisionCreateBrowserWindow HostDecisionKind = "create-browser-window" | |
| 116 | + | HostDecisionCreateNativePopup HostDecisionKind = "create-native-popup" | |
| 117 | + | HostDecisionAwaitPopupFeatures HostDecisionKind = "await-popup-features" | |
| 118 | + | HostDecisionDeny HostDecisionKind = "deny" | |
| 119 | + | ) | |
| 120 | + | ||
| 121 | + | // HostDecisionReasonCode is a stable machine-readable policy explanation. | |
| 122 | + | type HostDecisionReasonCode string | |
| 123 | + | ||
| 124 | + | 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" | |
| 134 | + | ) | |
| 135 | + | ||
| 136 | + | // BrowsingContextFailureCode is a stable machine-readable operational failure. | |
| 137 | + | type BrowsingContextFailureCode string | |
| 138 | + | ||
| 139 | + | const ( | |
| 140 | + | BrowsingContextFailureHostUnavailable BrowsingContextFailureCode = "host-unavailable" | |
| 141 | + | BrowsingContextFailureHostFailed BrowsingContextFailureCode = "host-failed" | |
| 142 | + | BrowsingContextFailureFeatureResolution BrowsingContextFailureCode = "feature-resolution-failed" | |
| 143 | + | BrowsingContextFailureStagingDetach BrowsingContextFailureCode = "staging-detach-failed" | |
| 144 | + | BrowsingContextFailureNativeArm BrowsingContextFailureCode = "native-arm-failed" | |
| 145 | + | BrowsingContextFailureFallback BrowsingContextFailureCode = "fallback-failed" | |
| 72 | 146 | ) | |
| 73 | 147 | ||
| 74 | 148 | // HostDecision represents the output of browsing-context policy. | |
| @@ -80,5 +154,6 @@ type HostDecision struct { | |||
| 80 | 154 | ||
| 81 | 155 | RequestContextDisposition RequestContextDisposition | |
| 82 | 156 | RequiresNativeOpener bool | |
| 157 | + | ReasonCode HostDecisionReasonCode | |
| 83 | 158 | Reason string | |
| 84 | 159 | } | |
| @@ -1,19 +1,65 @@ | |||||
| 1 | package dto | 1 | package dto | ||
| 2 | 2 | ||||
| 3 | - | // WindowDisposition describes how the browsing context should be presented | 3 | + | // SourceHostKind identifies the product host that originated a browsing-context request. |
| 4 | - | // relative to the source frame. Values mirror Chromium/CEF/WebKit navigation | 4 | + | type SourceHostKind string |
| 5 | - | // dispositions at a normalized product seam. | 5 | + | |
| 6 | + | const ( | |||
| 7 | + | // SourceHostWorkspace is the default persisted workspace pane host. | |||
| 8 | + | SourceHostWorkspace SourceHostKind = "workspace" | |||
| 9 | + | // SourceHostFloating is a transient floating pane outside the workspace tree. | |||
| 10 | + | SourceHostFloating SourceHostKind = "floating" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | // BrowserEngineKind identifies the browser engine that produced a normalized request. | |||
| 14 | + | type BrowserEngineKind string | |||
| 15 | + | ||||
| 16 | + | const ( | |||
| 17 | + | BrowserEngineUnknown BrowserEngineKind = "unknown" | |||
| 18 | + | BrowserEngineCEF BrowserEngineKind = "cef" | |||
| 19 | + | BrowserEngineWebKit BrowserEngineKind = "webkit" | |||
| 20 | + | ) | |||
| 21 | + | ||||
| 22 | + | // PopupFeatureState describes whether popup metadata is available and explicit. | |||
| 23 | + | type PopupFeatureState string | |||
| 24 | + | ||||
| 25 | + | const ( | |||
| 26 | + | PopupFeaturesUnknown PopupFeatureState = "unknown" | |||
| 27 | + | PopupFeaturesNone PopupFeatureState = "none" | |||
| 28 | + | PopupFeaturesSpecified PopupFeatureState = "specified" | |||
| 29 | + | ) | |||
| 30 | + | ||||
| 31 | + | // PopupFeatures is the engine-independent subset of window.open features used by host policy. | |||
| 32 | + | type PopupFeatures struct { | |||
| 33 | + | State PopupFeatureState | |||
| 34 | + | ||||
| 35 | + | X, Y, Width, Height int | |||
| 36 | + | XSet, YSet, WidthSet, HeightSet bool | |||
| 37 | + | ToolbarVisible, LocationbarVisible bool | |||
| 38 | + | ToolbarVisibilitySet bool | |||
| 39 | + | LocationbarVisibilitySet bool | |||
| 40 | + | Resizable, ResizableSet bool | |||
| 41 | + | IsPopup, IsPopupSet bool | |||
| 42 | + | } | |||
| 43 | + | ||||
| 44 | + | // RequestsPopupHost reports whether explicit features require a constrained native popup. | |||
| 45 | + | func (f PopupFeatures) RequestsPopupHost() bool { | |||
| 46 | + | return f.State == PopupFeaturesSpecified && | |||
| 47 | + | ((f.WidthSet && f.Width > 0) || | |||
| 48 | + | (f.HeightSet && f.Height > 0) || | |||
| 49 | + | (f.ToolbarVisibilitySet && !f.ToolbarVisible) || | |||
| 50 | + | (f.LocationbarVisibilitySet && !f.LocationbarVisible) || | |||
| 51 | + | (f.ResizableSet && !f.Resizable) || | |||
| 52 | + | (f.IsPopupSet && f.IsPopup)) | |||
| 53 | + | } | |||
| 54 | + | ||||
| 55 | + | // WindowDisposition describes how the browsing context should be presented. | |||
| 6 | type WindowDisposition string | 56 | type WindowDisposition string | ||
| 7 | 57 | ||||
| 8 | const ( | 58 | const ( | ||
| 9 | - | // WindowDispositionCurrentTab replaces the current document in the same browsing context. | |||
| 10 | WindowDispositionCurrentTab WindowDisposition = "current-tab" | 59 | WindowDispositionCurrentTab WindowDisposition = "current-tab" | ||
| 11 | - | // WindowDispositionNewTab opens in a new tab-like context. | 60 | + | WindowDispositionNewTab WindowDisposition = "new-tab" |
| 12 | - | WindowDispositionNewTab WindowDisposition = "new-tab" | 61 | + | WindowDispositionNewPopup WindowDisposition = "new-popup" |
| 13 | - | // WindowDispositionNewPopup opens in a popup-style context. | 62 | + | WindowDispositionNewWindow WindowDisposition = "new-window" |
| 14 | - | WindowDispositionNewPopup WindowDisposition = "new-popup" | |||
| 15 | - | // WindowDispositionNewWindow opens in a new top-level window. | |||
| 16 | - | WindowDispositionNewWindow WindowDisposition = "new-window" | |||
| 17 | ) | 63 | ) | ||
| 18 | 64 | ||||
| 19 | // TriggerKind categorizes what triggered a browsing-context request. | 65 | // TriggerKind categorizes what triggered a browsing-context request. | ||
| @@ -27,22 +73,21 @@ const ( | |||||
| 27 | TriggerAuthPopupRequest TriggerKind = "auth-popup-request" | 73 | TriggerAuthPopupRequest TriggerKind = "auth-popup-request" | ||
| 28 | ) | 74 | ) | ||
| 29 | 75 | ||||
| 30 | - | // RequestContextDisposition makes request-context inheritance explicit at the | 76 | + | // RequestContextDisposition makes request-context inheritance explicit at the policy seam. |
| 31 | - | // policy seam without leaking infrastructure request-context types upward. | |||
| 32 | type RequestContextDisposition string | 77 | type RequestContextDisposition string | ||
| 33 | 78 | ||||
| 34 | const ( | 79 | const ( | ||
| 35 | - | // RequestContextInheritParent preserves the parent request context/session. | |||
| 36 | RequestContextInheritParent RequestContextDisposition = "inherit-parent" | 80 | RequestContextInheritParent RequestContextDisposition = "inherit-parent" | ||
| 37 | - | // RequestContextIsolate starts from a fresh unrelated request context. | 81 | + | RequestContextIsolate RequestContextDisposition = "isolate" |
| 38 | - | RequestContextIsolate RequestContextDisposition = "isolate" | |||
| 39 | ) | 82 | ) | ||
| 40 | 83 | ||||
| 41 | - | // NewBrowsingContextRequest is the normalized request captured from engine/UI | 84 | + | // NewBrowsingContextRequest is the normalized request captured before host policy runs. |
| 42 | - | // signals before policy decides how it should be hosted. | |||
| 43 | type NewBrowsingContextRequest struct { | 85 | type NewBrowsingContextRequest struct { | ||
| 44 | ParentWebViewID uint64 | 86 | ParentWebViewID uint64 | ||
| 45 | 87 | ||||
| 88 | + | Engine BrowserEngineKind | |||
| 89 | + | SourceHost SourceHostKind | |||
| 90 | + | ||||
| 46 | SourceBrowserID int32 | 91 | SourceBrowserID int32 | ||
| 47 | SourceFrameID string | 92 | SourceFrameID string | ||
| 48 | SourceFrameURL string | 93 | SourceFrameURL string | ||
| @@ -53,7 +98,7 @@ type NewBrowsingContextRequest struct { | |||||
| 53 | 98 | ||||
| 54 | IsUserGesture bool | 99 | IsUserGesture bool | ||
| 55 | NoJavaScriptAccess bool | 100 | NoJavaScriptAccess bool | ||
| 56 | - | WindowFeatures string | 101 | + | PopupFeatures PopupFeatures |
| 57 | 102 | ||||
| 58 | TriggerKind TriggerKind | 103 | TriggerKind TriggerKind | ||
| 59 | AuthIntent bool | 104 | AuthIntent bool | ||
| @@ -61,14 +106,43 @@ type NewBrowsingContextRequest struct { | |||||
| 61 | RequestContextDisposition RequestContextDisposition | 106 | RequestContextDisposition RequestContextDisposition | ||
| 62 | } | 107 | } | ||
| 63 | 108 | ||||
| 64 | - | // HostDecisionKind categorizes the approved host choices for a new browsing context. | 109 | + | // HostDecisionKind categorizes approved hosts for a new browsing context. |
| 65 | type HostDecisionKind string | 110 | type HostDecisionKind string | ||
| 66 | 111 | ||||
| 67 | const ( | 112 | const ( | ||
| 68 | - | HostDecisionReuseNamedPane HostDecisionKind = "reuse-named-pane" | 113 | + | HostDecisionReuseNamedPane HostDecisionKind = "reuse-named-pane" |
| 69 | - | HostDecisionCreatePane HostDecisionKind = "create-pane" | 114 | + | HostDecisionCreatePane HostDecisionKind = "create-pane" |
| 70 | - | HostDecisionCreateNativeWin HostDecisionKind = "create-native-window" | 115 | + | HostDecisionCreateBrowserWindow HostDecisionKind = "create-browser-window" |
| 71 | - | HostDecisionDeny HostDecisionKind = "deny" | 116 | + | HostDecisionCreateNativePopup HostDecisionKind = "create-native-popup" |
| 117 | + | HostDecisionAwaitPopupFeatures HostDecisionKind = "await-popup-features" | |||
| 118 | + | HostDecisionDeny HostDecisionKind = "deny" | |||
| 119 | + | ) | |||
| 120 | + | ||||
| 121 | + | // HostDecisionReasonCode is a stable machine-readable policy explanation. | |||
| 122 | + | type HostDecisionReasonCode string | |||
| 123 | + | ||||
| 124 | + | 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" | |||
| 134 | + | ) | |||
| 135 | + | ||||
| 136 | + | // BrowsingContextFailureCode is a stable machine-readable operational failure. | |||
| 137 | + | type BrowsingContextFailureCode string | |||
| 138 | + | ||||
| 139 | + | const ( | |||
| 140 | + | BrowsingContextFailureHostUnavailable BrowsingContextFailureCode = "host-unavailable" | |||
| 141 | + | BrowsingContextFailureHostFailed BrowsingContextFailureCode = "host-failed" | |||
| 142 | + | BrowsingContextFailureFeatureResolution BrowsingContextFailureCode = "feature-resolution-failed" | |||
| 143 | + | BrowsingContextFailureStagingDetach BrowsingContextFailureCode = "staging-detach-failed" | |||
| 144 | + | BrowsingContextFailureNativeArm BrowsingContextFailureCode = "native-arm-failed" | |||
| 145 | + | BrowsingContextFailureFallback BrowsingContextFailureCode = "fallback-failed" | |||
| 72 | ) | 146 | ) | ||
| 73 | 147 | ||||
| 74 | // HostDecision represents the output of browsing-context policy. | 148 | // HostDecision represents the output of browsing-context policy. | ||
| @@ -80,5 +154,6 @@ type HostDecision struct { | |||||
| 80 | 154 | ||||
| 81 | RequestContextDisposition RequestContextDisposition | 155 | RequestContextDisposition RequestContextDisposition | ||
| 82 | RequiresNativeOpener bool | 156 | RequiresNativeOpener bool | ||
| 157 | + | ReasonCode HostDecisionReasonCode | |||
| 83 | Reason string | 158 | Reason string | ||
| 84 | } | 159 | } | ||
M internal/application/port/mocks/webview.go +72 -0
| @@ -7,6 +7,7 @@ package mocks | |||
| 7 | 7 | import ( | |
| 8 | 8 | "context" | |
| 9 | 9 | ||
| 10 | + | "github.com/bnema/dumber/internal/application/dto" | |
| 10 | 11 | "github.com/bnema/dumber/internal/application/port" | |
| 11 | 12 | mock "github.com/stretchr/testify/mock" | |
| 12 | 13 | ) | |
| @@ -2631,6 +2632,77 @@ func (_c *MockPopupLifecycleCapable_Show_Call) RunAndReturn(run func()) *MockPop | |||
| 2631 | 2632 | return _c | |
| 2632 | 2633 | } | |
| 2633 | 2634 | ||
| 2635 | + | // NewMockPopupFeatureResolver creates a new instance of MockPopupFeatureResolver. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |
| 2636 | + | // The first argument is typically a *testing.T value. | |
| 2637 | + | func NewMockPopupFeatureResolver(t interface { | |
| 2638 | + | mock.TestingT | |
| 2639 | + | Cleanup(func()) | |
| 2640 | + | }) *MockPopupFeatureResolver { | |
| 2641 | + | mock := &MockPopupFeatureResolver{} | |
| 2642 | + | mock.Mock.Test(t) | |
| 2643 | + | ||
| 2644 | + | t.Cleanup(func() { mock.AssertExpectations(t) }) | |
| 2645 | + | ||
| 2646 | + | return mock | |
| 2647 | + | } | |
| 2648 | + | ||
| 2649 | + | // MockPopupFeatureResolver is an autogenerated mock type for the PopupFeatureResolver type | |
| 2650 | + | type MockPopupFeatureResolver struct { | |
| 2651 | + | mock.Mock | |
| 2652 | + | } | |
| 2653 | + | ||
| 2654 | + | type MockPopupFeatureResolver_Expecter struct { | |
| 2655 | + | mock *mock.Mock | |
| 2656 | + | } | |
| 2657 | + | ||
| 2658 | + | func (_m *MockPopupFeatureResolver) EXPECT() *MockPopupFeatureResolver_Expecter { | |
| 2659 | + | return &MockPopupFeatureResolver_Expecter{mock: &_m.Mock} | |
| 2660 | + | } | |
| 2661 | + | ||
| 2662 | + | // ResolvePopupFeatures provides a mock function for the type MockPopupFeatureResolver | |
| 2663 | + | func (_mock *MockPopupFeatureResolver) ResolvePopupFeatures() dto.PopupFeatures { | |
| 2664 | + | ret := _mock.Called() | |
| 2665 | + | ||
| 2666 | + | if len(ret) == 0 { | |
| 2667 | + | panic("no return value specified for ResolvePopupFeatures") | |
| 2668 | + | } | |
| 2669 | + | ||
| 2670 | + | var r0 dto.PopupFeatures | |
| 2671 | + | if returnFunc, ok := ret.Get(0).(func() dto.PopupFeatures); ok { | |
| 2672 | + | r0 = returnFunc() | |
| 2673 | + | } else { | |
| 2674 | + | r0 = ret.Get(0).(dto.PopupFeatures) | |
| 2675 | + | } | |
| 2676 | + | return r0 | |
| 2677 | + | } | |
| 2678 | + | ||
| 2679 | + | // MockPopupFeatureResolver_ResolvePopupFeatures_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ResolvePopupFeatures' | |
| 2680 | + | type MockPopupFeatureResolver_ResolvePopupFeatures_Call struct { | |
| 2681 | + | *mock.Call | |
| 2682 | + | } | |
| 2683 | + | ||
| 2684 | + | // ResolvePopupFeatures is a helper method to define mock.On call | |
| 2685 | + | func (_e *MockPopupFeatureResolver_Expecter) ResolvePopupFeatures() *MockPopupFeatureResolver_ResolvePopupFeatures_Call { | |
| 2686 | + | return &MockPopupFeatureResolver_ResolvePopupFeatures_Call{Call: _e.mock.On("ResolvePopupFeatures")} | |
| 2687 | + | } | |
| 2688 | + | ||
| 2689 | + | func (_c *MockPopupFeatureResolver_ResolvePopupFeatures_Call) Run(run func()) *MockPopupFeatureResolver_ResolvePopupFeatures_Call { | |
| 2690 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 2691 | + | run() | |
| 2692 | + | }) | |
| 2693 | + | return _c | |
| 2694 | + | } | |
| 2695 | + | ||
| 2696 | + | func (_c *MockPopupFeatureResolver_ResolvePopupFeatures_Call) Return(popupFeatures dto.PopupFeatures) *MockPopupFeatureResolver_ResolvePopupFeatures_Call { | |
| 2697 | + | _c.Call.Return(popupFeatures) | |
| 2698 | + | return _c | |
| 2699 | + | } | |
| 2700 | + | ||
| 2701 | + | func (_c *MockPopupFeatureResolver_ResolvePopupFeatures_Call) RunAndReturn(run func() dto.PopupFeatures) *MockPopupFeatureResolver_ResolvePopupFeatures_Call { | |
| 2702 | + | _c.Call.Return(run) | |
| 2703 | + | return _c | |
| 2704 | + | } | |
| 2705 | + | ||
| 2634 | 2706 | // NewMockPopupOpenerCapable creates a new instance of MockPopupOpenerCapable. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |
| 2635 | 2707 | // The first argument is typically a *testing.T value. | |
| 2636 | 2708 | func NewMockPopupOpenerCapable(t interface { | |
| @@ -7,6 +7,7 @@ package mocks | |||||
| 7 | import ( | 7 | import ( | ||
| 8 | "context" | 8 | "context" | ||
| 9 | 9 | ||||
| 10 | + | "github.com/bnema/dumber/internal/application/dto" | |||
| 10 | "github.com/bnema/dumber/internal/application/port" | 11 | "github.com/bnema/dumber/internal/application/port" | ||
| 11 | mock "github.com/stretchr/testify/mock" | 12 | mock "github.com/stretchr/testify/mock" | ||
| 12 | ) | 13 | ) | ||
| @@ -2631,6 +2632,77 @@ func (_c *MockPopupLifecycleCapable_Show_Call) RunAndReturn(run func()) *MockPop | |||||
| 2631 | return _c | 2632 | return _c | ||
| 2632 | } | 2633 | } | ||
| 2633 | 2634 | ||||
| 2635 | + | // NewMockPopupFeatureResolver creates a new instance of MockPopupFeatureResolver. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |||
| 2636 | + | // The first argument is typically a *testing.T value. | |||
| 2637 | + | func NewMockPopupFeatureResolver(t interface { | |||
| 2638 | + | mock.TestingT | |||
| 2639 | + | Cleanup(func()) | |||
| 2640 | + | }) *MockPopupFeatureResolver { | |||
| 2641 | + | mock := &MockPopupFeatureResolver{} | |||
| 2642 | + | mock.Mock.Test(t) | |||
| 2643 | + | ||||
| 2644 | + | t.Cleanup(func() { mock.AssertExpectations(t) }) | |||
| 2645 | + | ||||
| 2646 | + | return mock | |||
| 2647 | + | } | |||
| 2648 | + | ||||
| 2649 | + | // MockPopupFeatureResolver is an autogenerated mock type for the PopupFeatureResolver type | |||
| 2650 | + | type MockPopupFeatureResolver struct { | |||
| 2651 | + | mock.Mock | |||
| 2652 | + | } | |||
| 2653 | + | ||||
| 2654 | + | type MockPopupFeatureResolver_Expecter struct { | |||
| 2655 | + | mock *mock.Mock | |||
| 2656 | + | } | |||
| 2657 | + | ||||
| 2658 | + | func (_m *MockPopupFeatureResolver) EXPECT() *MockPopupFeatureResolver_Expecter { | |||
| 2659 | + | return &MockPopupFeatureResolver_Expecter{mock: &_m.Mock} | |||
| 2660 | + | } | |||
| 2661 | + | ||||
| 2662 | + | // ResolvePopupFeatures provides a mock function for the type MockPopupFeatureResolver | |||
| 2663 | + | func (_mock *MockPopupFeatureResolver) ResolvePopupFeatures() dto.PopupFeatures { | |||
| 2664 | + | ret := _mock.Called() | |||
| 2665 | + | ||||
| 2666 | + | if len(ret) == 0 { | |||
| 2667 | + | panic("no return value specified for ResolvePopupFeatures") | |||
| 2668 | + | } | |||
| 2669 | + | ||||
| 2670 | + | var r0 dto.PopupFeatures | |||
| 2671 | + | if returnFunc, ok := ret.Get(0).(func() dto.PopupFeatures); ok { | |||
| 2672 | + | r0 = returnFunc() | |||
| 2673 | + | } else { | |||
| 2674 | + | r0 = ret.Get(0).(dto.PopupFeatures) | |||
| 2675 | + | } | |||
| 2676 | + | return r0 | |||
| 2677 | + | } | |||
| 2678 | + | ||||
| 2679 | + | // MockPopupFeatureResolver_ResolvePopupFeatures_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ResolvePopupFeatures' | |||
| 2680 | + | type MockPopupFeatureResolver_ResolvePopupFeatures_Call struct { | |||
| 2681 | + | *mock.Call | |||
| 2682 | + | } | |||
| 2683 | + | ||||
| 2684 | + | // ResolvePopupFeatures is a helper method to define mock.On call | |||
| 2685 | + | func (_e *MockPopupFeatureResolver_Expecter) ResolvePopupFeatures() *MockPopupFeatureResolver_ResolvePopupFeatures_Call { | |||
| 2686 | + | return &MockPopupFeatureResolver_ResolvePopupFeatures_Call{Call: _e.mock.On("ResolvePopupFeatures")} | |||
| 2687 | + | } | |||
| 2688 | + | ||||
| 2689 | + | func (_c *MockPopupFeatureResolver_ResolvePopupFeatures_Call) Run(run func()) *MockPopupFeatureResolver_ResolvePopupFeatures_Call { | |||
| 2690 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 2691 | + | run() | |||
| 2692 | + | }) | |||
| 2693 | + | return _c | |||
| 2694 | + | } | |||
| 2695 | + | ||||
| 2696 | + | func (_c *MockPopupFeatureResolver_ResolvePopupFeatures_Call) Return(popupFeatures dto.PopupFeatures) *MockPopupFeatureResolver_ResolvePopupFeatures_Call { | |||
| 2697 | + | _c.Call.Return(popupFeatures) | |||
| 2698 | + | return _c | |||
| 2699 | + | } | |||
| 2700 | + | ||||
| 2701 | + | func (_c *MockPopupFeatureResolver_ResolvePopupFeatures_Call) RunAndReturn(run func() dto.PopupFeatures) *MockPopupFeatureResolver_ResolvePopupFeatures_Call { | |||
| 2702 | + | _c.Call.Return(run) | |||
| 2703 | + | return _c | |||
| 2704 | + | } | |||
| 2705 | + | ||||
| 2634 | // NewMockPopupOpenerCapable creates a new instance of MockPopupOpenerCapable. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | 2706 | // NewMockPopupOpenerCapable creates a new instance of MockPopupOpenerCapable. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | ||
| 2635 | // The first argument is typically a *testing.T value. | 2707 | // The first argument is typically a *testing.T value. | ||
| 2636 | func NewMockPopupOpenerCapable(t interface { | 2708 | func NewMockPopupOpenerCapable(t interface { | ||
M internal/application/port/webview.go +8 -1
| @@ -77,8 +77,9 @@ type PopupRequest struct { | |||
| 77 | 77 | SourceFrameID string | |
| 78 | 78 | SourceFrameURL string | |
| 79 | 79 | ||
| 80 | + | Engine dto.BrowserEngineKind | |
| 80 | 81 | TargetDisposition dto.WindowDisposition | |
| 81 | - | WindowFeatures string | |
| 82 | + | PopupFeatures dto.PopupFeatures | |
| 82 | 83 | } | |
| 83 | 84 | ||
| 84 | 85 | // Texture represents a graphics texture returned by the engine. | |
| @@ -342,6 +343,12 @@ type PopupLifecycleCapable interface { | |||
| 342 | 343 | PrimePopupNavigation(uri string) | |
| 343 | 344 | } | |
| 344 | 345 | ||
| 346 | + | // PopupFeatureResolver is implemented by engines that can only resolve popup | |
| 347 | + | // window properties after the related WebView reaches ready-to-show. | |
| 348 | + | type PopupFeatureResolver interface { | |
| 349 | + | ResolvePopupFeatures() dto.PopupFeatures | |
| 350 | + | } | |
| 351 | + | ||
| 345 | 352 | // PopupOpenerCapable is implemented by popup WebViews that can emulate opener | |
| 346 | 353 | // semantics when a popup must be created directly instead of through a native | |
| 347 | 354 | // related-popup lifecycle, and that can report or observe synthetic opener | |
| @@ -77,8 +77,9 @@ type PopupRequest struct { | |||||
| 77 | SourceFrameID string | 77 | SourceFrameID string | ||
| 78 | SourceFrameURL string | 78 | SourceFrameURL string | ||
| 79 | 79 | ||||
| 80 | + | Engine dto.BrowserEngineKind | |||
| 80 | TargetDisposition dto.WindowDisposition | 81 | TargetDisposition dto.WindowDisposition | ||
| 81 | - | WindowFeatures string | 82 | + | PopupFeatures dto.PopupFeatures |
| 82 | } | 83 | } | ||
| 83 | 84 | ||||
| 84 | // Texture represents a graphics texture returned by the engine. | 85 | // Texture represents a graphics texture returned by the engine. | ||
| @@ -342,6 +343,12 @@ type PopupLifecycleCapable interface { | |||||
| 342 | PrimePopupNavigation(uri string) | 343 | PrimePopupNavigation(uri string) | ||
| 343 | } | 344 | } | ||
| 344 | 345 | ||||
| 346 | + | // PopupFeatureResolver is implemented by engines that can only resolve popup | |||
| 347 | + | // window properties after the related WebView reaches ready-to-show. | |||
| 348 | + | type PopupFeatureResolver interface { | |||
| 349 | + | ResolvePopupFeatures() dto.PopupFeatures | |||
| 350 | + | } | |||
| 351 | + | ||||
| 345 | // PopupOpenerCapable is implemented by popup WebViews that can emulate opener | 352 | // PopupOpenerCapable is implemented by popup WebViews that can emulate opener | ||
| 346 | // semantics when a popup must be created directly instead of through a native | 353 | // semantics when a popup must be created directly instead of through a native | ||
| 347 | // related-popup lifecycle, and that can report or observe synthetic opener | 354 | // related-popup lifecycle, and that can report or observe synthetic opener | ||
M internal/infrastructure/cef/webview_handlers.go +1 -1
| @@ -684,7 +684,7 @@ func (h *handlerSet) OnBeforePopup( | |||
| 684 | 684 | ||
| 685 | 685 | cefPopup.setPopupNoJavaScriptAccess(requestNoJavaScriptAccess) | |
| 686 | 686 | switch decision.Kind { | |
| 687 | - | case dto.HostDecisionCreateNativeWin: | |
| 687 | + | case dto.HostDecisionCreateNativePopup: | |
| 688 | 688 | if cefPopup.prepareNativePopup(popupID, targetURL, windowInfo, clientSlot, settings) { | |
| 689 | 689 | logging.FromContext(h.currentContext()).Debug(). | |
| 690 | 690 | Int32("popup_id", popupID). | |
| @@ -684,7 +684,7 @@ func (h *handlerSet) OnBeforePopup( | |||||
| 684 | 684 | ||||
| 685 | cefPopup.setPopupNoJavaScriptAccess(requestNoJavaScriptAccess) | 685 | cefPopup.setPopupNoJavaScriptAccess(requestNoJavaScriptAccess) | ||
| 686 | switch decision.Kind { | 686 | switch decision.Kind { | ||
| 687 | - | case dto.HostDecisionCreateNativeWin: | 687 | + | case dto.HostDecisionCreateNativePopup: |
| 688 | if cefPopup.prepareNativePopup(popupID, targetURL, windowInfo, clientSlot, settings) { | 688 | if cefPopup.prepareNativePopup(popupID, targetURL, windowInfo, clientSlot, settings) { | ||
| 689 | logging.FromContext(h.currentContext()).Debug(). | 689 | logging.FromContext(h.currentContext()).Debug(). | ||
| 690 | Int32("popup_id", popupID). | 690 | Int32("popup_id", popupID). | ||
M internal/infrastructure/cef/webview_handlers_test.go +1 -1
| @@ -359,7 +359,7 @@ func TestOnBeforePopup_NativeDecisionAbortsHostWhenArmingFails(t *testing.T) { | |||
| 359 | 359 | parentWV := &WebView{ctx: context.Background(), id: 51} | |
| 360 | 360 | popupWV := &WebView{ctx: context.Background(), id: 52} | |
| 361 | 361 | popupWV.markNativePopupCandidate(parentWV) | |
| 362 | - | popupWV.SetBrowsingContextHostDecision(dto.HostDecision{Kind: dto.HostDecisionCreateNativeWin}) | |
| 362 | + | popupWV.SetBrowsingContextHostDecision(dto.HostDecision{Kind: dto.HostDecisionCreateNativePopup}) | |
| 363 | 363 | aborted := false | |
| 364 | 364 | popupWV.SetNativePopupHostAbort(func() { aborted = true }) | |
| 365 | 365 | parentWV.SetCallbacks(&port.WebViewCallbacks{ | |
| @@ -359,7 +359,7 @@ func TestOnBeforePopup_NativeDecisionAbortsHostWhenArmingFails(t *testing.T) { | |||||
| 359 | parentWV := &WebView{ctx: context.Background(), id: 51} | 359 | parentWV := &WebView{ctx: context.Background(), id: 51} | ||
| 360 | popupWV := &WebView{ctx: context.Background(), id: 52} | 360 | popupWV := &WebView{ctx: context.Background(), id: 52} | ||
| 361 | popupWV.markNativePopupCandidate(parentWV) | 361 | popupWV.markNativePopupCandidate(parentWV) | ||
| 362 | - | popupWV.SetBrowsingContextHostDecision(dto.HostDecision{Kind: dto.HostDecisionCreateNativeWin}) | 362 | + | popupWV.SetBrowsingContextHostDecision(dto.HostDecision{Kind: dto.HostDecisionCreateNativePopup}) |
| 363 | aborted := false | 363 | aborted := false | ||
| 364 | popupWV.SetNativePopupHostAbort(func() { aborted = true }) | 364 | popupWV.SetNativePopupHostAbort(func() { aborted = true }) | ||
| 365 | parentWV.SetCallbacks(&port.WebViewCallbacks{ | 365 | parentWV.SetCallbacks(&port.WebViewCallbacks{ | ||
M internal/infrastructure/webkit/webview_reuse_state_test.go +1 -1
| @@ -11,7 +11,7 @@ func TestResetForPoolReuse_ClearsBrowsingContextHostState(t *testing.T) { | |||
| 11 | 11 | wv := &WebView{} | |
| 12 | 12 | abortCalled := false | |
| 13 | 13 | ||
| 14 | - | wv.SetBrowsingContextHostDecision(dto.HostDecision{Kind: dto.HostDecisionCreateNativeWin}) | |
| 14 | + | wv.SetBrowsingContextHostDecision(dto.HostDecision{Kind: dto.HostDecisionCreateNativePopup}) | |
| 15 | 15 | wv.SetNativePopupHostAbort(func() { abortCalled = true }) | |
| 16 | 16 | ||
| 17 | 17 | wv.ResetForPoolReuse() | |
| @@ -11,7 +11,7 @@ func TestResetForPoolReuse_ClearsBrowsingContextHostState(t *testing.T) { | |||||
| 11 | wv := &WebView{} | 11 | wv := &WebView{} | ||
| 12 | abortCalled := false | 12 | abortCalled := false | ||
| 13 | 13 | ||||
| 14 | - | wv.SetBrowsingContextHostDecision(dto.HostDecision{Kind: dto.HostDecisionCreateNativeWin}) | 14 | + | wv.SetBrowsingContextHostDecision(dto.HostDecision{Kind: dto.HostDecisionCreateNativePopup}) |
| 15 | wv.SetNativePopupHostAbort(func() { abortCalled = true }) | 15 | wv.SetNativePopupHostAbort(func() { abortCalled = true }) | ||
| 16 | 16 | ||||
| 17 | wv.ResetForPoolReuse() | 17 | wv.ResetForPoolReuse() | ||
A internal/ui/coordinator/content/browsing_context_observability_test.go +62 -0
| @@ -0,0 +1,62 @@ | |||
| 1 | + | package content | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bytes" | |
| 5 | + | "encoding/json" | |
| 6 | + | "errors" | |
| 7 | + | "testing" | |
| 8 | + | ||
| 9 | + | "github.com/bnema/dumber/internal/application/dto" | |
| 10 | + | "github.com/rs/zerolog" | |
| 11 | + | "github.com/stretchr/testify/assert" | |
| 12 | + | "github.com/stretchr/testify/require" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | func TestLogBrowsingContextDecisionUsesStructuredSafeFields(t *testing.T) { | |
| 16 | + | t.Parallel() | |
| 17 | + | ||
| 18 | + | var output bytes.Buffer | |
| 19 | + | logger := zerolog.New(&output) | |
| 20 | + | req := dto.NewBrowsingContextRequest{ | |
| 21 | + | Engine: dto.BrowserEngineWebKit, SourceHost: dto.SourceHostFloating, | |
| 22 | + | TargetURI: "https://example.com/private?token=secret", TargetDisposition: dto.WindowDispositionNewPopup, | |
| 23 | + | } | |
| 24 | + | decision := dto.HostDecision{Kind: dto.HostDecisionCreateBrowserWindow, ReasonCode: dto.HostDecisionReasonFloatingBrowserWindow} | |
| 25 | + | ||
| 26 | + | logBrowsingContextDecision(logger, req, decision) | |
| 27 | + | record := decodeBrowsingContextLog(t, output.Bytes()) | |
| 28 | + | assert.Equal(t, "webkit", record["engine"]) | |
| 29 | + | assert.Equal(t, "floating", record["source_host"]) | |
| 30 | + | assert.Equal(t, "create-browser-window", record["decision"]) | |
| 31 | + | assert.Equal(t, "new-popup", record["target_disposition"]) | |
| 32 | + | assert.Equal(t, "floating-browser-window", record["reason_code"]) | |
| 33 | + | assert.NotContains(t, output.String(), "token") | |
| 34 | + | assert.NotContains(t, output.String(), "secret") | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | func TestLogBrowsingContextFailureUsesTypedReasonCode(t *testing.T) { | |
| 38 | + | t.Parallel() | |
| 39 | + | ||
| 40 | + | var output bytes.Buffer | |
| 41 | + | logger := zerolog.New(&output) | |
| 42 | + | req := dto.NewBrowsingContextRequest{Engine: dto.BrowserEngineCEF, SourceHost: dto.SourceHostWorkspace, TargetDisposition: dto.WindowDispositionNewTab} | |
| 43 | + | decision := dto.HostDecision{Kind: dto.HostDecisionCreateNativePopup} | |
| 44 | + | ||
| 45 | + | logBrowsingContextFailure(logger, req, decision, dto.BrowsingContextFailureHostFailed, errors.New("attachment failed")) | |
| 46 | + | record := decodeBrowsingContextLog(t, output.Bytes()) | |
| 47 | + | assert.Equal(t, "cef", record["engine"]) | |
| 48 | + | assert.Equal(t, "workspace", record["source_host"]) | |
| 49 | + | assert.Equal(t, "create-native-popup", record["decision"]) | |
| 50 | + | assert.Equal(t, "new-tab", record["target_disposition"]) | |
| 51 | + | assert.Equal(t, "host-failed", record["reason_code"]) | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | func decodeBrowsingContextLog(t *testing.T, data []byte) map[string]any { | |
| 55 | + | t.Helper() | |
| 56 | + | var record map[string]any | |
| 57 | + | require.NoError(t, json.Unmarshal(data, &record)) | |
| 58 | + | for _, field := range []string{"engine", "source_host", "decision", "target_disposition", "reason_code"} { | |
| 59 | + | assert.Contains(t, record, field) | |
| 60 | + | } | |
| 61 | + | return record | |
| 62 | + | } | |
| @@ -0,0 +1,62 @@ | |||||
| 1 | + | package content | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "bytes" | |||
| 5 | + | "encoding/json" | |||
| 6 | + | "errors" | |||
| 7 | + | "testing" | |||
| 8 | + | ||||
| 9 | + | "github.com/bnema/dumber/internal/application/dto" | |||
| 10 | + | "github.com/rs/zerolog" | |||
| 11 | + | "github.com/stretchr/testify/assert" | |||
| 12 | + | "github.com/stretchr/testify/require" | |||
| 13 | + | ) | |||
| 14 | + | ||||
| 15 | + | func TestLogBrowsingContextDecisionUsesStructuredSafeFields(t *testing.T) { | |||
| 16 | + | t.Parallel() | |||
| 17 | + | ||||
| 18 | + | var output bytes.Buffer | |||
| 19 | + | logger := zerolog.New(&output) | |||
| 20 | + | req := dto.NewBrowsingContextRequest{ | |||
| 21 | + | Engine: dto.BrowserEngineWebKit, SourceHost: dto.SourceHostFloating, | |||
| 22 | + | TargetURI: "https://example.com/private?token=secret", TargetDisposition: dto.WindowDispositionNewPopup, | |||
| 23 | + | } | |||
| 24 | + | decision := dto.HostDecision{Kind: dto.HostDecisionCreateBrowserWindow, ReasonCode: dto.HostDecisionReasonFloatingBrowserWindow} | |||
| 25 | + | ||||
| 26 | + | logBrowsingContextDecision(logger, req, decision) | |||
| 27 | + | record := decodeBrowsingContextLog(t, output.Bytes()) | |||
| 28 | + | assert.Equal(t, "webkit", record["engine"]) | |||
| 29 | + | assert.Equal(t, "floating", record["source_host"]) | |||
| 30 | + | assert.Equal(t, "create-browser-window", record["decision"]) | |||
| 31 | + | assert.Equal(t, "new-popup", record["target_disposition"]) | |||
| 32 | + | assert.Equal(t, "floating-browser-window", record["reason_code"]) | |||
| 33 | + | assert.NotContains(t, output.String(), "token") | |||
| 34 | + | assert.NotContains(t, output.String(), "secret") | |||
| 35 | + | } | |||
| 36 | + | ||||
| 37 | + | func TestLogBrowsingContextFailureUsesTypedReasonCode(t *testing.T) { | |||
| 38 | + | t.Parallel() | |||
| 39 | + | ||||
| 40 | + | var output bytes.Buffer | |||
| 41 | + | logger := zerolog.New(&output) | |||
| 42 | + | req := dto.NewBrowsingContextRequest{Engine: dto.BrowserEngineCEF, SourceHost: dto.SourceHostWorkspace, TargetDisposition: dto.WindowDispositionNewTab} | |||
| 43 | + | decision := dto.HostDecision{Kind: dto.HostDecisionCreateNativePopup} | |||
| 44 | + | ||||
| 45 | + | logBrowsingContextFailure(logger, req, decision, dto.BrowsingContextFailureHostFailed, errors.New("attachment failed")) | |||
| 46 | + | record := decodeBrowsingContextLog(t, output.Bytes()) | |||
| 47 | + | assert.Equal(t, "cef", record["engine"]) | |||
| 48 | + | assert.Equal(t, "workspace", record["source_host"]) | |||
| 49 | + | assert.Equal(t, "create-native-popup", record["decision"]) | |||
| 50 | + | assert.Equal(t, "new-tab", record["target_disposition"]) | |||
| 51 | + | assert.Equal(t, "host-failed", record["reason_code"]) | |||
| 52 | + | } | |||
| 53 | + | ||||
| 54 | + | func decodeBrowsingContextLog(t *testing.T, data []byte) map[string]any { | |||
| 55 | + | t.Helper() | |||
| 56 | + | var record map[string]any | |||
| 57 | + | require.NoError(t, json.Unmarshal(data, &record)) | |||
| 58 | + | for _, field := range []string{"engine", "source_host", "decision", "target_disposition", "reason_code"} { | |||
| 59 | + | assert.Contains(t, record, field) | |||
| 60 | + | } | |||
| 61 | + | return record | |||
| 62 | + | } | |||
M internal/ui/coordinator/content/browsing_context_policy.go +56 -28
| @@ -5,6 +5,7 @@ import ( | |||
| 5 | 5 | ||
| 6 | 6 | "github.com/bnema/dumber/internal/application/dto" | |
| 7 | 7 | "github.com/bnema/dumber/internal/application/port" | |
| 8 | + | "github.com/rs/zerolog" | |
| 8 | 9 | ) | |
| 9 | 10 | ||
| 10 | 11 | type browsingContextPolicy struct{} | |
| @@ -18,46 +19,73 @@ func (browsingContextPolicy) Decide(req dto.NewBrowsingContextRequest, namedCont | |||
| 18 | 19 | decision.RequestContextDisposition = dto.RequestContextInheritParent | |
| 19 | 20 | } | |
| 20 | 21 | ||
| 21 | - | if strings.TrimSpace(req.TargetURI) == "" { | |
| 22 | - | decision.Kind = dto.HostDecisionDeny | |
| 23 | - | decision.Reason = "empty target URI" | |
| 24 | - | return decision | |
| 25 | - | } | |
| 26 | - | ||
| 27 | - | if req.AuthIntent { | |
| 28 | - | decision.Kind = dto.HostDecisionCreateNativeWin | |
| 29 | - | decision.Reason = "auth intent requires native window" | |
| 30 | - | return decision | |
| 31 | - | } | |
| 32 | - | if req.RequiresNativeOpener { | |
| 33 | - | decision.Kind = dto.HostDecisionCreateNativeWin | |
| 34 | - | decision.Reason = "native opener required" | |
| 35 | - | return decision | |
| 36 | - | } | |
| 37 | - | if isAmbiguousNativeBrowsingContext(req) { | |
| 38 | - | decision.Kind = dto.HostDecisionCreateNativeWin | |
| 39 | - | decision.Reason = "ambiguous opener-coupled request prefers native window" | |
| 40 | - | return decision | |
| 22 | + | switch { | |
| 23 | + | case strings.TrimSpace(req.TargetURI) == "": | |
| 24 | + | return completeHostDecision(decision, dto.HostDecisionDeny, dto.HostDecisionReasonEmptyTarget, "empty target URI") | |
| 25 | + | case req.AuthIntent: | |
| 26 | + | return completeHostDecision(decision, dto.HostDecisionCreateNativePopup, dto.HostDecisionReasonAuthNativePopup, "authentication intent requires a related native popup") | |
| 27 | + | case req.RequiresNativeOpener: | |
| 28 | + | return completeHostDecision(decision, dto.HostDecisionCreateNativePopup, dto.HostDecisionReasonNativeOpenerRequired, "native opener relationship is required") | |
| 29 | + | case isAmbiguousNativeBrowsingContext(req): | |
| 30 | + | return completeHostDecision(decision, dto.HostDecisionCreateNativePopup, dto.HostDecisionReasonAmbiguousOpenerNative, "ambiguous opener-coupled request prefers a related native popup") | |
| 41 | 31 | } | |
| 42 | 32 | ||
| 43 | 33 | name := reusableBrowsingContextName(req.TargetFrameName) | |
| 44 | - | if namedContextExists && name != "" { | |
| 45 | - | decision.Kind = dto.HostDecisionReuseNamedPane | |
| 34 | + | if namedContextExists && name != "" && !req.NoJavaScriptAccess { | |
| 46 | 35 | decision.ReuseContextName = name | |
| 47 | 36 | decision.BrowsingContextName = name | |
| 48 | - | decision.Reason = "reuse named browsing context in current window" | |
| 49 | - | return decision | |
| 37 | + | return completeHostDecision(decision, dto.HostDecisionReuseNamedPane, dto.HostDecisionReasonNamedContextReuse, "reuse live named browsing context") | |
| 50 | 38 | } | |
| 51 | - | ||
| 52 | - | decision.Kind = dto.HostDecisionCreatePane | |
| 53 | 39 | decision.BrowsingContextName = name | |
| 54 | - | decision.Reason = "pane-hosted browsing context" | |
| 40 | + | ||
| 41 | + | if req.SourceHost == dto.SourceHostFloating { | |
| 42 | + | return decideFloatingHost(decision, req) | |
| 43 | + | } | |
| 44 | + | return completeHostDecision(decision, dto.HostDecisionCreatePane, dto.HostDecisionReasonWorkspacePane, "workspace request uses pane hosting") | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | func decideFloatingHost(decision dto.HostDecision, req dto.NewBrowsingContextRequest) dto.HostDecision { | |
| 48 | + | switch { | |
| 49 | + | case req.PopupFeatures.State == dto.PopupFeaturesUnknown && req.TriggerKind != dto.TriggerLinkNewPage: | |
| 50 | + | return completeHostDecision(decision, dto.HostDecisionAwaitPopupFeatures, dto.HostDecisionReasonFloatingFeaturesPending, "popup features unavailable until ready-to-show") | |
| 51 | + | case req.PopupFeatures.RequestsPopupHost(): | |
| 52 | + | return completeHostDecision(decision, dto.HostDecisionCreateNativePopup, dto.HostDecisionReasonFloatingPopupFeatures, "explicit popup features requested") | |
| 53 | + | default: | |
| 54 | + | return completeHostDecision(decision, dto.HostDecisionCreateBrowserWindow, dto.HostDecisionReasonFloatingBrowserWindow, "floating request opens a detached browser window") | |
| 55 | + | } | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | func completeHostDecision(decision dto.HostDecision, kind dto.HostDecisionKind, code dto.HostDecisionReasonCode, reason string) dto.HostDecision { | |
| 59 | + | decision.Kind = kind | |
| 60 | + | decision.ReasonCode = code | |
| 61 | + | decision.Reason = reason | |
| 55 | 62 | return decision | |
| 56 | 63 | } | |
| 57 | 64 | ||
| 65 | + | func logBrowsingContextDecision(logger zerolog.Logger, req dto.NewBrowsingContextRequest, decision dto.HostDecision) { | |
| 66 | + | logger.Debug(). | |
| 67 | + | Str("engine", string(req.Engine)). | |
| 68 | + | Str("source_host", string(req.SourceHost)). | |
| 69 | + | Str("decision", string(decision.Kind)). | |
| 70 | + | Str("target_disposition", string(req.TargetDisposition)). | |
| 71 | + | Str("reason_code", string(decision.ReasonCode)). | |
| 72 | + | Msg("browsing context host decided") | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | func logBrowsingContextFailure(logger zerolog.Logger, req dto.NewBrowsingContextRequest, decision dto.HostDecision, code dto.BrowsingContextFailureCode, err error) { | |
| 76 | + | logger.Error().Err(err). | |
| 77 | + | Str("engine", string(req.Engine)). | |
| 78 | + | Str("source_host", string(req.SourceHost)). | |
| 79 | + | Str("decision", string(decision.Kind)). | |
| 80 | + | Str("target_disposition", string(req.TargetDisposition)). | |
| 81 | + | Str("reason_code", string(code)). | |
| 82 | + | Msg("browsing context hosting failed") | |
| 83 | + | } | |
| 84 | + | ||
| 58 | 85 | func buildPopupBrowsingContextRequest(req port.PopupRequest) dto.NewBrowsingContextRequest { | |
| 59 | 86 | return dto.NewBrowsingContextRequest{ | |
| 60 | 87 | ParentWebViewID: uint64(req.ParentViewID), | |
| 88 | + | Engine: req.Engine, | |
| 61 | 89 | SourceBrowserID: req.SourceBrowserID, | |
| 62 | 90 | SourceFrameID: req.SourceFrameID, | |
| 63 | 91 | SourceFrameURL: req.SourceFrameURL, | |
| @@ -66,7 +94,7 @@ func buildPopupBrowsingContextRequest(req port.PopupRequest) dto.NewBrowsingCont | |||
| 66 | 94 | TargetDisposition: inferPopupWindowDisposition(req), | |
| 67 | 95 | IsUserGesture: req.IsUserGesture, | |
| 68 | 96 | NoJavaScriptAccess: req.NoJavaScriptAccess, | |
| 69 | - | WindowFeatures: req.WindowFeatures, | |
| 97 | + | PopupFeatures: req.PopupFeatures, | |
| 70 | 98 | TriggerKind: inferPopupTriggerKind(req), | |
| 71 | 99 | AuthIntent: IsOAuthURL(req.TargetURI), | |
| 72 | 100 | RequiresNativeOpener: false, | |
| @@ -5,6 +5,7 @@ import ( | |||||
| 5 | 5 | ||||
| 6 | "github.com/bnema/dumber/internal/application/dto" | 6 | "github.com/bnema/dumber/internal/application/dto" | ||
| 7 | "github.com/bnema/dumber/internal/application/port" | 7 | "github.com/bnema/dumber/internal/application/port" | ||
| 8 | + | "github.com/rs/zerolog" | |||
| 8 | ) | 9 | ) | ||
| 9 | 10 | ||||
| 10 | type browsingContextPolicy struct{} | 11 | type browsingContextPolicy struct{} | ||
| @@ -18,46 +19,73 @@ func (browsingContextPolicy) Decide(req dto.NewBrowsingContextRequest, namedCont | |||||
| 18 | decision.RequestContextDisposition = dto.RequestContextInheritParent | 19 | decision.RequestContextDisposition = dto.RequestContextInheritParent | ||
| 19 | } | 20 | } | ||
| 20 | 21 | ||||
| 21 | - | if strings.TrimSpace(req.TargetURI) == "" { | 22 | + | switch { |
| 22 | - | decision.Kind = dto.HostDecisionDeny | 23 | + | case strings.TrimSpace(req.TargetURI) == "": |
| 23 | - | decision.Reason = "empty target URI" | 24 | + | return completeHostDecision(decision, dto.HostDecisionDeny, dto.HostDecisionReasonEmptyTarget, "empty target URI") |
| 24 | - | return decision | 25 | + | case req.AuthIntent: |
| 25 | - | } | 26 | + | return completeHostDecision(decision, dto.HostDecisionCreateNativePopup, dto.HostDecisionReasonAuthNativePopup, "authentication intent requires a related native popup") |
| 26 | - | 27 | + | case req.RequiresNativeOpener: | |
| 27 | - | if req.AuthIntent { | 28 | + | return completeHostDecision(decision, dto.HostDecisionCreateNativePopup, dto.HostDecisionReasonNativeOpenerRequired, "native opener relationship is required") |
| 28 | - | decision.Kind = dto.HostDecisionCreateNativeWin | 29 | + | case isAmbiguousNativeBrowsingContext(req): |
| 29 | - | decision.Reason = "auth intent requires native window" | 30 | + | return completeHostDecision(decision, dto.HostDecisionCreateNativePopup, dto.HostDecisionReasonAmbiguousOpenerNative, "ambiguous opener-coupled request prefers a related native popup") |
| 30 | - | return decision | |||
| 31 | - | } | |||
| 32 | - | if req.RequiresNativeOpener { | |||
| 33 | - | decision.Kind = dto.HostDecisionCreateNativeWin | |||
| 34 | - | decision.Reason = "native opener required" | |||
| 35 | - | return decision | |||
| 36 | - | } | |||
| 37 | - | if isAmbiguousNativeBrowsingContext(req) { | |||
| 38 | - | decision.Kind = dto.HostDecisionCreateNativeWin | |||
| 39 | - | decision.Reason = "ambiguous opener-coupled request prefers native window" | |||
| 40 | - | return decision | |||
| 41 | } | 31 | } | ||
| 42 | 32 | ||||
| 43 | name := reusableBrowsingContextName(req.TargetFrameName) | 33 | name := reusableBrowsingContextName(req.TargetFrameName) | ||
| 44 | - | if namedContextExists && name != "" { | 34 | + | if namedContextExists && name != "" && !req.NoJavaScriptAccess { |
| 45 | - | decision.Kind = dto.HostDecisionReuseNamedPane | |||
| 46 | decision.ReuseContextName = name | 35 | decision.ReuseContextName = name | ||
| 47 | decision.BrowsingContextName = name | 36 | decision.BrowsingContextName = name | ||
| 48 | - | decision.Reason = "reuse named browsing context in current window" | 37 | + | return completeHostDecision(decision, dto.HostDecisionReuseNamedPane, dto.HostDecisionReasonNamedContextReuse, "reuse live named browsing context") |
| 49 | - | return decision | |||
| 50 | } | 38 | } | ||
| 51 | - | ||||
| 52 | - | decision.Kind = dto.HostDecisionCreatePane | |||
| 53 | decision.BrowsingContextName = name | 39 | decision.BrowsingContextName = name | ||
| 54 | - | decision.Reason = "pane-hosted browsing context" | 40 | + | |
| 41 | + | if req.SourceHost == dto.SourceHostFloating { | |||
| 42 | + | return decideFloatingHost(decision, req) | |||
| 43 | + | } | |||
| 44 | + | return completeHostDecision(decision, dto.HostDecisionCreatePane, dto.HostDecisionReasonWorkspacePane, "workspace request uses pane hosting") | |||
| 45 | + | } | |||
| 46 | + | ||||
| 47 | + | func decideFloatingHost(decision dto.HostDecision, req dto.NewBrowsingContextRequest) dto.HostDecision { | |||
| 48 | + | switch { | |||
| 49 | + | case req.PopupFeatures.State == dto.PopupFeaturesUnknown && req.TriggerKind != dto.TriggerLinkNewPage: | |||
| 50 | + | return completeHostDecision(decision, dto.HostDecisionAwaitPopupFeatures, dto.HostDecisionReasonFloatingFeaturesPending, "popup features unavailable until ready-to-show") | |||
| 51 | + | case req.PopupFeatures.RequestsPopupHost(): | |||
| 52 | + | return completeHostDecision(decision, dto.HostDecisionCreateNativePopup, dto.HostDecisionReasonFloatingPopupFeatures, "explicit popup features requested") | |||
| 53 | + | default: | |||
| 54 | + | return completeHostDecision(decision, dto.HostDecisionCreateBrowserWindow, dto.HostDecisionReasonFloatingBrowserWindow, "floating request opens a detached browser window") | |||
| 55 | + | } | |||
| 56 | + | } | |||
| 57 | + | ||||
| 58 | + | func completeHostDecision(decision dto.HostDecision, kind dto.HostDecisionKind, code dto.HostDecisionReasonCode, reason string) dto.HostDecision { | |||
| 59 | + | decision.Kind = kind | |||
| 60 | + | decision.ReasonCode = code | |||
| 61 | + | decision.Reason = reason | |||
| 55 | return decision | 62 | return decision | ||
| 56 | } | 63 | } | ||
| 57 | 64 | ||||
| 65 | + | func logBrowsingContextDecision(logger zerolog.Logger, req dto.NewBrowsingContextRequest, decision dto.HostDecision) { | |||
| 66 | + | logger.Debug(). | |||
| 67 | + | Str("engine", string(req.Engine)). | |||
| 68 | + | Str("source_host", string(req.SourceHost)). | |||
| 69 | + | Str("decision", string(decision.Kind)). | |||
| 70 | + | Str("target_disposition", string(req.TargetDisposition)). | |||
| 71 | + | Str("reason_code", string(decision.ReasonCode)). | |||
| 72 | + | Msg("browsing context host decided") | |||
| 73 | + | } | |||
| 74 | + | ||||
| 75 | + | func logBrowsingContextFailure(logger zerolog.Logger, req dto.NewBrowsingContextRequest, decision dto.HostDecision, code dto.BrowsingContextFailureCode, err error) { | |||
| 76 | + | logger.Error().Err(err). | |||
| 77 | + | Str("engine", string(req.Engine)). | |||
| 78 | + | Str("source_host", string(req.SourceHost)). | |||
| 79 | + | Str("decision", string(decision.Kind)). | |||
| 80 | + | Str("target_disposition", string(req.TargetDisposition)). | |||
| 81 | + | Str("reason_code", string(code)). | |||
| 82 | + | Msg("browsing context hosting failed") | |||
| 83 | + | } | |||
| 84 | + | ||||
| 58 | func buildPopupBrowsingContextRequest(req port.PopupRequest) dto.NewBrowsingContextRequest { | 85 | func buildPopupBrowsingContextRequest(req port.PopupRequest) dto.NewBrowsingContextRequest { | ||
| 59 | return dto.NewBrowsingContextRequest{ | 86 | return dto.NewBrowsingContextRequest{ | ||
| 60 | ParentWebViewID: uint64(req.ParentViewID), | 87 | ParentWebViewID: uint64(req.ParentViewID), | ||
| 88 | + | Engine: req.Engine, | |||
| 61 | SourceBrowserID: req.SourceBrowserID, | 89 | SourceBrowserID: req.SourceBrowserID, | ||
| 62 | SourceFrameID: req.SourceFrameID, | 90 | SourceFrameID: req.SourceFrameID, | ||
| 63 | SourceFrameURL: req.SourceFrameURL, | 91 | SourceFrameURL: req.SourceFrameURL, | ||
| @@ -66,7 +94,7 @@ func buildPopupBrowsingContextRequest(req port.PopupRequest) dto.NewBrowsingCont | |||||
| 66 | TargetDisposition: inferPopupWindowDisposition(req), | 94 | TargetDisposition: inferPopupWindowDisposition(req), | ||
| 67 | IsUserGesture: req.IsUserGesture, | 95 | IsUserGesture: req.IsUserGesture, | ||
| 68 | NoJavaScriptAccess: req.NoJavaScriptAccess, | 96 | NoJavaScriptAccess: req.NoJavaScriptAccess, | ||
| 69 | - | WindowFeatures: req.WindowFeatures, | 97 | + | PopupFeatures: req.PopupFeatures, |
| 70 | TriggerKind: inferPopupTriggerKind(req), | 98 | TriggerKind: inferPopupTriggerKind(req), | ||
| 71 | AuthIntent: IsOAuthURL(req.TargetURI), | 99 | AuthIntent: IsOAuthURL(req.TargetURI), | ||
| 72 | RequiresNativeOpener: false, | 100 | RequiresNativeOpener: false, | ||
M internal/ui/coordinator/content/browsing_context_policy_test.go +58 -133
| @@ -8,167 +8,92 @@ import ( | |||
| 8 | 8 | "github.com/stretchr/testify/assert" | |
| 9 | 9 | ) | |
| 10 | 10 | ||
| 11 | + | func TestPopupFeaturesRequestsPopupHost(t *testing.T) { | |
| 12 | + | t.Parallel() | |
| 13 | + | ||
| 14 | + | tests := []struct { | |
| 15 | + | name string | |
| 16 | + | features dto.PopupFeatures | |
| 17 | + | want bool | |
| 18 | + | }{ | |
| 19 | + | {name: "known featureless", features: dto.PopupFeatures{State: dto.PopupFeaturesNone}}, | |
| 20 | + | {name: "zero width", features: dto.PopupFeatures{State: dto.PopupFeaturesSpecified, Width: 0, WidthSet: true}}, | |
| 21 | + | {name: "positive width", features: dto.PopupFeatures{State: dto.PopupFeaturesSpecified, Width: 1, WidthSet: true}, want: true}, | |
| 22 | + | {name: "positive height", features: dto.PopupFeatures{State: dto.PopupFeaturesSpecified, Height: 1, HeightSet: true}, want: true}, | |
| 23 | + | {name: "hidden toolbar", features: dto.PopupFeatures{State: dto.PopupFeaturesSpecified, ToolbarVisible: false, ToolbarVisibilitySet: true}, want: true}, | |
| 24 | + | {name: "hidden location bar", features: dto.PopupFeatures{State: dto.PopupFeaturesSpecified, LocationbarVisible: false, LocationbarVisibilitySet: true}, want: true}, | |
| 25 | + | {name: "disabled resize", features: dto.PopupFeatures{State: dto.PopupFeaturesSpecified, Resizable: false, ResizableSet: true}, want: true}, | |
| 26 | + | {name: "engine popup flag", features: dto.PopupFeatures{State: dto.PopupFeaturesSpecified, IsPopup: true, IsPopupSet: true}, want: true}, | |
| 27 | + | {name: "visible chrome", features: dto.PopupFeatures{State: dto.PopupFeaturesSpecified, ToolbarVisible: true, ToolbarVisibilitySet: true}}, | |
| 28 | + | {name: "resizable", features: dto.PopupFeatures{State: dto.PopupFeaturesSpecified, Resizable: true, ResizableSet: true}}, | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | for _, tt := range tests { | |
| 32 | + | t.Run(tt.name, func(t *testing.T) { | |
| 33 | + | t.Parallel() | |
| 34 | + | assert.Equal(t, tt.want, tt.features.RequestsPopupHost()) | |
| 35 | + | }) | |
| 36 | + | } | |
| 37 | + | } | |
| 38 | + | ||
| 11 | 39 | func TestBrowsingContextPolicyDecide(t *testing.T) { | |
| 12 | 40 | t.Parallel() | |
| 13 | 41 | ||
| 14 | - | policy := browsingContextPolicy{} | |
| 42 | + | workspace := dto.NewBrowsingContextRequest{SourceHost: dto.SourceHostWorkspace, TargetURI: "https://example.com/docs", TargetDisposition: dto.WindowDispositionNewTab, TriggerKind: dto.TriggerLinkNewPage} | |
| 15 | 43 | tests := []struct { | |
| 16 | 44 | name string | |
| 17 | 45 | request dto.NewBrowsingContextRequest | |
| 18 | 46 | namedContextExist bool | |
| 19 | 47 | wantKind dto.HostDecisionKind | |
| 20 | 48 | wantName string | |
| 49 | + | wantReason dto.HostDecisionReasonCode | |
| 21 | 50 | }{ | |
| 22 | - | { | |
| 23 | - | name: "blank target stays pane-hosted", | |
| 24 | - | request: dto.NewBrowsingContextRequest{ | |
| 25 | - | TargetURI: "https://example.com/docs", | |
| 26 | - | TargetFrameName: "_blank", | |
| 27 | - | TriggerKind: dto.TriggerLinkNewPage, | |
| 28 | - | TargetDisposition: dto.WindowDispositionNewTab, | |
| 29 | - | RequestContextDisposition: dto.RequestContextInheritParent, | |
| 30 | - | }, | |
| 31 | - | wantKind: dto.HostDecisionCreatePane, | |
| 32 | - | }, | |
| 33 | - | { | |
| 34 | - | name: "named target reuses existing pane in same window", | |
| 35 | - | request: dto.NewBrowsingContextRequest{ | |
| 36 | - | TargetURI: "https://example.com/second", | |
| 37 | - | TargetFrameName: "shared-pane", | |
| 38 | - | TriggerKind: dto.TriggerNamedTargetNavigation, | |
| 39 | - | TargetDisposition: dto.WindowDispositionNewPopup, | |
| 40 | - | RequestContextDisposition: dto.RequestContextInheritParent, | |
| 41 | - | }, | |
| 42 | - | namedContextExist: true, | |
| 43 | - | wantKind: dto.HostDecisionReuseNamedPane, | |
| 44 | - | wantName: "shared-pane", | |
| 45 | - | }, | |
| 46 | - | { | |
| 47 | - | name: "ordinary window.open stays pane-hosted", | |
| 48 | - | request: dto.NewBrowsingContextRequest{ | |
| 49 | - | TargetURI: "https://example.com/help", | |
| 50 | - | TriggerKind: dto.TriggerScriptWindowOpen, | |
| 51 | - | TargetDisposition: dto.WindowDispositionNewPopup, | |
| 52 | - | NoJavaScriptAccess: false, | |
| 53 | - | RequestContextDisposition: dto.RequestContextInheritParent, | |
| 54 | - | }, | |
| 55 | - | wantKind: dto.HostDecisionCreatePane, | |
| 56 | - | }, | |
| 57 | - | { | |
| 58 | - | name: "auth intent forces native window", | |
| 59 | - | request: dto.NewBrowsingContextRequest{ | |
| 60 | - | TargetURI: "https://accounts.google.com/o/oauth2/v2/auth", | |
| 61 | - | TriggerKind: dto.TriggerScriptWindowOpen, | |
| 62 | - | TargetDisposition: dto.WindowDispositionNewPopup, | |
| 63 | - | AuthIntent: true, | |
| 64 | - | RequestContextDisposition: dto.RequestContextInheritParent, | |
| 65 | - | }, | |
| 66 | - | wantKind: dto.HostDecisionCreateNativeWin, | |
| 67 | - | }, | |
| 68 | - | { | |
| 69 | - | name: "ambiguous opener-coupled request prefers native", | |
| 70 | - | request: dto.NewBrowsingContextRequest{ | |
| 71 | - | TargetURI: "https://example.com/popup", | |
| 72 | - | TriggerKind: dto.TriggerUnknown, | |
| 73 | - | TargetDisposition: dto.WindowDispositionNewPopup, | |
| 74 | - | NoJavaScriptAccess: false, | |
| 75 | - | RequestContextDisposition: dto.RequestContextInheritParent, | |
| 76 | - | }, | |
| 77 | - | wantKind: dto.HostDecisionCreateNativeWin, | |
| 78 | - | }, | |
| 79 | - | { | |
| 80 | - | name: "empty target is denied", | |
| 81 | - | request: dto.NewBrowsingContextRequest{ | |
| 82 | - | TargetURI: "", | |
| 83 | - | TriggerKind: dto.TriggerLinkNewPage, | |
| 84 | - | RequestContextDisposition: dto.RequestContextInheritParent, | |
| 85 | - | }, | |
| 86 | - | wantKind: dto.HostDecisionDeny, | |
| 87 | - | }, | |
| 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}, | |
| 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}, | |
| 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}, | |
| 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}, | |
| 58 | + | {name: "auth intent precedes floating", request: dto.NewBrowsingContextRequest{SourceHost: dto.SourceHostFloating, TargetURI: "https://example.com/auth", AuthIntent: true}, wantKind: dto.HostDecisionCreateNativePopup, wantReason: dto.HostDecisionReasonAuthNativePopup}, | |
| 59 | + | {name: "native opener precedes floating", request: dto.NewBrowsingContextRequest{SourceHost: dto.SourceHostFloating, TargetURI: "https://example.com/open", RequiresNativeOpener: true}, wantKind: dto.HostDecisionCreateNativePopup, wantReason: dto.HostDecisionReasonNativeOpenerRequired}, | |
| 60 | + | {name: "ambiguous workspace opener stays native", request: dto.NewBrowsingContextRequest{SourceHost: dto.SourceHostWorkspace, TargetURI: "https://example.com/ambiguous", TriggerKind: dto.TriggerUnknown}, wantKind: dto.HostDecisionCreateNativePopup, wantReason: dto.HostDecisionReasonAmbiguousOpenerNative}, | |
| 61 | + | {name: "floating live named context is reused", request: dto.NewBrowsingContextRequest{SourceHost: dto.SourceHostFloating, TargetURI: "https://example.com/named", TargetFrameName: "shared", TriggerKind: dto.TriggerNamedTargetNavigation, PopupFeatures: dto.PopupFeatures{State: dto.PopupFeaturesNone}}, namedContextExist: true, wantKind: dto.HostDecisionReuseNamedPane, wantName: "shared", wantReason: dto.HostDecisionReasonNamedContextReuse}, | |
| 62 | + | {name: "floating noopener skips named reuse", request: dto.NewBrowsingContextRequest{SourceHost: dto.SourceHostFloating, TargetURI: "https://example.com/named", TargetFrameName: "shared", TriggerKind: dto.TriggerNamedTargetNavigation, NoJavaScriptAccess: true, PopupFeatures: dto.PopupFeatures{State: dto.PopupFeaturesNone}}, namedContextExist: true, wantKind: dto.HostDecisionCreateBrowserWindow, wantName: "shared", wantReason: dto.HostDecisionReasonFloatingBrowserWindow}, | |
| 63 | + | {name: "empty target is denied first", request: dto.NewBrowsingContextRequest{SourceHost: dto.SourceHostFloating, AuthIntent: true}, wantKind: dto.HostDecisionDeny, wantReason: dto.HostDecisionReasonEmptyTarget}, | |
| 88 | 64 | } | |
| 89 | 65 | ||
| 90 | 66 | for _, tt := range tests { | |
| 91 | 67 | t.Run(tt.name, func(t *testing.T) { | |
| 92 | 68 | t.Parallel() | |
| 93 | - | got := policy.Decide(tt.request, tt.namedContextExist) | |
| 69 | + | got := (browsingContextPolicy{}).Decide(tt.request, tt.namedContextExist) | |
| 94 | 70 | assert.Equal(t, tt.wantKind, got.Kind) | |
| 95 | 71 | assert.Equal(t, tt.wantName, got.BrowsingContextName) | |
| 72 | + | assert.Equal(t, tt.wantReason, got.ReasonCode) | |
| 73 | + | assert.NotEmpty(t, got.Reason) | |
| 96 | 74 | assert.Equal(t, dto.RequestContextInheritParent, got.RequestContextDisposition) | |
| 97 | 75 | }) | |
| 98 | 76 | } | |
| 99 | 77 | } | |
| 100 | 78 | ||
| 101 | - | func TestInferPopupTriggerKind_DoesNotTreatNoJavaScriptAccessFalseAsNativeSignal(t *testing.T) { | |
| 79 | + | func TestBuildPopupBrowsingContextRequestCarriesNormalizedMetadata(t *testing.T) { | |
| 102 | 80 | t.Parallel() | |
| 103 | 81 | ||
| 104 | - | req := port.PopupRequest{ | |
| 105 | - | TargetURI: "https://example.com/window-open", | |
| 106 | - | NoJavaScriptAccess: false, | |
| 107 | - | TargetDisposition: dto.WindowDispositionNewPopup, | |
| 108 | - | } | |
| 109 | - | ||
| 110 | - | assert.Equal(t, dto.TriggerScriptWindowOpen, inferPopupTriggerKind(req)) | |
| 82 | + | features := dto.PopupFeatures{State: dto.PopupFeaturesSpecified, Width: 320, WidthSet: true} | |
| 83 | + | got := buildPopupBrowsingContextRequest(port.PopupRequest{Engine: dto.BrowserEngineCEF, PopupFeatures: features, TargetURI: "https://example.com", ParentViewID: 42}) | |
| 84 | + | assert.Equal(t, dto.BrowserEngineCEF, got.Engine) | |
| 85 | + | assert.Equal(t, features, got.PopupFeatures) | |
| 111 | 86 | } | |
| 112 | 87 | ||
| 113 | - | func TestInferPopupTriggerKind_ClassifiesCurrentTabAsNavigation(t *testing.T) { | |
| 88 | + | func TestInferPopupTriggerKind(t *testing.T) { | |
| 114 | 89 | t.Parallel() | |
| 115 | - | ||
| 116 | - | tests := []struct { | |
| 117 | - | name string | |
| 118 | - | req port.PopupRequest | |
| 119 | - | want dto.TriggerKind | |
| 120 | - | }{ | |
| 121 | - | { | |
| 122 | - | name: "unnamed current-tab request stays a navigation", | |
| 123 | - | req: port.PopupRequest{ | |
| 124 | - | TargetURI: "https://example.com/docs", | |
| 125 | - | TargetDisposition: dto.WindowDispositionCurrentTab, | |
| 126 | - | }, | |
| 127 | - | want: dto.TriggerLinkNewPage, | |
| 128 | - | }, | |
| 129 | - | { | |
| 130 | - | name: "named current-tab request is treated as named target navigation", | |
| 131 | - | req: port.PopupRequest{ | |
| 132 | - | TargetURI: "https://example.com/docs", | |
| 133 | - | FrameName: "shared-pane", | |
| 134 | - | TargetDisposition: dto.WindowDispositionCurrentTab, | |
| 135 | - | }, | |
| 136 | - | want: dto.TriggerNamedTargetNavigation, | |
| 137 | - | }, | |
| 138 | - | } | |
| 139 | - | ||
| 140 | - | for _, tt := range tests { | |
| 141 | - | t.Run(tt.name, func(t *testing.T) { | |
| 142 | - | t.Parallel() | |
| 143 | - | assert.Equal(t, tt.want, inferPopupTriggerKind(tt.req)) | |
| 144 | - | }) | |
| 145 | - | } | |
| 90 | + | assert.Equal(t, dto.TriggerScriptWindowOpen, inferPopupTriggerKind(port.PopupRequest{TargetURI: "https://example.com/window-open", TargetDisposition: dto.WindowDispositionNewPopup})) | |
| 91 | + | assert.Equal(t, dto.TriggerLinkNewPage, inferPopupTriggerKind(port.PopupRequest{TargetDisposition: dto.WindowDispositionCurrentTab})) | |
| 92 | + | assert.Equal(t, dto.TriggerNamedTargetNavigation, inferPopupTriggerKind(port.PopupRequest{FrameName: "shared", TargetDisposition: dto.WindowDispositionCurrentTab})) | |
| 146 | 93 | } | |
| 147 | 94 | ||
| 148 | - | func TestInferPopupWindowDisposition_NormalizesBlankTarget(t *testing.T) { | |
| 95 | + | func TestInferPopupWindowDisposition(t *testing.T) { | |
| 149 | 96 | t.Parallel() | |
| 150 | - | ||
| 151 | - | tests := []struct { | |
| 152 | - | name string | |
| 153 | - | req port.PopupRequest | |
| 154 | - | want dto.WindowDisposition | |
| 155 | - | }{ | |
| 156 | - | { | |
| 157 | - | name: "mixed-case blank target is treated as new tab", | |
| 158 | - | req: port.PopupRequest{FrameName: " _BlAnK "}, | |
| 159 | - | want: dto.WindowDispositionNewTab, | |
| 160 | - | }, | |
| 161 | - | { | |
| 162 | - | name: "named target still defaults to popup", | |
| 163 | - | req: port.PopupRequest{FrameName: "shared-pane"}, | |
| 164 | - | want: dto.WindowDispositionNewPopup, | |
| 165 | - | }, | |
| 166 | - | } | |
| 167 | - | ||
| 168 | - | for _, tt := range tests { | |
| 169 | - | t.Run(tt.name, func(t *testing.T) { | |
| 170 | - | t.Parallel() | |
| 171 | - | assert.Equal(t, tt.want, inferPopupWindowDisposition(tt.req)) | |
| 172 | - | }) | |
| 173 | - | } | |
| 97 | + | assert.Equal(t, dto.WindowDispositionNewTab, inferPopupWindowDisposition(port.PopupRequest{FrameName: " _BlAnK "})) | |
| 98 | + | assert.Equal(t, dto.WindowDispositionNewPopup, inferPopupWindowDisposition(port.PopupRequest{FrameName: "shared-pane"})) | |
| 174 | 99 | } | |
| @@ -8,167 +8,92 @@ import ( | |||||
| 8 | "github.com/stretchr/testify/assert" | 8 | "github.com/stretchr/testify/assert" | ||
| 9 | ) | 9 | ) | ||
| 10 | 10 | ||||
| 11 | + | func TestPopupFeaturesRequestsPopupHost(t *testing.T) { | |||
| 12 | + | t.Parallel() | |||
| 13 | + | ||||
| 14 | + | tests := []struct { | |||
| 15 | + | name string | |||
| 16 | + | features dto.PopupFeatures | |||
| 17 | + | want bool | |||
| 18 | + | }{ | |||
| 19 | + | {name: "known featureless", features: dto.PopupFeatures{State: dto.PopupFeaturesNone}}, | |||
| 20 | + | {name: "zero width", features: dto.PopupFeatures{State: dto.PopupFeaturesSpecified, Width: 0, WidthSet: true}}, | |||
| 21 | + | {name: "positive width", features: dto.PopupFeatures{State: dto.PopupFeaturesSpecified, Width: 1, WidthSet: true}, want: true}, | |||
| 22 | + | {name: "positive height", features: dto.PopupFeatures{State: dto.PopupFeaturesSpecified, Height: 1, HeightSet: true}, want: true}, | |||
| 23 | + | {name: "hidden toolbar", features: dto.PopupFeatures{State: dto.PopupFeaturesSpecified, ToolbarVisible: false, ToolbarVisibilitySet: true}, want: true}, | |||
| 24 | + | {name: "hidden location bar", features: dto.PopupFeatures{State: dto.PopupFeaturesSpecified, LocationbarVisible: false, LocationbarVisibilitySet: true}, want: true}, | |||
| 25 | + | {name: "disabled resize", features: dto.PopupFeatures{State: dto.PopupFeaturesSpecified, Resizable: false, ResizableSet: true}, want: true}, | |||
| 26 | + | {name: "engine popup flag", features: dto.PopupFeatures{State: dto.PopupFeaturesSpecified, IsPopup: true, IsPopupSet: true}, want: true}, | |||
| 27 | + | {name: "visible chrome", features: dto.PopupFeatures{State: dto.PopupFeaturesSpecified, ToolbarVisible: true, ToolbarVisibilitySet: true}}, | |||
| 28 | + | {name: "resizable", features: dto.PopupFeatures{State: dto.PopupFeaturesSpecified, Resizable: true, ResizableSet: true}}, | |||
| 29 | + | } | |||
| 30 | + | ||||
| 31 | + | for _, tt := range tests { | |||
| 32 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 33 | + | t.Parallel() | |||
| 34 | + | assert.Equal(t, tt.want, tt.features.RequestsPopupHost()) | |||
| 35 | + | }) | |||
| 36 | + | } | |||
| 37 | + | } | |||
| 38 | + | ||||
| 11 | func TestBrowsingContextPolicyDecide(t *testing.T) { | 39 | func TestBrowsingContextPolicyDecide(t *testing.T) { | ||
| 12 | t.Parallel() | 40 | t.Parallel() | ||
| 13 | 41 | ||||
| 14 | - | policy := browsingContextPolicy{} | 42 | + | workspace := dto.NewBrowsingContextRequest{SourceHost: dto.SourceHostWorkspace, TargetURI: "https://example.com/docs", TargetDisposition: dto.WindowDispositionNewTab, TriggerKind: dto.TriggerLinkNewPage} |
| 15 | tests := []struct { | 43 | tests := []struct { | ||
| 16 | name string | 44 | name string | ||
| 17 | request dto.NewBrowsingContextRequest | 45 | request dto.NewBrowsingContextRequest | ||
| 18 | namedContextExist bool | 46 | namedContextExist bool | ||
| 19 | wantKind dto.HostDecisionKind | 47 | wantKind dto.HostDecisionKind | ||
| 20 | wantName string | 48 | wantName string | ||
| 49 | + | wantReason dto.HostDecisionReasonCode | |||
| 21 | }{ | 50 | }{ | ||
| 22 | - | { | 51 | + | {name: "workspace behavior remains pane hosted", request: workspace, wantKind: dto.HostDecisionCreatePane, wantReason: dto.HostDecisionReasonWorkspacePane}, |
| 23 | - | name: "blank target stays pane-hosted", | 52 | + | {name: "zero source preserves workspace behavior", request: dto.NewBrowsingContextRequest{TargetURI: "https://example.com/docs", TriggerKind: dto.TriggerLinkNewPage}, wantKind: dto.HostDecisionCreatePane, wantReason: dto.HostDecisionReasonWorkspacePane}, |
| 24 | - | request: dto.NewBrowsingContextRequest{ | 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}, |
| 25 | - | TargetURI: "https://example.com/docs", | 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}, |
| 26 | - | TargetFrameName: "_blank", | 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}, |
| 27 | - | TriggerKind: dto.TriggerLinkNewPage, | 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}, |
| 28 | - | TargetDisposition: dto.WindowDispositionNewTab, | 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}, |
| 29 | - | RequestContextDisposition: dto.RequestContextInheritParent, | 58 | + | {name: "auth intent precedes floating", request: dto.NewBrowsingContextRequest{SourceHost: dto.SourceHostFloating, TargetURI: "https://example.com/auth", AuthIntent: true}, wantKind: dto.HostDecisionCreateNativePopup, wantReason: dto.HostDecisionReasonAuthNativePopup}, |
| 30 | - | }, | 59 | + | {name: "native opener precedes floating", request: dto.NewBrowsingContextRequest{SourceHost: dto.SourceHostFloating, TargetURI: "https://example.com/open", RequiresNativeOpener: true}, wantKind: dto.HostDecisionCreateNativePopup, wantReason: dto.HostDecisionReasonNativeOpenerRequired}, |
| 31 | - | wantKind: dto.HostDecisionCreatePane, | 60 | + | {name: "ambiguous workspace opener stays native", request: dto.NewBrowsingContextRequest{SourceHost: dto.SourceHostWorkspace, TargetURI: "https://example.com/ambiguous", TriggerKind: dto.TriggerUnknown}, wantKind: dto.HostDecisionCreateNativePopup, wantReason: dto.HostDecisionReasonAmbiguousOpenerNative}, |
| 32 | - | }, | 61 | + | {name: "floating live named context is reused", request: dto.NewBrowsingContextRequest{SourceHost: dto.SourceHostFloating, TargetURI: "https://example.com/named", TargetFrameName: "shared", TriggerKind: dto.TriggerNamedTargetNavigation, PopupFeatures: dto.PopupFeatures{State: dto.PopupFeaturesNone}}, namedContextExist: true, wantKind: dto.HostDecisionReuseNamedPane, wantName: "shared", wantReason: dto.HostDecisionReasonNamedContextReuse}, |
| 33 | - | { | 62 | + | {name: "floating noopener skips named reuse", request: dto.NewBrowsingContextRequest{SourceHost: dto.SourceHostFloating, TargetURI: "https://example.com/named", TargetFrameName: "shared", TriggerKind: dto.TriggerNamedTargetNavigation, NoJavaScriptAccess: true, PopupFeatures: dto.PopupFeatures{State: dto.PopupFeaturesNone}}, namedContextExist: true, wantKind: dto.HostDecisionCreateBrowserWindow, wantName: "shared", wantReason: dto.HostDecisionReasonFloatingBrowserWindow}, |
| 34 | - | name: "named target reuses existing pane in same window", | 63 | + | {name: "empty target is denied first", request: dto.NewBrowsingContextRequest{SourceHost: dto.SourceHostFloating, AuthIntent: true}, wantKind: dto.HostDecisionDeny, wantReason: dto.HostDecisionReasonEmptyTarget}, |
| 35 | - | request: dto.NewBrowsingContextRequest{ | |||
| 36 | - | TargetURI: "https://example.com/second", | |||
| 37 | - | TargetFrameName: "shared-pane", | |||
| 38 | - | TriggerKind: dto.TriggerNamedTargetNavigation, | |||
| 39 | - | TargetDisposition: dto.WindowDispositionNewPopup, | |||
| 40 | - | RequestContextDisposition: dto.RequestContextInheritParent, | |||
| 41 | - | }, | |||
| 42 | - | namedContextExist: true, | |||
| 43 | - | wantKind: dto.HostDecisionReuseNamedPane, | |||
| 44 | - | wantName: "shared-pane", | |||
| 45 | - | }, | |||
| 46 | - | { | |||
| 47 | - | name: "ordinary window.open stays pane-hosted", | |||
| 48 | - | request: dto.NewBrowsingContextRequest{ | |||
| 49 | - | TargetURI: "https://example.com/help", | |||
| 50 | - | TriggerKind: dto.TriggerScriptWindowOpen, | |||
| 51 | - | TargetDisposition: dto.WindowDispositionNewPopup, | |||
| 52 | - | NoJavaScriptAccess: false, | |||
| 53 | - | RequestContextDisposition: dto.RequestContextInheritParent, | |||
| 54 | - | }, | |||
| 55 | - | wantKind: dto.HostDecisionCreatePane, | |||
| 56 | - | }, | |||
| 57 | - | { | |||
| 58 | - | name: "auth intent forces native window", | |||
| 59 | - | request: dto.NewBrowsingContextRequest{ | |||
| 60 | - | TargetURI: "https://accounts.google.com/o/oauth2/v2/auth", | |||
| 61 | - | TriggerKind: dto.TriggerScriptWindowOpen, | |||
| 62 | - | TargetDisposition: dto.WindowDispositionNewPopup, | |||
| 63 | - | AuthIntent: true, | |||
| 64 | - | RequestContextDisposition: dto.RequestContextInheritParent, | |||
| 65 | - | }, | |||
| 66 | - | wantKind: dto.HostDecisionCreateNativeWin, | |||
| 67 | - | }, | |||
| 68 | - | { | |||
| 69 | - | name: "ambiguous opener-coupled request prefers native", | |||
| 70 | - | request: dto.NewBrowsingContextRequest{ | |||
| 71 | - | TargetURI: "https://example.com/popup", | |||
| 72 | - | TriggerKind: dto.TriggerUnknown, | |||
| 73 | - | TargetDisposition: dto.WindowDispositionNewPopup, | |||
| 74 | - | NoJavaScriptAccess: false, | |||
| 75 | - | RequestContextDisposition: dto.RequestContextInheritParent, | |||
| 76 | - | }, | |||
| 77 | - | wantKind: dto.HostDecisionCreateNativeWin, | |||
| 78 | - | }, | |||
| 79 | - | { | |||
| 80 | - | name: "empty target is denied", | |||
| 81 | - | request: dto.NewBrowsingContextRequest{ | |||
| 82 | - | TargetURI: "", | |||
| 83 | - | TriggerKind: dto.TriggerLinkNewPage, | |||
| 84 | - | RequestContextDisposition: dto.RequestContextInheritParent, | |||
| 85 | - | }, | |||
| 86 | - | wantKind: dto.HostDecisionDeny, | |||
| 87 | - | }, | |||
| 88 | } | 64 | } | ||
| 89 | 65 | ||||
| 90 | for _, tt := range tests { | 66 | for _, tt := range tests { | ||
| 91 | t.Run(tt.name, func(t *testing.T) { | 67 | t.Run(tt.name, func(t *testing.T) { | ||
| 92 | t.Parallel() | 68 | t.Parallel() | ||
| 93 | - | got := policy.Decide(tt.request, tt.namedContextExist) | 69 | + | got := (browsingContextPolicy{}).Decide(tt.request, tt.namedContextExist) |
| 94 | assert.Equal(t, tt.wantKind, got.Kind) | 70 | assert.Equal(t, tt.wantKind, got.Kind) | ||
| 95 | assert.Equal(t, tt.wantName, got.BrowsingContextName) | 71 | assert.Equal(t, tt.wantName, got.BrowsingContextName) | ||
| 72 | + | assert.Equal(t, tt.wantReason, got.ReasonCode) | |||
| 73 | + | assert.NotEmpty(t, got.Reason) | |||
| 96 | assert.Equal(t, dto.RequestContextInheritParent, got.RequestContextDisposition) | 74 | assert.Equal(t, dto.RequestContextInheritParent, got.RequestContextDisposition) | ||
| 97 | }) | 75 | }) | ||
| 98 | } | 76 | } | ||
| 99 | } | 77 | } | ||
| 100 | 78 | ||||
| 101 | - | func TestInferPopupTriggerKind_DoesNotTreatNoJavaScriptAccessFalseAsNativeSignal(t *testing.T) { | 79 | + | func TestBuildPopupBrowsingContextRequestCarriesNormalizedMetadata(t *testing.T) { |
| 102 | t.Parallel() | 80 | t.Parallel() | ||
| 103 | 81 | ||||
| 104 | - | req := port.PopupRequest{ | 82 | + | features := dto.PopupFeatures{State: dto.PopupFeaturesSpecified, Width: 320, WidthSet: true} |
| 105 | - | TargetURI: "https://example.com/window-open", | 83 | + | got := buildPopupBrowsingContextRequest(port.PopupRequest{Engine: dto.BrowserEngineCEF, PopupFeatures: features, TargetURI: "https://example.com", ParentViewID: 42}) |
| 106 | - | NoJavaScriptAccess: false, | 84 | + | assert.Equal(t, dto.BrowserEngineCEF, got.Engine) |
| 107 | - | TargetDisposition: dto.WindowDispositionNewPopup, | 85 | + | assert.Equal(t, features, got.PopupFeatures) |
| 108 | - | } | |||
| 109 | - | ||||
| 110 | - | assert.Equal(t, dto.TriggerScriptWindowOpen, inferPopupTriggerKind(req)) | |||
| 111 | } | 86 | } | ||
| 112 | 87 | ||||
| 113 | - | func TestInferPopupTriggerKind_ClassifiesCurrentTabAsNavigation(t *testing.T) { | 88 | + | func TestInferPopupTriggerKind(t *testing.T) { |
| 114 | t.Parallel() | 89 | t.Parallel() | ||
| 115 | - | 90 | + | assert.Equal(t, dto.TriggerScriptWindowOpen, inferPopupTriggerKind(port.PopupRequest{TargetURI: "https://example.com/window-open", TargetDisposition: dto.WindowDispositionNewPopup})) | |
| 116 | - | tests := []struct { | 91 | + | assert.Equal(t, dto.TriggerLinkNewPage, inferPopupTriggerKind(port.PopupRequest{TargetDisposition: dto.WindowDispositionCurrentTab})) |
| 117 | - | name string | 92 | + | assert.Equal(t, dto.TriggerNamedTargetNavigation, inferPopupTriggerKind(port.PopupRequest{FrameName: "shared", TargetDisposition: dto.WindowDispositionCurrentTab})) |
| 118 | - | req port.PopupRequest | |||
| 119 | - | want dto.TriggerKind | |||
| 120 | - | }{ | |||
| 121 | - | { | |||
| 122 | - | name: "unnamed current-tab request stays a navigation", | |||
| 123 | - | req: port.PopupRequest{ | |||
| 124 | - | TargetURI: "https://example.com/docs", | |||
| 125 | - | TargetDisposition: dto.WindowDispositionCurrentTab, | |||
| 126 | - | }, | |||
| 127 | - | want: dto.TriggerLinkNewPage, | |||
| 128 | - | }, | |||
| 129 | - | { | |||
| 130 | - | name: "named current-tab request is treated as named target navigation", | |||
| 131 | - | req: port.PopupRequest{ | |||
| 132 | - | TargetURI: "https://example.com/docs", | |||
| 133 | - | FrameName: "shared-pane", | |||
| 134 | - | TargetDisposition: dto.WindowDispositionCurrentTab, | |||
| 135 | - | }, | |||
| 136 | - | want: dto.TriggerNamedTargetNavigation, | |||
| 137 | - | }, | |||
| 138 | - | } | |||
| 139 | - | ||||
| 140 | - | for _, tt := range tests { | |||
| 141 | - | t.Run(tt.name, func(t *testing.T) { | |||
| 142 | - | t.Parallel() | |||
| 143 | - | assert.Equal(t, tt.want, inferPopupTriggerKind(tt.req)) | |||
| 144 | - | }) | |||
| 145 | - | } | |||
| 146 | } | 93 | } | ||
| 147 | 94 | ||||
| 148 | - | func TestInferPopupWindowDisposition_NormalizesBlankTarget(t *testing.T) { | 95 | + | func TestInferPopupWindowDisposition(t *testing.T) { |
| 149 | t.Parallel() | 96 | t.Parallel() | ||
| 150 | - | 97 | + | assert.Equal(t, dto.WindowDispositionNewTab, inferPopupWindowDisposition(port.PopupRequest{FrameName: " _BlAnK "})) | |
| 151 | - | tests := []struct { | 98 | + | assert.Equal(t, dto.WindowDispositionNewPopup, inferPopupWindowDisposition(port.PopupRequest{FrameName: "shared-pane"})) |
| 152 | - | name string | |||
| 153 | - | req port.PopupRequest | |||
| 154 | - | want dto.WindowDisposition | |||
| 155 | - | }{ | |||
| 156 | - | { | |||
| 157 | - | name: "mixed-case blank target is treated as new tab", | |||
| 158 | - | req: port.PopupRequest{FrameName: " _BlAnK "}, | |||
| 159 | - | want: dto.WindowDispositionNewTab, | |||
| 160 | - | }, | |||
| 161 | - | { | |||
| 162 | - | name: "named target still defaults to popup", | |||
| 163 | - | req: port.PopupRequest{FrameName: "shared-pane"}, | |||
| 164 | - | want: dto.WindowDispositionNewPopup, | |||
| 165 | - | }, | |||
| 166 | - | } | |||
| 167 | - | ||||
| 168 | - | for _, tt := range tests { | |||
| 169 | - | t.Run(tt.name, func(t *testing.T) { | |||
| 170 | - | t.Parallel() | |||
| 171 | - | assert.Equal(t, tt.want, inferPopupWindowDisposition(tt.req)) | |||
| 172 | - | }) | |||
| 173 | - | } | |||
| 174 | } | 99 | } | ||
M internal/ui/coordinator/content/popup_manager.go +1 -1
| @@ -549,7 +549,7 @@ func (pm *popupManager) handlePopupCreate( | |||
| 549 | 549 | decision.Kind = dto.HostDecisionCreatePane | |
| 550 | 550 | decision.ReuseContextName = "" | |
| 551 | 551 | decision.Reason = "named browsing context unavailable; creating replacement pane" | |
| 552 | - | case dto.HostDecisionCreateNativeWin: | |
| 552 | + | case dto.HostDecisionCreateNativePopup: | |
| 553 | 553 | return pm.openNativePopup(ctx, parentPaneID, parentID, parentURIAtOpen, req, decision) | |
| 554 | 554 | case dto.HostDecisionCreatePane: | |
| 555 | 555 | // Continue below. | |
| @@ -549,7 +549,7 @@ func (pm *popupManager) handlePopupCreate( | |||||
| 549 | decision.Kind = dto.HostDecisionCreatePane | 549 | decision.Kind = dto.HostDecisionCreatePane | ||
| 550 | decision.ReuseContextName = "" | 550 | decision.ReuseContextName = "" | ||
| 551 | decision.Reason = "named browsing context unavailable; creating replacement pane" | 551 | decision.Reason = "named browsing context unavailable; creating replacement pane" | ||
| 552 | - | case dto.HostDecisionCreateNativeWin: | 552 | + | case dto.HostDecisionCreateNativePopup: |
| 553 | return pm.openNativePopup(ctx, parentPaneID, parentID, parentURIAtOpen, req, decision) | 553 | return pm.openNativePopup(ctx, parentPaneID, parentID, parentURIAtOpen, req, decision) | ||
| 554 | case dto.HostDecisionCreatePane: | 554 | case dto.HostDecisionCreatePane: | ||
| 555 | // Continue below. | 555 | // Continue below. | ||