detached a6c7a7f

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 VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
11 files · +359 -189
M .mockery.yaml +1 -0
@@ -85,6 +85,7 @@ packages:
8585 FaviconDatabase: {}
8686 IdleInhibitor: {}
8787 PopupLifecycleCapable: {}
88+ PopupFeatureResolver: {}
8889 PopupOpenerCapable: {}
8990 OAuthCallbackCapable: {}
9091 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 @@
11 package dto
22
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.
656 type WindowDisposition string
757
858 const (
9- // WindowDispositionCurrentTab replaces the current document in the same browsing context.
1059 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"
1763 )
1864
1965 // TriggerKind categorizes what triggered a browsing-context request.
@@ -27,22 +73,21 @@ const (
2773 TriggerAuthPopupRequest TriggerKind = "auth-popup-request"
2874 )
2975
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.
3277 type RequestContextDisposition string
3378
3479 const (
35- // RequestContextInheritParent preserves the parent request context/session.
3680 RequestContextInheritParent RequestContextDisposition = "inherit-parent"
37- // RequestContextIsolate starts from a fresh unrelated request context.
38- RequestContextIsolate RequestContextDisposition = "isolate"
81+ RequestContextIsolate RequestContextDisposition = "isolate"
3982 )
4083
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.
4385 type NewBrowsingContextRequest struct {
4486 ParentWebViewID uint64
4587
88+ Engine BrowserEngineKind
89+ SourceHost SourceHostKind
90+
4691 SourceBrowserID int32
4792 SourceFrameID string
4893 SourceFrameURL string
@@ -53,7 +98,7 @@ type NewBrowsingContextRequest struct {
5398
5499 IsUserGesture bool
55100 NoJavaScriptAccess bool
56- WindowFeatures string
101+ PopupFeatures PopupFeatures
57102
58103 TriggerKind TriggerKind
59104 AuthIntent bool
@@ -61,14 +106,43 @@ type NewBrowsingContextRequest struct {
61106 RequestContextDisposition RequestContextDisposition
62107 }
63108
64-// HostDecisionKind categorizes the approved host choices for a new browsing context.
109+// HostDecisionKind categorizes approved hosts for a new browsing context.
65110 type HostDecisionKind string
66111
67112 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"
72146 )
73147
74148 // HostDecision represents the output of browsing-context policy.
@@ -80,5 +154,6 @@ type HostDecision struct {
80154
81155 RequestContextDisposition RequestContextDisposition
82156 RequiresNativeOpener bool
157+ ReasonCode HostDecisionReasonCode
83158 Reason string
84159 }
@@ -1,19 +1,65 @@
1 package dto1 package dto
2 2
3-// WindowDisposition describes how the browsing context should be presented3+// SourceHostKind identifies the product host that originated a browsing-context request.
4-// relative to the source frame. Values mirror Chromium/CEF/WebKit navigation4+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 string56 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 the76+// RequestContextDisposition makes request-context inheritance explicit at the policy seam.
31-// policy seam without leaking infrastructure request-context types upward.
32 type RequestContextDisposition string77 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/UI84+// 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 uint6486 ParentWebViewID uint64
45 87
88+ Engine BrowserEngineKind
89+ SourceHost SourceHostKind
90+
46 SourceBrowserID int3291 SourceBrowserID int32
47 SourceFrameID string92 SourceFrameID string
48 SourceFrameURL string93 SourceFrameURL string
@@ -53,7 +98,7 @@ type NewBrowsingContextRequest struct {
53 98
54 IsUserGesture bool99 IsUserGesture bool
55 NoJavaScriptAccess bool100 NoJavaScriptAccess bool
56- WindowFeatures string101+ PopupFeatures PopupFeatures
57 102
58 TriggerKind TriggerKind103 TriggerKind TriggerKind
59 AuthIntent bool104 AuthIntent bool
@@ -61,14 +106,43 @@ type NewBrowsingContextRequest struct {
61 RequestContextDisposition RequestContextDisposition106 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 string110 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 RequestContextDisposition155 RequestContextDisposition RequestContextDisposition
82 RequiresNativeOpener bool156 RequiresNativeOpener bool
157+ ReasonCode HostDecisionReasonCode
83 Reason string158 Reason string
84 }159 }
M internal/application/port/mocks/webview.go +72 -0
@@ -7,6 +7,7 @@ package mocks
77 import (
88 "context"
99
10+ "github.com/bnema/dumber/internal/application/dto"
1011 "github.com/bnema/dumber/internal/application/port"
1112 mock "github.com/stretchr/testify/mock"
1213 )
@@ -2631,6 +2632,77 @@ func (_c *MockPopupLifecycleCapable_Show_Call) RunAndReturn(run func()) *MockPop
26312632 return _c
26322633 }
26332634
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+
26342706 // 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.
26352707 // The first argument is typically a *testing.T value.
26362708 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 _c2632 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 {
7777 SourceFrameID string
7878 SourceFrameURL string
7979
80+ Engine dto.BrowserEngineKind
8081 TargetDisposition dto.WindowDisposition
81- WindowFeatures string
82+ PopupFeatures dto.PopupFeatures
8283 }
8384
8485 // Texture represents a graphics texture returned by the engine.
@@ -342,6 +343,12 @@ type PopupLifecycleCapable interface {
342343 PrimePopupNavigation(uri string)
343344 }
344345
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+
345352 // PopupOpenerCapable is implemented by popup WebViews that can emulate opener
346353 // semantics when a popup must be created directly instead of through a native
347354 // related-popup lifecycle, and that can report or observe synthetic opener
@@ -77,8 +77,9 @@ type PopupRequest struct {
77 SourceFrameID string77 SourceFrameID string
78 SourceFrameURL string78 SourceFrameURL string
79 79
80+ Engine dto.BrowserEngineKind
80 TargetDisposition dto.WindowDisposition81 TargetDisposition dto.WindowDisposition
81- WindowFeatures string82+ 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 opener352 // PopupOpenerCapable is implemented by popup WebViews that can emulate opener
346 // semantics when a popup must be created directly instead of through a native353 // semantics when a popup must be created directly instead of through a native
347 // related-popup lifecycle, and that can report or observe synthetic opener354 // 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(
684684
685685 cefPopup.setPopupNoJavaScriptAccess(requestNoJavaScriptAccess)
686686 switch decision.Kind {
687- case dto.HostDecisionCreateNativeWin:
687+ case dto.HostDecisionCreateNativePopup:
688688 if cefPopup.prepareNativePopup(popupID, targetURL, windowInfo, clientSlot, settings) {
689689 logging.FromContext(h.currentContext()).Debug().
690690 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) {
359359 parentWV := &WebView{ctx: context.Background(), id: 51}
360360 popupWV := &WebView{ctx: context.Background(), id: 52}
361361 popupWV.markNativePopupCandidate(parentWV)
362- popupWV.SetBrowsingContextHostDecision(dto.HostDecision{Kind: dto.HostDecisionCreateNativeWin})
362+ popupWV.SetBrowsingContextHostDecision(dto.HostDecision{Kind: dto.HostDecisionCreateNativePopup})
363363 aborted := false
364364 popupWV.SetNativePopupHostAbort(func() { aborted = true })
365365 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 := false363 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) {
1111 wv := &WebView{}
1212 abortCalled := false
1313
14- wv.SetBrowsingContextHostDecision(dto.HostDecision{Kind: dto.HostDecisionCreateNativeWin})
14+ wv.SetBrowsingContextHostDecision(dto.HostDecision{Kind: dto.HostDecisionCreateNativePopup})
1515 wv.SetNativePopupHostAbort(func() { abortCalled = true })
1616
1717 wv.ResetForPoolReuse()
@@ -11,7 +11,7 @@ func TestResetForPoolReuse_ClearsBrowsingContextHostState(t *testing.T) {
11 wv := &WebView{}11 wv := &WebView{}
12 abortCalled := false12 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 (
55
66 "github.com/bnema/dumber/internal/application/dto"
77 "github.com/bnema/dumber/internal/application/port"
8+ "github.com/rs/zerolog"
89 )
910
1011 type browsingContextPolicy struct{}
@@ -18,46 +19,73 @@ func (browsingContextPolicy) Decide(req dto.NewBrowsingContextRequest, namedCont
1819 decision.RequestContextDisposition = dto.RequestContextInheritParent
1920 }
2021
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")
4131 }
4232
4333 name := reusableBrowsingContextName(req.TargetFrameName)
44- if namedContextExists && name != "" {
45- decision.Kind = dto.HostDecisionReuseNamedPane
34+ if namedContextExists && name != "" && !req.NoJavaScriptAccess {
4635 decision.ReuseContextName = name
4736 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")
5038 }
51-
52- decision.Kind = dto.HostDecisionCreatePane
5339 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
5562 return decision
5663 }
5764
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+
5885 func buildPopupBrowsingContextRequest(req port.PopupRequest) dto.NewBrowsingContextRequest {
5986 return dto.NewBrowsingContextRequest{
6087 ParentWebViewID: uint64(req.ParentViewID),
88+ Engine: req.Engine,
6189 SourceBrowserID: req.SourceBrowserID,
6290 SourceFrameID: req.SourceFrameID,
6391 SourceFrameURL: req.SourceFrameURL,
@@ -66,7 +94,7 @@ func buildPopupBrowsingContextRequest(req port.PopupRequest) dto.NewBrowsingCont
6694 TargetDisposition: inferPopupWindowDisposition(req),
6795 IsUserGesture: req.IsUserGesture,
6896 NoJavaScriptAccess: req.NoJavaScriptAccess,
69- WindowFeatures: req.WindowFeatures,
97+ PopupFeatures: req.PopupFeatures,
7098 TriggerKind: inferPopupTriggerKind(req),
7199 AuthIntent: IsOAuthURL(req.TargetURI),
72100 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.RequestContextInheritParent19 decision.RequestContextDisposition = dto.RequestContextInheritParent
19 }20 }
20 21
21- if strings.TrimSpace(req.TargetURI) == "" {22+ switch {
22- decision.Kind = dto.HostDecisionDeny23+ case strings.TrimSpace(req.TargetURI) == "":
23- decision.Reason = "empty target URI"24+ return completeHostDecision(decision, dto.HostDecisionDeny, dto.HostDecisionReasonEmptyTarget, "empty target URI")
24- return decision25+ 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.HostDecisionCreateNativeWin29+ 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 = name35 decision.ReuseContextName = name
47 decision.BrowsingContextName = name36 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 = name39 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 decision62 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 (
88 "github.com/stretchr/testify/assert"
99 )
1010
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+
1139 func TestBrowsingContextPolicyDecide(t *testing.T) {
1240 t.Parallel()
1341
14- policy := browsingContextPolicy{}
42+ workspace := dto.NewBrowsingContextRequest{SourceHost: dto.SourceHostWorkspace, TargetURI: "https://example.com/docs", TargetDisposition: dto.WindowDispositionNewTab, TriggerKind: dto.TriggerLinkNewPage}
1543 tests := []struct {
1644 name string
1745 request dto.NewBrowsingContextRequest
1846 namedContextExist bool
1947 wantKind dto.HostDecisionKind
2048 wantName string
49+ wantReason dto.HostDecisionReasonCode
2150 }{
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},
8864 }
8965
9066 for _, tt := range tests {
9167 t.Run(tt.name, func(t *testing.T) {
9268 t.Parallel()
93- got := policy.Decide(tt.request, tt.namedContextExist)
69+ got := (browsingContextPolicy{}).Decide(tt.request, tt.namedContextExist)
9470 assert.Equal(t, tt.wantKind, got.Kind)
9571 assert.Equal(t, tt.wantName, got.BrowsingContextName)
72+ assert.Equal(t, tt.wantReason, got.ReasonCode)
73+ assert.NotEmpty(t, got.Reason)
9674 assert.Equal(t, dto.RequestContextInheritParent, got.RequestContextDisposition)
9775 })
9876 }
9977 }
10078
101-func TestInferPopupTriggerKind_DoesNotTreatNoJavaScriptAccessFalseAsNativeSignal(t *testing.T) {
79+func TestBuildPopupBrowsingContextRequestCarriesNormalizedMetadata(t *testing.T) {
10280 t.Parallel()
10381
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)
11186 }
11287
113-func TestInferPopupTriggerKind_ClassifiesCurrentTabAsNavigation(t *testing.T) {
88+func TestInferPopupTriggerKind(t *testing.T) {
11489 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}))
14693 }
14794
148-func TestInferPopupWindowDisposition_NormalizesBlankTarget(t *testing.T) {
95+func TestInferPopupWindowDisposition(t *testing.T) {
14996 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"}))
17499 }
@@ -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 string44 name string
17 request dto.NewBrowsingContextRequest45 request dto.NewBrowsingContextRequest
18 namedContextExist bool46 namedContextExist bool
19 wantKind dto.HostDecisionKind47 wantKind dto.HostDecisionKind
20 wantName string48 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 string92+ 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(
549549 decision.Kind = dto.HostDecisionCreatePane
550550 decision.ReuseContextName = ""
551551 decision.Reason = "named browsing context unavailable; creating replacement pane"
552- case dto.HostDecisionCreateNativeWin:
552+ case dto.HostDecisionCreateNativePopup:
553553 return pm.openNativePopup(ctx, parentPaneID, parentID, parentURIAtOpen, req, decision)
554554 case dto.HostDecisionCreatePane:
555555 // Continue below.
@@ -549,7 +549,7 @@ func (pm *popupManager) handlePopupCreate(
549 decision.Kind = dto.HostDecisionCreatePane549 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.

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close