detached 986803d

refactor(browsing): centralize context routing contracts

commit
986803d32a14fdd078e151f0f449ab2e0de5f0af
author
brice · · 2026-07-27 08:15 UTC
parent
cf4e6b2
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
18 files · +2281 -2178
M cmd/dumber/main.go +0 -62
@@ -146,68 +146,6 @@ type startupTiming struct {
146146 configComplete time.Time
147147 }
148148
149-func environmentValue(environ []string, name string) string {
150- prefix := name + "="
151- for _, entry := range environ {
152- if strings.HasPrefix(entry, prefix) {
153- return strings.TrimPrefix(entry, prefix)
154- }
155- }
156- return ""
157-}
158-
159-func replaceEnvironmentValue(environ []string, name, value string) []string {
160- prefix := name + "="
161- updated := make([]string, 0, len(environ)+1)
162- for _, entry := range environ {
163- if !strings.HasPrefix(entry, prefix) {
164- updated = append(updated, entry)
165- }
166- }
167- return append(updated, prefix+value)
168-}
169-
170-func runtimeSafetyEnvironment(environ []string) ([]string, bool) {
171- updated := append([]string(nil), environ...)
172- const setting = "gcshrinkstackoff"
173- parts := strings.Split(environmentValue(updated, "GODEBUG"), ",")
174- kept := make([]string, 0, len(parts)+1)
175- alreadySafe := false
176- for _, part := range parts {
177- part = strings.TrimSpace(part)
178- if part == "" {
179- continue
180- }
181- key, value, found := strings.Cut(part, "=")
182- if key == setting {
183- alreadySafe = found && value == "1"
184- continue
185- }
186- kept = append(kept, part)
187- }
188- if alreadySafe {
189- return updated, false
190- }
191- kept = append(kept, setting+"=1")
192- updated = replaceEnvironmentValue(updated, "GODEBUG", strings.Join(kept, ","))
193- return updated, true
194-}
195-
196-func ensureRuntimeSafety() error {
197- environ, reexec := runtimeSafetyEnvironment(os.Environ())
198- if !reexec {
199- return nil
200- }
201- executable, err := os.Executable()
202- if err != nil {
203- return fmt.Errorf("resolve executable for runtime safety re-exec: %w", err)
204- }
205- if err := syscall.Exec(executable, os.Args, environ); err != nil {
206- return fmt.Errorf("runtime safety re-exec: %w", err)
207- }
208- return nil
209-}
210-
211149 func main() {
212150 // purego callbacks cross foreign CEF/GTK stacks. Go stack shrinking can
213151 // invalidate frames retained across those boundaries and make the GC abort
M cmd/dumber/main_test.go +0 -26
@@ -14,32 +14,6 @@ import (
1414 "github.com/rs/zerolog"
1515 )
1616
17-func TestRuntimeSafetyEnvironmentDisablesStackShrinkingBeforeStartup(t *testing.T) {
18- tests := []struct {
19- name string
20- environ []string
21- wantGODEBUG string
22- wantReexec bool
23- }{
24- {name: "missing", environ: []string{"HOME=/tmp"}, wantGODEBUG: "gcshrinkstackoff=1", wantReexec: true},
25- {name: "preserves settings", environ: []string{"GODEBUG=gctrace=1"}, wantGODEBUG: "gctrace=1,gcshrinkstackoff=1", wantReexec: true},
26- {name: "overrides unsafe value", environ: []string{"GODEBUG=gcshrinkstackoff=0,gctrace=1"}, wantGODEBUG: "gctrace=1,gcshrinkstackoff=1", wantReexec: true},
27- {name: "already safe", environ: []string{"GODEBUG=gctrace=1,gcshrinkstackoff=1"}, wantGODEBUG: "gctrace=1,gcshrinkstackoff=1", wantReexec: false},
28- {name: "forged marker is ignored", environ: []string{"DUMBER_RUNTIME_SAFETY_REEXEC=1", "GODEBUG=gcshrinkstackoff=0"}, wantGODEBUG: "gcshrinkstackoff=1", wantReexec: true},
29- }
30- for _, tt := range tests {
31- t.Run(tt.name, func(t *testing.T) {
32- got, reexec := runtimeSafetyEnvironment(tt.environ)
33- if reexec != tt.wantReexec {
34- t.Fatalf("reexec = %v, want %v", reexec, tt.wantReexec)
35- }
36- if value := environmentValue(got, "GODEBUG"); value != tt.wantGODEBUG {
37- t.Fatalf("GODEBUG = %q, want %q", value, tt.wantGODEBUG)
38- }
39- })
40- }
41-}
42-
4317 func TestLaunchModeFromArgs_DetectsStandaloneOmnibox(t *testing.T) {
4418 mode, _ := launchModeFromArgs([]string{"dumber", "omnibox"})
4519 if mode != launchModeStandaloneOmnibox {
A cmd/dumber/runtime_safety.go +70 -0
@@ -0,0 +1,70 @@
1+package main
2+
3+import (
4+ "fmt"
5+ "os"
6+ "strings"
7+ "syscall"
8+)
9+
10+func environmentValue(environ []string, name string) string {
11+ prefix := name + "="
12+ for _, entry := range environ {
13+ if strings.HasPrefix(entry, prefix) {
14+ return strings.TrimPrefix(entry, prefix)
15+ }
16+ }
17+ return ""
18+}
19+
20+func replaceEnvironmentValue(environ []string, name, value string) []string {
21+ prefix := name + "="
22+ updated := make([]string, 0, len(environ)+1)
23+ for _, entry := range environ {
24+ if !strings.HasPrefix(entry, prefix) {
25+ updated = append(updated, entry)
26+ }
27+ }
28+ return append(updated, prefix+value)
29+}
30+
31+func runtimeSafetyEnvironment(environ []string) ([]string, bool) {
32+ updated := append([]string(nil), environ...)
33+ const setting = "gcshrinkstackoff"
34+ parts := strings.Split(environmentValue(updated, "GODEBUG"), ",")
35+ kept := make([]string, 0, len(parts)+1)
36+ alreadySafe := false
37+ for _, part := range parts {
38+ part = strings.TrimSpace(part)
39+ if part == "" {
40+ continue
41+ }
42+ key, value, found := strings.Cut(part, "=")
43+ if key == setting {
44+ alreadySafe = found && value == "1"
45+ continue
46+ }
47+ kept = append(kept, part)
48+ }
49+ if alreadySafe {
50+ return updated, false
51+ }
52+ kept = append(kept, setting+"=1")
53+ updated = replaceEnvironmentValue(updated, "GODEBUG", strings.Join(kept, ","))
54+ return updated, true
55+}
56+
57+func ensureRuntimeSafety() error {
58+ environ, reexec := runtimeSafetyEnvironment(os.Environ())
59+ if !reexec {
60+ return nil
61+ }
62+ executable, err := os.Executable()
63+ if err != nil {
64+ return fmt.Errorf("resolve executable for runtime safety re-exec: %w", err)
65+ }
66+ if err := syscall.Exec(executable, os.Args, environ); err != nil {
67+ return fmt.Errorf("runtime safety re-exec: %w", err)
68+ }
69+ return nil
70+}
A cmd/dumber/runtime_safety_test.go +29 -0
@@ -0,0 +1,29 @@
1+package main
2+
3+import "testing"
4+
5+func TestRuntimeSafetyEnvironmentDisablesStackShrinkingBeforeStartup(t *testing.T) {
6+ tests := []struct {
7+ name string
8+ environ []string
9+ wantGODEBUG string
10+ wantReexec bool
11+ }{
12+ {name: "missing", environ: []string{"HOME=/tmp"}, wantGODEBUG: "gcshrinkstackoff=1", wantReexec: true},
13+ {name: "preserves settings", environ: []string{"GODEBUG=gctrace=1"}, wantGODEBUG: "gctrace=1,gcshrinkstackoff=1", wantReexec: true},
14+ {name: "overrides unsafe value", environ: []string{"GODEBUG=gcshrinkstackoff=0,gctrace=1"}, wantGODEBUG: "gctrace=1,gcshrinkstackoff=1", wantReexec: true},
15+ {name: "already safe", environ: []string{"GODEBUG=gctrace=1,gcshrinkstackoff=1"}, wantGODEBUG: "gctrace=1,gcshrinkstackoff=1", wantReexec: false},
16+ {name: "forged marker is ignored", environ: []string{"DUMBER_RUNTIME_SAFETY_REEXEC=1", "GODEBUG=gcshrinkstackoff=0"}, wantGODEBUG: "gcshrinkstackoff=1", wantReexec: true},
17+ }
18+ for _, tt := range tests {
19+ t.Run(tt.name, func(t *testing.T) {
20+ got, reexec := runtimeSafetyEnvironment(tt.environ)
21+ if reexec != tt.wantReexec {
22+ t.Fatalf("reexec = %v, want %v", reexec, tt.wantReexec)
23+ }
24+ if value := environmentValue(got, "GODEBUG"); value != tt.wantGODEBUG {
25+ t.Fatalf("GODEBUG = %q, want %q", value, tt.wantGODEBUG)
26+ }
27+ })
28+ }
29+}
M internal/application/dto/browsing_context.go +0 -11
@@ -41,17 +41,6 @@ type PopupFeatures struct {
4141 IsPopup, IsPopupSet bool
4242 }
4343
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-
5544 // WindowDisposition describes how the browsing context should be presented.
5645 type WindowDisposition string
5746
A internal/application/usecase/browsing_context_policy.go +89 -0
@@ -0,0 +1,89 @@
1+package usecase
2+
3+import (
4+ "strings"
5+
6+ "github.com/bnema/dumber/internal/application/dto"
7+)
8+
9+// BrowsingContextPolicy selects the product host for a normalized browsing-context request.
10+type BrowsingContextPolicy struct{}
11+
12+// Decide returns the host decision without performing any I/O.
13+func (BrowsingContextPolicy) Decide(req dto.NewBrowsingContextRequest, namedContextExists bool) dto.HostDecision {
14+ decision := dto.HostDecision{
15+ SourceHost: req.SourceHost,
16+ RequestContextDisposition: req.RequestContextDisposition,
17+ RequiresNativeOpener: req.RequiresNativeOpener,
18+ }
19+ if decision.RequestContextDisposition == "" {
20+ decision.RequestContextDisposition = dto.RequestContextInheritParent
21+ }
22+
23+ switch {
24+ case strings.TrimSpace(req.TargetURI) == "":
25+ return completeHostDecision(decision, dto.HostDecisionDeny, dto.HostDecisionReasonEmptyTarget, "empty target URI")
26+ case req.AuthIntent:
27+ return completeHostDecision(decision, dto.HostDecisionCreateNativePopup, dto.HostDecisionReasonAuthNativePopup, "authentication intent requires a related native popup")
28+ case req.RequiresNativeOpener:
29+ return completeHostDecision(decision, dto.HostDecisionCreateNativePopup, dto.HostDecisionReasonNativeOpenerRequired, "native opener relationship is required")
30+ case isAmbiguousNativeBrowsingContext(req):
31+ return completeHostDecision(decision, dto.HostDecisionCreateNativePopup, dto.HostDecisionReasonAmbiguousOpenerNative, "ambiguous opener-coupled request prefers a related native popup")
32+ }
33+
34+ name := ReusableBrowsingContextName(req.TargetFrameName)
35+ if namedContextExists && name != "" && !req.NoJavaScriptAccess {
36+ decision.ReuseContextName = name
37+ decision.BrowsingContextName = name
38+ return completeHostDecision(decision, dto.HostDecisionReuseNamedPane, dto.HostDecisionReasonNamedContextReuse, "reuse live named browsing context")
39+ }
40+ decision.BrowsingContextName = name
41+
42+ if req.SourceHost == dto.SourceHostFloating {
43+ return decideFloatingHost(decision, req)
44+ }
45+ return completeHostDecision(decision, dto.HostDecisionCreatePane, dto.HostDecisionReasonWorkspacePane, "workspace request uses pane hosting")
46+}
47+
48+func decideFloatingHost(decision dto.HostDecision, req dto.NewBrowsingContextRequest) dto.HostDecision {
49+ switch {
50+ case req.PopupFeatures.State == dto.PopupFeaturesUnknown && req.TriggerKind != dto.TriggerLinkNewPage:
51+ return completeHostDecision(decision, dto.HostDecisionAwaitPopupFeatures, dto.HostDecisionReasonFloatingFeaturesPending, "popup features unavailable until ready-to-show")
52+ case popupFeaturesRequestHost(req.PopupFeatures):
53+ return completeHostDecision(decision, dto.HostDecisionCreateNativePopup, dto.HostDecisionReasonFloatingPopupFeatures, "explicit popup features requested")
54+ case ReusableBrowsingContextName(req.TargetFrameName) == "":
55+ return completeHostDecision(decision, dto.HostDecisionNavigateSource, dto.HostDecisionReasonFloatingFeaturelessBlank, "featureless blank request navigates the source floating pane")
56+ default:
57+ return completeHostDecision(decision, dto.HostDecisionCreateBrowserWindow, dto.HostDecisionReasonFloatingBrowserWindow, "floating request opens a detached browser window")
58+ }
59+}
60+
61+func popupFeaturesRequestHost(features dto.PopupFeatures) bool {
62+ return features.State == dto.PopupFeaturesSpecified &&
63+ ((features.WidthSet && features.Width > 0) ||
64+ (features.HeightSet && features.Height > 0) ||
65+ (features.ToolbarVisibilitySet && !features.ToolbarVisible) ||
66+ (features.LocationbarVisibilitySet && !features.LocationbarVisible) ||
67+ (features.ResizableSet && !features.Resizable) ||
68+ (features.IsPopupSet && features.IsPopup))
69+}
70+
71+func completeHostDecision(decision dto.HostDecision, kind dto.HostDecisionKind, code dto.HostDecisionReasonCode, reason string) dto.HostDecision {
72+ decision.Kind = kind
73+ decision.ReasonCode = code
74+ decision.Reason = reason
75+ return decision
76+}
77+
78+func isAmbiguousNativeBrowsingContext(req dto.NewBrowsingContextRequest) bool {
79+ return req.TriggerKind == dto.TriggerUnknown && !req.NoJavaScriptAccess
80+}
81+
82+// ReusableBrowsingContextName returns a live-context key for named targets.
83+func ReusableBrowsingContextName(frameName string) string {
84+ name := strings.TrimSpace(frameName)
85+ if name == "" || strings.EqualFold(name, "_blank") {
86+ return ""
87+ }
88+ return name
89+}
A internal/application/usecase/browsing_context_policy_test.go +76 -0
@@ -0,0 +1,76 @@
1+package usecase
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/dumber/internal/application/dto"
7+ "github.com/stretchr/testify/assert"
8+)
9+
10+func TestPopupFeaturesRequestHost(t *testing.T) {
11+ t.Parallel()
12+
13+ tests := []struct {
14+ name string
15+ features dto.PopupFeatures
16+ want bool
17+ }{
18+ {name: "known featureless", features: dto.PopupFeatures{State: dto.PopupFeaturesNone}},
19+ {name: "zero width", features: dto.PopupFeatures{State: dto.PopupFeaturesSpecified, Width: 0, WidthSet: true}},
20+ {name: "positive width", features: dto.PopupFeatures{State: dto.PopupFeaturesSpecified, Width: 1, WidthSet: true}, want: true},
21+ {name: "positive height", features: dto.PopupFeatures{State: dto.PopupFeaturesSpecified, Height: 1, HeightSet: true}, want: true},
22+ {name: "hidden toolbar", features: dto.PopupFeatures{State: dto.PopupFeaturesSpecified, ToolbarVisible: false, ToolbarVisibilitySet: true}, want: true},
23+ {name: "hidden location bar", features: dto.PopupFeatures{State: dto.PopupFeaturesSpecified, LocationbarVisible: false, LocationbarVisibilitySet: true}, want: true},
24+ {name: "disabled resize", features: dto.PopupFeatures{State: dto.PopupFeaturesSpecified, Resizable: false, ResizableSet: true}, want: true},
25+ {name: "engine popup flag", features: dto.PopupFeatures{State: dto.PopupFeaturesSpecified, IsPopup: true, IsPopupSet: true}, want: true},
26+ {name: "visible chrome", features: dto.PopupFeatures{State: dto.PopupFeaturesSpecified, ToolbarVisible: true, ToolbarVisibilitySet: true}},
27+ {name: "resizable", features: dto.PopupFeatures{State: dto.PopupFeaturesSpecified, Resizable: true, ResizableSet: true}},
28+ }
29+
30+ for _, tt := range tests {
31+ t.Run(tt.name, func(t *testing.T) {
32+ t.Parallel()
33+ assert.Equal(t, tt.want, popupFeaturesRequestHost(tt.features))
34+ })
35+ }
36+}
37+
38+func TestBrowsingContextPolicyDecide(t *testing.T) {
39+ t.Parallel()
40+
41+ workspace := dto.NewBrowsingContextRequest{SourceHost: dto.SourceHostWorkspace, TargetURI: "https://example.com/docs", TargetDisposition: dto.WindowDispositionNewTab, TriggerKind: dto.TriggerLinkNewPage}
42+ tests := []struct {
43+ name string
44+ request dto.NewBrowsingContextRequest
45+ namedContextExist bool
46+ wantKind dto.HostDecisionKind
47+ wantName string
48+ wantReason dto.HostDecisionReasonCode
49+ }{
50+ {name: "workspace behavior remains pane hosted", request: workspace, wantKind: dto.HostDecisionCreatePane, wantReason: dto.HostDecisionReasonWorkspacePane},
51+ {name: "zero source preserves workspace behavior", request: dto.NewBrowsingContextRequest{TargetURI: "https://example.com/docs", TriggerKind: dto.TriggerLinkNewPage}, wantKind: dto.HostDecisionCreatePane, wantReason: dto.HostDecisionReasonWorkspacePane},
52+ {name: "floating blank target navigates source", request: dto.NewBrowsingContextRequest{SourceHost: dto.SourceHostFloating, TargetURI: "https://example.com/docs", TargetFrameName: "_blank", TargetDisposition: dto.WindowDispositionNewTab, TriggerKind: dto.TriggerLinkNewPage, PopupFeatures: dto.PopupFeatures{State: dto.PopupFeaturesNone}}, wantKind: dto.HostDecisionNavigateSource, wantReason: dto.HostDecisionReasonFloatingFeaturelessBlank},
53+ {name: "floating featureless script navigates source", request: dto.NewBrowsingContextRequest{SourceHost: dto.SourceHostFloating, TargetURI: "https://example.com/help", TargetFrameName: "_blank", TargetDisposition: dto.WindowDispositionNewPopup, TriggerKind: dto.TriggerScriptWindowOpen, PopupFeatures: dto.PopupFeatures{State: dto.PopupFeaturesNone}}, wantKind: dto.HostDecisionNavigateSource, wantReason: dto.HostDecisionReasonFloatingFeaturelessBlank},
54+ {name: "floating featured script opens native popup", request: dto.NewBrowsingContextRequest{SourceHost: dto.SourceHostFloating, TargetURI: "https://example.com/help", TargetDisposition: dto.WindowDispositionNewPopup, TriggerKind: dto.TriggerScriptWindowOpen, PopupFeatures: dto.PopupFeatures{State: dto.PopupFeaturesSpecified, Width: 640, WidthSet: true}}, wantKind: dto.HostDecisionCreateNativePopup, wantReason: dto.HostDecisionReasonFloatingPopupFeatures},
55+ {name: "floating unknown script waits", request: dto.NewBrowsingContextRequest{SourceHost: dto.SourceHostFloating, TargetURI: "https://example.com/help", TargetDisposition: dto.WindowDispositionNewPopup, TriggerKind: dto.TriggerScriptWindowOpen, PopupFeatures: dto.PopupFeatures{State: dto.PopupFeaturesUnknown}}, wantKind: dto.HostDecisionAwaitPopupFeatures, wantReason: dto.HostDecisionReasonFloatingFeaturesPending},
56+ {name: "floating named unknown waits", request: dto.NewBrowsingContextRequest{SourceHost: dto.SourceHostFloating, TargetURI: "https://example.com/help", TargetFrameName: "oauth", TargetDisposition: dto.WindowDispositionNewPopup, TriggerKind: dto.TriggerNamedTargetNavigation, PopupFeatures: dto.PopupFeatures{State: dto.PopupFeaturesUnknown}}, wantKind: dto.HostDecisionAwaitPopupFeatures, wantName: "oauth", wantReason: dto.HostDecisionReasonFloatingFeaturesPending},
57+ {name: "auth intent precedes floating", request: dto.NewBrowsingContextRequest{SourceHost: dto.SourceHostFloating, TargetURI: "https://example.com/auth", AuthIntent: true}, wantKind: dto.HostDecisionCreateNativePopup, wantReason: dto.HostDecisionReasonAuthNativePopup},
58+ {name: "native opener precedes floating", request: dto.NewBrowsingContextRequest{SourceHost: dto.SourceHostFloating, TargetURI: "https://example.com/open", RequiresNativeOpener: true}, wantKind: dto.HostDecisionCreateNativePopup, wantReason: dto.HostDecisionReasonNativeOpenerRequired},
59+ {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},
60+ {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},
61+ {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},
62+ {name: "empty target is denied first", request: dto.NewBrowsingContextRequest{SourceHost: dto.SourceHostFloating, AuthIntent: true}, wantKind: dto.HostDecisionDeny, wantReason: dto.HostDecisionReasonEmptyTarget},
63+ }
64+
65+ for _, tt := range tests {
66+ t.Run(tt.name, func(t *testing.T) {
67+ t.Parallel()
68+ got := (BrowsingContextPolicy{}).Decide(tt.request, tt.namedContextExist)
69+ assert.Equal(t, tt.wantKind, got.Kind)
70+ assert.Equal(t, tt.wantName, got.BrowsingContextName)
71+ assert.Equal(t, tt.wantReason, got.ReasonCode)
72+ assert.NotEmpty(t, got.Reason)
73+ assert.Equal(t, dto.RequestContextInheritParent, got.RequestContextDisposition)
74+ })
75+ }
76+}
M internal/infrastructure/cef/webview_handlers_test.go +0 -372
@@ -1,17 +1,13 @@
11 package cef
22
33 import (
4- "bytes"
54 "context"
6- "encoding/json"
75 "sync"
8- "sync/atomic"
96 "testing"
107 "time"
118
129 purecef "github.com/bnema/purego-cef/cef"
1310 cefmocks "github.com/bnema/purego-cef/cef/mocks"
14- "github.com/rs/zerolog"
1511 "github.com/stretchr/testify/assert"
1612 "github.com/stretchr/testify/mock"
1713 "github.com/stretchr/testify/require"
@@ -19,7 +15,6 @@ import (
1915 "github.com/bnema/dumber/internal/application/dto"
2016 "github.com/bnema/dumber/internal/application/port"
2117 portmocks "github.com/bnema/dumber/internal/application/port/mocks"
22- "github.com/bnema/dumber/internal/logging"
2318 )
2419
2520 type clipboardOrchestratorRecorder struct {
@@ -268,373 +263,6 @@ func TestHandleNativePopupAborted_PreservesPrimedNavigationForFallback(t *testin
268263 require.True(t, wv.isLoading)
269264 }
270265
271-func TestMapCEFPopupFeatures(t *testing.T) {
272- t.Parallel()
273-
274- tests := []struct {
275- name string
276- in *purecef.PopupFeatures
277- want dto.PopupFeatures
278- }{
279- {
280- name: "nil is known featureless",
281- want: dto.PopupFeatures{State: dto.PopupFeaturesNone},
282- },
283- {
284- name: "positive width is popup-like",
285- in: &purecef.PopupFeatures{Width: 640, Widthset: 1},
286- want: dto.PopupFeatures{
287- State: dto.PopupFeaturesSpecified, Width: 640, WidthSet: true, IsPopupSet: true,
288- },
289- },
290- {
291- name: "geometry and flags are normalized",
292- in: &purecef.PopupFeatures{
293- X: 10, Xset: 1, Y: 20, Yset: 1,
294- Width: 800, Widthset: 1, Height: 600, Heightset: 1,
295- },
296- want: dto.PopupFeatures{
297- State: dto.PopupFeaturesSpecified,
298- X: 10, XSet: true, Y: 20, YSet: true,
299- Width: 800, WidthSet: true, Height: 600, HeightSet: true,
300- IsPopupSet: true,
301- },
302- },
303- {
304- name: "is popup flag survives without inventing geometry",
305- in: &purecef.PopupFeatures{Ispopup: 1},
306- want: dto.PopupFeatures{
307- State: dto.PopupFeaturesSpecified, IsPopup: true, IsPopupSet: true,
308- },
309- },
310- {
311- name: "allocated defaults are known featureless",
312- in: &purecef.PopupFeatures{},
313- want: dto.PopupFeatures{State: dto.PopupFeaturesNone, IsPopupSet: true},
314- },
315- }
316-
317- for _, tt := range tests {
318- t.Run(tt.name, func(t *testing.T) {
319- got := mapCEFPopupFeatures(tt.in)
320- assert.Equal(t, tt.want, got)
321- assert.Equal(t, tt.want.RequestsPopupHost(), got.RequestsPopupHost())
322- })
323- }
324-}
325-
326-func TestOnBeforePopup_PropagatesCEFEngineFeaturesGestureAndNoJavaScriptAccess(t *testing.T) {
327- parentWV := &WebView{ctx: context.Background(), id: 16}
328- popupWV := &WebView{ctx: context.Background(), id: 24, pendingCreate: &pendingBrowserCreate{}}
329- popupWV.markNativePopupCandidate(parentWV)
330- popupWV.SetBrowsingContextHostDecision(dto.HostDecision{Kind: dto.HostDecisionCreateBrowserWindow})
331- noJavaScriptAccess := true
332- parentWV.SetCallbacks(&port.WebViewCallbacks{OnCreate: func(req port.PopupRequest) port.WebView {
333- assert.Equal(t, dto.BrowserEngineCEF, req.Engine)
334- assert.Equal(t, dto.PopupFeatures{
335- State: dto.PopupFeaturesSpecified, X: 10, XSet: true, Width: 640, WidthSet: true,
336- IsPopup: true, IsPopupSet: true,
337- }, req.PopupFeatures)
338- assert.Equal(t, dto.WindowDispositionNewPopup, req.TargetDisposition)
339- assert.True(t, req.IsUserGesture)
340- assert.True(t, req.NoJavaScriptAccess)
341- return popupWV
342- }})
343-
344- blocked := (&handlerSet{wv: parentWV}).OnBeforePopup(
345- nil, nil, 90, "https://example.com/popup", "named-popup",
346- purecef.WindowOpenDispositionWodNewPopup, 1,
347- &purecef.PopupFeatures{X: 10, Xset: 1, Width: 640, Widthset: 1, Ispopup: 1},
348- nil, nil, nil, nil, &noJavaScriptAccess,
349- )
350-
351- require.True(t, blocked)
352- require.True(t, popupWV.nativePopupFallbackStarted)
353- require.Nil(t, popupWV.popupOpenerBridgeParent, "noopener must not install the synthetic opener bridge")
354-}
355-
356-func TestOnBeforePopup_BrowserWindowDecisionPreservesSyntheticOpenerBridge(t *testing.T) {
357- parentWV := &WebView{ctx: context.Background(), id: 17}
358- parentWV.updateURI("https://example.com/opener")
359- popupWV := &WebView{ctx: context.Background(), id: 25, pendingCreate: &pendingBrowserCreate{}}
360- popupWV.markNativePopupCandidate(parentWV)
361- popupWV.SetBrowsingContextHostDecision(dto.HostDecision{Kind: dto.HostDecisionCreateBrowserWindow})
362- parentWV.SetCallbacks(&port.WebViewCallbacks{OnCreate: func(port.PopupRequest) port.WebView { return popupWV }})
363-
364- blocked := (&handlerSet{wv: parentWV}).OnBeforePopup(
365- nil, nil, 91, "https://example.com/child", "child",
366- purecef.WindowOpenDispositionWodNewPopup, 1, nil, nil, nil, nil, nil, nil,
367- )
368-
369- require.True(t, blocked)
370- require.True(t, popupWV.nativePopupFallbackStarted)
371- require.Same(t, parentWV, popupWV.popupOpenerBridgeParent)
372- require.Equal(t, "https://example.com/opener", popupWV.popupOpenerBridgeParentURI)
373-}
374-
375-func TestOnBeforePopup_NativeArmFailurePreparesEligibleFallbackBeforeAbort(t *testing.T) {
376- parentWV := &WebView{ctx: context.Background(), id: 18}
377- parentWV.updateURI("https://example.com/opener")
378- popupWV := &WebView{ctx: context.Background(), id: 26, pendingCreate: &pendingBrowserCreate{}}
379- popupWV.markNativePopupCandidate(parentWV)
380- popupWV.SetBrowsingContextHostDecision(dto.HostDecision{Kind: dto.HostDecisionCreateNativePopup})
381- abortCalls := 0
382- popupWV.SetNativePopupHostAbort(func() {
383- abortCalls++
384- assert.True(t, popupWV.nativePopupFallbackStarted)
385- assert.Same(t, parentWV, popupWV.popupOpenerBridgeParent)
386- })
387- parentWV.SetCallbacks(&port.WebViewCallbacks{OnCreate: func(port.PopupRequest) port.WebView { return popupWV }})
388-
389- blocked := (&handlerSet{wv: parentWV}).OnBeforePopup(
390- nil, nil, 92, "https://example.com/visual", "visual",
391- purecef.WindowOpenDispositionWodNewPopup, 1,
392- &purecef.PopupFeatures{Width: 640, Widthset: 1}, nil, nil, nil, nil, nil,
393- )
394-
395- require.True(t, blocked)
396- require.Equal(t, 1, abortCalls)
397- require.False(t, popupWV.IsDestroyed())
398-}
399-
400-func TestOnBeforePopup_OpenerRequiredNativeArmFailureDeniesWithoutFallback(t *testing.T) {
401- var output bytes.Buffer
402- logger := zerolog.New(&output).Level(zerolog.WarnLevel)
403- ctx := logging.WithContext(context.Background(), logger)
404- parentWV := &WebView{ctx: ctx, id: 19}
405- popupWV := &WebView{ctx: context.Background(), id: 27, pendingCreate: &pendingBrowserCreate{}}
406- popupWV.markNativePopupCandidate(parentWV)
407- popupWV.SetBrowsingContextHostDecision(dto.HostDecision{
408- Kind: dto.HostDecisionCreateNativePopup, SourceHost: dto.SourceHostFloating, RequiresNativeOpener: true,
409- })
410- abortCalls := 0
411- popupWV.SetNativePopupHostAbort(func() {
412- abortCalls++
413- popupWV.Destroy()
414- })
415- parentWV.SetCallbacks(&port.WebViewCallbacks{OnCreate: func(port.PopupRequest) port.WebView { return popupWV }})
416-
417- blocked := (&handlerSet{wv: parentWV}).OnBeforePopup(
418- nil, nil, 93, "https://example.com/opener-required", "required",
419- purecef.WindowOpenDispositionWodNewPopup, 1, nil, nil, nil, nil, nil, nil,
420- )
421-
422- require.True(t, blocked)
423- require.Equal(t, 1, abortCalls)
424- require.False(t, popupWV.nativePopupFallbackStarted)
425- require.Nil(t, popupWV.popupOpenerBridgeParent)
426- require.True(t, popupWV.IsDestroyed())
427- var record map[string]any
428- require.NoError(t, json.Unmarshal(output.Bytes(), &record))
429- assert.Equal(t, "cef", record["engine"])
430- assert.Equal(t, "floating", record["source_host"])
431- assert.Equal(t, "create-native-popup", record["decision"])
432- assert.Equal(t, "new-popup", record["target_disposition"])
433- assert.Equal(t, "native-arm-failed", record["reason_code"])
434-}
435-
436-func TestOnBeforePopup_AuthNativeArmFailureDeniesWithoutFallback(t *testing.T) {
437- parentWV := &WebView{ctx: context.Background(), id: 20}
438- popupWV := &WebView{ctx: context.Background(), id: 28, pendingCreate: &pendingBrowserCreate{}}
439- popupWV.markNativePopupCandidate(parentWV)
440- popupWV.SetBrowsingContextHostDecision(dto.HostDecision{
441- Kind: dto.HostDecisionCreateNativePopup, ReasonCode: dto.HostDecisionReasonAuthNativePopup,
442- })
443- popupWV.SetNativePopupHostAbort(func() { popupWV.Destroy() })
444- parentWV.SetCallbacks(&port.WebViewCallbacks{OnCreate: func(port.PopupRequest) port.WebView { return popupWV }})
445-
446- blocked := (&handlerSet{wv: parentWV}).OnBeforePopup(
447- nil, nil, 94, "https://accounts.example.test/oauth", "oauth",
448- purecef.WindowOpenDispositionWodNewPopup, 1, nil, nil, nil, nil, nil, nil,
449- )
450-
451- require.True(t, blocked)
452- require.False(t, popupWV.nativePopupFallbackStarted)
453- require.True(t, popupWV.IsDestroyed())
454-}
455-
456-func TestOnBeforePopup_IsPopupFlagCanSelectNativePopup(t *testing.T) {
457- parentWV := &WebView{ctx: context.Background(), id: 22}
458- popupWV := &WebView{ctx: context.Background(), id: 30, pendingCreate: &pendingBrowserCreate{}}
459- popupWV.markNativePopupCandidate(parentWV)
460- abortCalls := 0
461- popupWV.SetNativePopupHostAbort(func() { abortCalls++ })
462- parentWV.SetCallbacks(&port.WebViewCallbacks{OnCreate: func(req port.PopupRequest) port.WebView {
463- require.True(t, req.PopupFeatures.RequestsPopupHost())
464- popupWV.SetBrowsingContextHostDecision(dto.HostDecision{Kind: dto.HostDecisionCreateNativePopup})
465- return popupWV
466- }})
467-
468- blocked := (&handlerSet{wv: parentWV}).OnBeforePopup(
469- nil, nil, 96, "https://example.com/chrome-restricted", "restricted",
470- purecef.WindowOpenDispositionWodNewPopup, 1,
471- &purecef.PopupFeatures{Ispopup: 1}, nil, nil, nil, nil, nil,
472- )
473-
474- require.True(t, blocked)
475- require.Equal(t, 1, abortCalls)
476-}
477-
478-func TestOnBeforePopup_ReuseNamedPaneBlocksNativePopup(t *testing.T) {
479- parentWV := &WebView{ctx: context.Background(), id: 23}
480- popupWV := &WebView{ctx: context.Background(), id: 31, pendingCreate: &pendingBrowserCreate{}}
481- popupWV.markNativePopupCandidate(parentWV)
482- popupWV.SetBrowsingContextHostDecision(dto.HostDecision{Kind: dto.HostDecisionReuseNamedPane})
483- parentWV.SetCallbacks(&port.WebViewCallbacks{OnCreate: func(port.PopupRequest) port.WebView { return popupWV }})
484-
485- blocked := (&handlerSet{wv: parentWV}).OnBeforePopup(
486- nil, nil, 97, "https://example.com/named", "shared",
487- purecef.WindowOpenDispositionWodNewPopup, 1, nil, nil, nil, nil, nil, nil,
488- )
489-
490- require.True(t, blocked)
491- require.True(t, popupWV.nativePopupFallbackStarted)
492-}
493-
494-func TestOnBeforePopup_DenyCleansUp(t *testing.T) {
495- parentWV := &WebView{ctx: context.Background(), id: 24}
496- popupWV := &WebView{ctx: context.Background(), id: 32}
497- popupWV.SetBrowsingContextHostDecision(dto.HostDecision{Kind: dto.HostDecisionDeny})
498- parentWV.SetCallbacks(&port.WebViewCallbacks{OnCreate: func(port.PopupRequest) port.WebView { return popupWV }})
499-
500- blocked := (&handlerSet{wv: parentWV}).OnBeforePopup(
501- nil, nil, 98, "https://example.com/denied", "denied",
502- purecef.WindowOpenDispositionWodNewPopup, 1, nil, nil, nil, nil, nil, nil,
503- )
504-
505- require.True(t, blocked)
506- require.True(t, popupWV.IsDestroyed())
507-}
508-
509-func TestOnBeforePopup_AwaitFeaturesIsImpossibleAndCleansUp(t *testing.T) {
510- parentWV := &WebView{ctx: context.Background(), id: 21}
511- popupWV := &WebView{ctx: context.Background(), id: 29}
512- popupWV.SetBrowsingContextHostDecision(dto.HostDecision{Kind: dto.HostDecisionAwaitPopupFeatures})
513- parentWV.SetCallbacks(&port.WebViewCallbacks{OnCreate: func(port.PopupRequest) port.WebView { return popupWV }})
514-
515- blocked := (&handlerSet{wv: parentWV}).OnBeforePopup(
516- nil, nil, 95, "https://example.com/impossible", "impossible",
517- purecef.WindowOpenDispositionWodNewPopup, 1, nil, nil, nil, nil, nil, nil,
518- )
519-
520- require.True(t, blocked)
521- require.True(t, popupWV.IsDestroyed())
522-}
523-
524-func TestOnBeforePopup_TimesOutGTKDispatchAndBlocksPopup(t *testing.T) {
525- delayed := make(chan func(), 1)
526- parentWV := &WebView{
527- ctx: context.Background(),
528- id: 16,
529- gtkSyncTimeout: 5 * time.Millisecond,
530- gtkSyncIsOwner: func() bool { return false },
531- gtkSyncDispatch: func(fn func()) {
532- delayed <- fn
533- },
534- }
535- var createCalls atomic.Int32
536- parentWV.SetCallbacks(&port.WebViewCallbacks{
537- OnCreate: func(_ port.PopupRequest) port.WebView {
538- createCalls.Add(1)
539- return &WebView{ctx: context.Background(), id: 24}
540- },
541- })
542-
543- h := &handlerSet{wv: parentWV}
544- blocked := h.OnBeforePopup(nil, nil, 90, "https://example.com/slow-popup", "slow-popup", 0, 1, nil, nil, nil, nil, nil, nil)
545-
546- require.True(t, blocked)
547- require.Zero(t, createCalls.Load())
548-
549- select {
550- case fn := <-delayed:
551- fn()
552- case <-time.After(time.Second):
553- t.Fatal("timed out waiting for delayed popup dispatch")
554- }
555- require.Zero(t, createCalls.Load())
556-}
557-
558-func TestOnBeforePopup_PrimesPopupNavigationWhenCEFPopupBlocksNativeCreation(t *testing.T) {
559- parentWV := &WebView{ctx: context.Background(), id: 17}
560- popupWV := &WebView{ctx: context.Background(), id: 23}
561- parentWV.SetCallbacks(&port.WebViewCallbacks{
562- OnCreate: func(req port.PopupRequest) port.WebView {
563- require.Equal(t, "https://example.com/popup", req.TargetURI)
564- require.Equal(t, "auth-popup", req.FrameName)
565- require.True(t, req.IsUserGesture)
566- popupWV.PrimePopupNavigation(req.TargetURI)
567- return popupWV
568- },
569- })
570-
571- h := &handlerSet{wv: parentWV}
572- blocked := h.OnBeforePopup(nil, nil, 91, "https://example.com/popup", "auth-popup", 0, 1, nil, nil, nil, nil, nil, nil)
573-
574- require.True(t, blocked)
575- require.Equal(t, "https://example.com/popup", popupWV.pendingNavigationURI())
576-}
577-
578-func TestOnBeforePopup_PrimesPopupShellWhenNativePopupCannotBeArmed(t *testing.T) {
579- parentWV := &WebView{ctx: context.Background(), id: 31}
580- popupWV := &WebView{ctx: context.Background(), id: 32}
581- popupWV.markNativePopupCandidate(parentWV)
582- parentWV.SetCallbacks(&port.WebViewCallbacks{
583- OnCreate: func(req port.PopupRequest) port.WebView {
584- require.Equal(t, "https://example.com/login", req.TargetURI)
585- popupWV.PrimePopupNavigation(req.TargetURI)
586- return popupWV
587- },
588- })
589-
590- h := &handlerSet{wv: parentWV}
591- blocked := h.OnBeforePopup(nil, nil, 77, "https://example.com/login", "Google login", 0, 1, nil, nil, nil, nil, nil, nil)
592-
593- require.True(t, blocked)
594- require.False(t, popupWV.isNativePopupCandidate())
595- require.Equal(t, "https://example.com/login", popupWV.pendingNavigationURI())
596-}
597-
598-func TestOnBeforePopup_PaneDecisionBlocksNativePopupAndKeepsPanePath(t *testing.T) {
599- parentWV := &WebView{ctx: context.Background(), id: 41}
600- popupWV := &WebView{ctx: context.Background(), id: 42}
601- popupWV.SetBrowsingContextHostDecision(dto.HostDecision{Kind: dto.HostDecisionCreatePane})
602- parentWV.SetCallbacks(&port.WebViewCallbacks{
603- OnCreate: func(req port.PopupRequest) port.WebView {
604- popupWV.PreparePaneHostedBrowsingContext()
605- popupWV.PrimePopupNavigation(req.TargetURI)
606- return popupWV
607- },
608- })
609-
610- h := &handlerSet{wv: parentWV}
611- blocked := h.OnBeforePopup(nil, nil, 81, "https://example.com/pane", "_blank", 0, 1, nil, nil, nil, nil, nil, nil)
612-
613- require.True(t, blocked)
614- require.Equal(t, "https://example.com/pane", popupWV.pendingNavigationURI())
615-}
616-
617-func TestOnBeforePopup_NativeDecisionAbortsHostWhenArmingFails(t *testing.T) {
618- parentWV := &WebView{ctx: context.Background(), id: 51}
619- popupWV := &WebView{ctx: context.Background(), id: 52}
620- popupWV.markNativePopupCandidate(parentWV)
621- popupWV.SetBrowsingContextHostDecision(dto.HostDecision{Kind: dto.HostDecisionCreateNativePopup})
622- aborted := false
623- popupWV.SetNativePopupHostAbort(func() { aborted = true })
624- parentWV.SetCallbacks(&port.WebViewCallbacks{
625- OnCreate: func(req port.PopupRequest) port.WebView {
626- popupWV.PrimePopupNavigation(req.TargetURI)
627- return popupWV
628- },
629- })
630-
631- h := &handlerSet{wv: parentWV}
632- blocked := h.OnBeforePopup(nil, nil, 82, "https://accounts.google.com/o/oauth2/v2/auth", "oauth", 0, 1, nil, nil, nil, nil, nil, nil)
633-
634- require.True(t, blocked)
635- require.True(t, aborted)
636-}
637-
638266 func TestConfigureNativePopupWindow_UsesSharedTextureWindowlessDefaults(t *testing.T) {
639267 windowInfo := purecef.NewWindowInfo()
640268 settings := purecef.NewBrowserSettings()
A internal/infrastructure/cef/webview_popup_routing_test.go +419 -0
@@ -0,0 +1,419 @@
1+package cef
2+
3+import (
4+ "bytes"
5+ "context"
6+ "encoding/json"
7+ "sync/atomic"
8+ "testing"
9+ "time"
10+
11+ purecef "github.com/bnema/purego-cef/cef"
12+ "github.com/rs/zerolog"
13+ "github.com/stretchr/testify/assert"
14+ "github.com/stretchr/testify/require"
15+
16+ "github.com/bnema/dumber/internal/application/dto"
17+ "github.com/bnema/dumber/internal/application/port"
18+ portmocks "github.com/bnema/dumber/internal/application/port/mocks"
19+ "github.com/bnema/dumber/internal/logging"
20+)
21+
22+func TestMapCEFPopupFeatures(t *testing.T) {
23+ t.Parallel()
24+
25+ tests := []struct {
26+ name string
27+ in *purecef.PopupFeatures
28+ want dto.PopupFeatures
29+ }{
30+ {
31+ name: "nil is known featureless",
32+ want: dto.PopupFeatures{State: dto.PopupFeaturesNone},
33+ },
34+ {
35+ name: "positive width is popup-like",
36+ in: &purecef.PopupFeatures{Width: 640, Widthset: 1},
37+ want: dto.PopupFeatures{
38+ State: dto.PopupFeaturesSpecified, Width: 640, WidthSet: true, IsPopupSet: true,
39+ },
40+ },
41+ {
42+ name: "geometry and flags are normalized",
43+ in: &purecef.PopupFeatures{
44+ X: 10, Xset: 1, Y: 20, Yset: 1,
45+ Width: 800, Widthset: 1, Height: 600, Heightset: 1,
46+ },
47+ want: dto.PopupFeatures{
48+ State: dto.PopupFeaturesSpecified,
49+ X: 10, XSet: true, Y: 20, YSet: true,
50+ Width: 800, WidthSet: true, Height: 600, HeightSet: true,
51+ IsPopupSet: true,
52+ },
53+ },
54+ {
55+ name: "is popup flag survives without inventing geometry",
56+ in: &purecef.PopupFeatures{Ispopup: 1},
57+ want: dto.PopupFeatures{
58+ State: dto.PopupFeaturesSpecified, IsPopup: true, IsPopupSet: true,
59+ },
60+ },
61+ {
62+ name: "allocated defaults are known featureless",
63+ in: &purecef.PopupFeatures{},
64+ want: dto.PopupFeatures{State: dto.PopupFeaturesNone, IsPopupSet: true},
65+ },
66+ }
67+
68+ for _, tt := range tests {
69+ t.Run(tt.name, func(t *testing.T) {
70+ got := mapCEFPopupFeatures(tt.in)
71+ assert.Equal(t, tt.want, got)
72+ })
73+ }
74+}
75+
76+func TestOnBeforePopup_PropagatesCEFEngineFeaturesGestureAndNoJavaScriptAccess(t *testing.T) {
77+ parentWV := &WebView{ctx: context.Background(), id: 16}
78+ popupWV := &WebView{ctx: context.Background(), id: 24, pendingCreate: &pendingBrowserCreate{}}
79+ popupWV.markNativePopupCandidate(parentWV)
80+ popupWV.SetBrowsingContextHostDecision(dto.HostDecision{Kind: dto.HostDecisionCreateBrowserWindow})
81+ noJavaScriptAccess := true
82+ parentWV.SetCallbacks(&port.WebViewCallbacks{OnCreate: func(req port.PopupRequest) port.WebView {
83+ assert.Equal(t, dto.BrowserEngineCEF, req.Engine)
84+ assert.Equal(t, dto.PopupFeatures{
85+ State: dto.PopupFeaturesSpecified, X: 10, XSet: true, Width: 640, WidthSet: true,
86+ IsPopup: true, IsPopupSet: true,
87+ }, req.PopupFeatures)
88+ assert.Equal(t, dto.WindowDispositionNewPopup, req.TargetDisposition)
89+ assert.True(t, req.IsUserGesture)
90+ assert.True(t, req.NoJavaScriptAccess)
91+ return popupWV
92+ }})
93+
94+ blocked := (&handlerSet{wv: parentWV}).OnBeforePopup(
95+ nil, nil, 90, "https://example.com/popup", "named-popup",
96+ purecef.WindowOpenDispositionWodNewPopup, 1,
97+ &purecef.PopupFeatures{X: 10, Xset: 1, Width: 640, Widthset: 1, Ispopup: 1},
98+ nil, nil, nil, nil, &noJavaScriptAccess,
99+ )
100+
101+ require.True(t, blocked)
102+ require.True(t, popupWV.nativePopupFallbackStarted)
103+ require.Nil(t, popupWV.popupOpenerBridgeParent, "noopener must not install the synthetic opener bridge")
104+}
105+
106+func TestOnBeforePopup_FeaturelessBlankNavigateSourceBlocksNativePopup(t *testing.T) {
107+ const targetURI = "https://example.com/source-navigation"
108+ parentWV := &WebView{ctx: context.Background(), id: 17}
109+ candidate := portmocks.NewMockWebView(t)
110+ candidate.EXPECT().Destroy().Once()
111+
112+ var callbackResult port.WebView = candidate
113+ parentWV.SetCallbacks(&port.WebViewCallbacks{OnCreate: func(req port.PopupRequest) port.WebView {
114+ require.Equal(t, dto.BrowserEngineCEF, req.Engine)
115+ require.Equal(t, "_blank", req.FrameName)
116+ require.Equal(t, dto.WindowDispositionNewTab, req.TargetDisposition)
117+ require.Equal(t, dto.PopupFeaturesNone, req.PopupFeatures.State)
118+
119+ // NavigateSource ownership belongs to the coordinator callback: navigate
120+ // the source, destroy its unused candidate exactly once, then return nil.
121+ require.NoError(t, parentWV.LoadURI(context.Background(), req.TargetURI))
122+ candidate.Destroy()
123+ callbackResult = nil
124+ return callbackResult
125+ }})
126+
127+ blocked := (&handlerSet{wv: parentWV}).OnBeforePopup(
128+ nil, nil, 91, targetURI, "_blank",
129+ purecef.WindowOpenDispositionWodNewForegroundTab, 1, nil, nil, nil, nil, nil, nil,
130+ )
131+
132+ require.True(t, blocked, "CEF native popup creation must remain blocked")
133+ require.Nil(t, callbackResult, "NavigateSource callback must return nil")
134+ require.Equal(t, targetURI, parentWV.pendingNavigationURI())
135+}
136+
137+func TestOnBeforePopup_BrowserWindowDecisionPreservesSyntheticOpenerBridge(t *testing.T) {
138+ parentWV := &WebView{ctx: context.Background(), id: 17}
139+ parentWV.updateURI("https://example.com/opener")
140+ popupWV := &WebView{ctx: context.Background(), id: 25, pendingCreate: &pendingBrowserCreate{}}
141+ popupWV.markNativePopupCandidate(parentWV)
142+ popupWV.SetBrowsingContextHostDecision(dto.HostDecision{Kind: dto.HostDecisionCreateBrowserWindow})
143+ parentWV.SetCallbacks(&port.WebViewCallbacks{OnCreate: func(port.PopupRequest) port.WebView { return popupWV }})
144+
145+ blocked := (&handlerSet{wv: parentWV}).OnBeforePopup(
146+ nil, nil, 91, "https://example.com/child", "child",
147+ purecef.WindowOpenDispositionWodNewPopup, 1, nil, nil, nil, nil, nil, nil,
148+ )
149+
150+ require.True(t, blocked)
151+ require.True(t, popupWV.nativePopupFallbackStarted)
152+ require.Same(t, parentWV, popupWV.popupOpenerBridgeParent)
153+ require.Equal(t, "https://example.com/opener", popupWV.popupOpenerBridgeParentURI)
154+}
155+
156+func TestOnBeforePopup_NativeArmFailurePreparesEligibleFallbackBeforeAbort(t *testing.T) {
157+ parentWV := &WebView{ctx: context.Background(), id: 18}
158+ parentWV.updateURI("https://example.com/opener")
159+ popupWV := &WebView{ctx: context.Background(), id: 26, pendingCreate: &pendingBrowserCreate{}}
160+ popupWV.markNativePopupCandidate(parentWV)
161+ popupWV.SetBrowsingContextHostDecision(dto.HostDecision{Kind: dto.HostDecisionCreateNativePopup})
162+ abortCalls := 0
163+ popupWV.SetNativePopupHostAbort(func() {
164+ abortCalls++
165+ assert.True(t, popupWV.nativePopupFallbackStarted)
166+ assert.Same(t, parentWV, popupWV.popupOpenerBridgeParent)
167+ })
168+ parentWV.SetCallbacks(&port.WebViewCallbacks{OnCreate: func(port.PopupRequest) port.WebView { return popupWV }})
169+
170+ blocked := (&handlerSet{wv: parentWV}).OnBeforePopup(
171+ nil, nil, 92, "https://example.com/visual", "visual",
172+ purecef.WindowOpenDispositionWodNewPopup, 1,
173+ &purecef.PopupFeatures{Width: 640, Widthset: 1}, nil, nil, nil, nil, nil,
174+ )
175+
176+ require.True(t, blocked)
177+ require.Equal(t, 1, abortCalls)
178+ require.False(t, popupWV.IsDestroyed())
179+}
180+
181+func TestOnBeforePopup_OpenerRequiredNativeArmFailureDeniesWithoutFallback(t *testing.T) {
182+ var output bytes.Buffer
183+ logger := zerolog.New(&output).Level(zerolog.WarnLevel)
184+ ctx := logging.WithContext(context.Background(), logger)
185+ parentWV := &WebView{ctx: ctx, id: 19}
186+ popupWV := &WebView{ctx: context.Background(), id: 27, pendingCreate: &pendingBrowserCreate{}}
187+ popupWV.markNativePopupCandidate(parentWV)
188+ popupWV.SetBrowsingContextHostDecision(dto.HostDecision{
189+ Kind: dto.HostDecisionCreateNativePopup, SourceHost: dto.SourceHostFloating, RequiresNativeOpener: true,
190+ })
191+ abortCalls := 0
192+ popupWV.SetNativePopupHostAbort(func() {
193+ abortCalls++
194+ popupWV.Destroy()
195+ })
196+ parentWV.SetCallbacks(&port.WebViewCallbacks{OnCreate: func(port.PopupRequest) port.WebView { return popupWV }})
197+
198+ blocked := (&handlerSet{wv: parentWV}).OnBeforePopup(
199+ nil, nil, 93, "https://example.com/opener-required", "required",
200+ purecef.WindowOpenDispositionWodNewPopup, 1, nil, nil, nil, nil, nil, nil,
201+ )
202+
203+ require.True(t, blocked)
204+ require.Equal(t, 1, abortCalls)
205+ require.False(t, popupWV.nativePopupFallbackStarted)
206+ require.Nil(t, popupWV.popupOpenerBridgeParent)
207+ require.True(t, popupWV.IsDestroyed())
208+ var record map[string]any
209+ require.NoError(t, json.Unmarshal(output.Bytes(), &record))
210+ assert.Equal(t, "cef", record["engine"])
211+ assert.Equal(t, "floating", record["source_host"])
212+ assert.Equal(t, "create-native-popup", record["decision"])
213+ assert.Equal(t, "new-popup", record["target_disposition"])
214+ assert.Equal(t, "native-arm-failed", record["reason_code"])
215+}
216+
217+func TestOnBeforePopup_AuthNativeArmFailureDeniesWithoutFallback(t *testing.T) {
218+ parentWV := &WebView{ctx: context.Background(), id: 20}
219+ popupWV := &WebView{ctx: context.Background(), id: 28, pendingCreate: &pendingBrowserCreate{}}
220+ popupWV.markNativePopupCandidate(parentWV)
221+ popupWV.SetBrowsingContextHostDecision(dto.HostDecision{
222+ Kind: dto.HostDecisionCreateNativePopup, ReasonCode: dto.HostDecisionReasonAuthNativePopup,
223+ })
224+ popupWV.SetNativePopupHostAbort(func() { popupWV.Destroy() })
225+ parentWV.SetCallbacks(&port.WebViewCallbacks{OnCreate: func(port.PopupRequest) port.WebView { return popupWV }})
226+
227+ blocked := (&handlerSet{wv: parentWV}).OnBeforePopup(
228+ nil, nil, 94, "https://accounts.example.test/oauth", "oauth",
229+ purecef.WindowOpenDispositionWodNewPopup, 1, nil, nil, nil, nil, nil, nil,
230+ )
231+
232+ require.True(t, blocked)
233+ require.False(t, popupWV.nativePopupFallbackStarted)
234+ require.True(t, popupWV.IsDestroyed())
235+}
236+
237+func TestOnBeforePopup_IsPopupFlagCanSelectNativePopup(t *testing.T) {
238+ parentWV := &WebView{ctx: context.Background(), id: 22}
239+ popupWV := &WebView{ctx: context.Background(), id: 30, pendingCreate: &pendingBrowserCreate{}}
240+ popupWV.markNativePopupCandidate(parentWV)
241+ abortCalls := 0
242+ popupWV.SetNativePopupHostAbort(func() { abortCalls++ })
243+ parentWV.SetCallbacks(&port.WebViewCallbacks{OnCreate: func(req port.PopupRequest) port.WebView {
244+ require.Equal(t, dto.PopupFeaturesSpecified, req.PopupFeatures.State)
245+ require.True(t, req.PopupFeatures.IsPopup)
246+ require.True(t, req.PopupFeatures.IsPopupSet)
247+ popupWV.SetBrowsingContextHostDecision(dto.HostDecision{Kind: dto.HostDecisionCreateNativePopup})
248+ return popupWV
249+ }})
250+
251+ blocked := (&handlerSet{wv: parentWV}).OnBeforePopup(
252+ nil, nil, 96, "https://example.com/chrome-restricted", "restricted",
253+ purecef.WindowOpenDispositionWodNewPopup, 1,
254+ &purecef.PopupFeatures{Ispopup: 1}, nil, nil, nil, nil, nil,
255+ )
256+
257+ require.True(t, blocked)
258+ require.Equal(t, 1, abortCalls)
259+}
260+
261+func TestOnBeforePopup_ReuseNamedPaneBlocksNativePopup(t *testing.T) {
262+ parentWV := &WebView{ctx: context.Background(), id: 23}
263+ popupWV := &WebView{ctx: context.Background(), id: 31, pendingCreate: &pendingBrowserCreate{}}
264+ popupWV.markNativePopupCandidate(parentWV)
265+ popupWV.SetBrowsingContextHostDecision(dto.HostDecision{Kind: dto.HostDecisionReuseNamedPane})
266+ parentWV.SetCallbacks(&port.WebViewCallbacks{OnCreate: func(port.PopupRequest) port.WebView { return popupWV }})
267+
268+ blocked := (&handlerSet{wv: parentWV}).OnBeforePopup(
269+ nil, nil, 97, "https://example.com/named", "shared",
270+ purecef.WindowOpenDispositionWodNewPopup, 1, nil, nil, nil, nil, nil, nil,
271+ )
272+
273+ require.True(t, blocked)
274+ require.True(t, popupWV.nativePopupFallbackStarted)
275+}
276+
277+func TestOnBeforePopup_DenyCleansUp(t *testing.T) {
278+ parentWV := &WebView{ctx: context.Background(), id: 24}
279+ popupWV := &WebView{ctx: context.Background(), id: 32}
280+ popupWV.SetBrowsingContextHostDecision(dto.HostDecision{Kind: dto.HostDecisionDeny})
281+ parentWV.SetCallbacks(&port.WebViewCallbacks{OnCreate: func(port.PopupRequest) port.WebView { return popupWV }})
282+
283+ blocked := (&handlerSet{wv: parentWV}).OnBeforePopup(
284+ nil, nil, 98, "https://example.com/denied", "denied",
285+ purecef.WindowOpenDispositionWodNewPopup, 1, nil, nil, nil, nil, nil, nil,
286+ )
287+
288+ require.True(t, blocked)
289+ require.True(t, popupWV.IsDestroyed())
290+}
291+
292+func TestOnBeforePopup_AwaitFeaturesIsImpossibleAndCleansUp(t *testing.T) {
293+ parentWV := &WebView{ctx: context.Background(), id: 21}
294+ popupWV := &WebView{ctx: context.Background(), id: 29}
295+ popupWV.SetBrowsingContextHostDecision(dto.HostDecision{Kind: dto.HostDecisionAwaitPopupFeatures})
296+ parentWV.SetCallbacks(&port.WebViewCallbacks{OnCreate: func(port.PopupRequest) port.WebView { return popupWV }})
297+
298+ blocked := (&handlerSet{wv: parentWV}).OnBeforePopup(
299+ nil, nil, 95, "https://example.com/impossible", "impossible",
300+ purecef.WindowOpenDispositionWodNewPopup, 1, nil, nil, nil, nil, nil, nil,
301+ )
302+
303+ require.True(t, blocked)
304+ require.True(t, popupWV.IsDestroyed())
305+}
306+
307+func TestOnBeforePopup_TimesOutGTKDispatchAndBlocksPopup(t *testing.T) {
308+ delayed := make(chan func(), 1)
309+ parentWV := &WebView{
310+ ctx: context.Background(),
311+ id: 16,
312+ gtkSyncTimeout: 5 * time.Millisecond,
313+ gtkSyncIsOwner: func() bool { return false },
314+ gtkSyncDispatch: func(fn func()) {
315+ delayed <- fn
316+ },
317+ }
318+ var createCalls atomic.Int32
319+ parentWV.SetCallbacks(&port.WebViewCallbacks{
320+ OnCreate: func(_ port.PopupRequest) port.WebView {
321+ createCalls.Add(1)
322+ return &WebView{ctx: context.Background(), id: 24}
323+ },
324+ })
325+
326+ h := &handlerSet{wv: parentWV}
327+ blocked := h.OnBeforePopup(nil, nil, 90, "https://example.com/slow-popup", "slow-popup", 0, 1, nil, nil, nil, nil, nil, nil)
328+
329+ require.True(t, blocked)
330+ require.Zero(t, createCalls.Load())
331+
332+ select {
333+ case fn := <-delayed:
334+ fn()
335+ case <-time.After(time.Second):
336+ t.Fatal("timed out waiting for delayed popup dispatch")
337+ }
338+ require.Zero(t, createCalls.Load())
339+}
340+
341+func TestOnBeforePopup_PrimesPopupNavigationWhenCEFPopupBlocksNativeCreation(t *testing.T) {
342+ parentWV := &WebView{ctx: context.Background(), id: 17}
343+ popupWV := &WebView{ctx: context.Background(), id: 23}
344+ parentWV.SetCallbacks(&port.WebViewCallbacks{
345+ OnCreate: func(req port.PopupRequest) port.WebView {
346+ require.Equal(t, "https://example.com/popup", req.TargetURI)
347+ require.Equal(t, "auth-popup", req.FrameName)
348+ require.True(t, req.IsUserGesture)
349+ popupWV.PrimePopupNavigation(req.TargetURI)
350+ return popupWV
351+ },
352+ })
353+
354+ h := &handlerSet{wv: parentWV}
355+ blocked := h.OnBeforePopup(nil, nil, 91, "https://example.com/popup", "auth-popup", 0, 1, nil, nil, nil, nil, nil, nil)
356+
357+ require.True(t, blocked)
358+ require.Equal(t, "https://example.com/popup", popupWV.pendingNavigationURI())
359+}
360+
361+func TestOnBeforePopup_PrimesPopupShellWhenNativePopupCannotBeArmed(t *testing.T) {
362+ parentWV := &WebView{ctx: context.Background(), id: 31}
363+ popupWV := &WebView{ctx: context.Background(), id: 32}
364+ popupWV.markNativePopupCandidate(parentWV)
365+ parentWV.SetCallbacks(&port.WebViewCallbacks{
366+ OnCreate: func(req port.PopupRequest) port.WebView {
367+ require.Equal(t, "https://example.com/login", req.TargetURI)
368+ popupWV.PrimePopupNavigation(req.TargetURI)
369+ return popupWV
370+ },
371+ })
372+
373+ h := &handlerSet{wv: parentWV}
374+ blocked := h.OnBeforePopup(nil, nil, 77, "https://example.com/login", "Google login", 0, 1, nil, nil, nil, nil, nil, nil)
375+
376+ require.True(t, blocked)
377+ require.False(t, popupWV.isNativePopupCandidate())
378+ require.Equal(t, "https://example.com/login", popupWV.pendingNavigationURI())
379+}
380+
381+func TestOnBeforePopup_PaneDecisionBlocksNativePopupAndKeepsPanePath(t *testing.T) {
382+ parentWV := &WebView{ctx: context.Background(), id: 41}
383+ popupWV := &WebView{ctx: context.Background(), id: 42}
384+ popupWV.SetBrowsingContextHostDecision(dto.HostDecision{Kind: dto.HostDecisionCreatePane})
385+ parentWV.SetCallbacks(&port.WebViewCallbacks{
386+ OnCreate: func(req port.PopupRequest) port.WebView {
387+ popupWV.PreparePaneHostedBrowsingContext()
388+ popupWV.PrimePopupNavigation(req.TargetURI)
389+ return popupWV
390+ },
391+ })
392+
393+ h := &handlerSet{wv: parentWV}
394+ blocked := h.OnBeforePopup(nil, nil, 81, "https://example.com/pane", "_blank", 0, 1, nil, nil, nil, nil, nil, nil)
395+
396+ require.True(t, blocked)
397+ require.Equal(t, "https://example.com/pane", popupWV.pendingNavigationURI())
398+}
399+
400+func TestOnBeforePopup_NativeDecisionAbortsHostWhenArmingFails(t *testing.T) {
401+ parentWV := &WebView{ctx: context.Background(), id: 51}
402+ popupWV := &WebView{ctx: context.Background(), id: 52}
403+ popupWV.markNativePopupCandidate(parentWV)
404+ popupWV.SetBrowsingContextHostDecision(dto.HostDecision{Kind: dto.HostDecisionCreateNativePopup})
405+ aborted := false
406+ popupWV.SetNativePopupHostAbort(func() { aborted = true })
407+ parentWV.SetCallbacks(&port.WebViewCallbacks{
408+ OnCreate: func(req port.PopupRequest) port.WebView {
409+ popupWV.PrimePopupNavigation(req.TargetURI)
410+ return popupWV
411+ },
412+ })
413+
414+ h := &handlerSet{wv: parentWV}
415+ blocked := h.OnBeforePopup(nil, nil, 82, "https://accounts.google.com/o/oauth2/v2/auth", "oauth", 0, 1, nil, nil, nil, nil, nil, nil)
416+
417+ require.True(t, blocked)
418+ require.True(t, aborted)
419+}
M internal/infrastructure/webkit/webview_popup_test.go +18 -1
@@ -4,8 +4,10 @@ import (
44 "testing"
55
66 "github.com/bnema/dumber/internal/application/dto"
7+ "github.com/bnema/dumber/internal/application/port"
78 purewebkit "github.com/bnema/puregotk/v4/webkit"
89 "github.com/stretchr/testify/assert"
10+ "github.com/stretchr/testify/require"
911 )
1012
1113 func TestResolvePopupFeatures(t *testing.T) {
@@ -31,7 +33,6 @@ func TestResolvePopupFeatures(t *testing.T) {
3133 t.Parallel()
3234 got := popupFeaturesFromWindowProperties(tt.x, tt.y, tt.width, tt.height, tt.toolbar, tt.location, tt.resizable)
3335 assert.Equal(t, tt.wantState, got.State)
34- assert.Equal(t, tt.wantPopup, got.RequestsPopupHost())
3536 assert.Equal(t, tt.width > 0, got.WidthSet)
3637 assert.Equal(t, tt.height > 0, got.HeightSet)
3738 assert.True(t, got.ToolbarVisibilitySet)
@@ -66,6 +67,22 @@ func TestMapPopupRequestDefersScriptBlankFeatures(t *testing.T) {
6667 assert.Equal(t, dto.PopupFeaturesUnknown, script.PopupFeatures.State)
6768 }
6869
70+func TestPopupCreateCallbackPreservesNilNamedReuseResult(t *testing.T) {
71+ wv := &WebView{}
72+ wv.SetCallbacks(&port.WebViewCallbacks{OnCreate: func(req port.PopupRequest) port.WebView {
73+ require.Equal(t, dto.BrowserEngineWebKit, req.Engine)
74+ require.Equal(t, "shared", req.FrameName)
75+ return nil // Existing named context was navigated; block WebKit creation.
76+ }})
77+
78+ result := wv.OnCreate(PopupRequest{
79+ TargetURI: "https://example.com/reused", FrameName: "shared", ParentID: 7,
80+ NavigationType: purewebkit.NavigationTypeOtherValue,
81+ })
82+
83+ require.Nil(t, result)
84+}
85+
6986 func TestResolvePopupFeaturesNilWebViewIsUnknown(t *testing.T) {
7087 t.Parallel()
7188 var wv *WebView
M internal/ui/coordinator/content/browsing_context_policy.go +5 -105
@@ -5,98 +5,10 @@ import (
55
66 "github.com/bnema/dumber/internal/application/dto"
77 "github.com/bnema/dumber/internal/application/port"
8+ "github.com/bnema/dumber/internal/application/usecase"
89 "github.com/rs/zerolog"
910 )
1011
11-type browsingContextPolicy struct{}
12-
13-func (browsingContextPolicy) Decide(req dto.NewBrowsingContextRequest, namedContextExists bool) dto.HostDecision {
14- decision := dto.HostDecision{
15- SourceHost: req.SourceHost,
16- RequestContextDisposition: req.RequestContextDisposition,
17- RequiresNativeOpener: req.RequiresNativeOpener,
18- }
19- if decision.RequestContextDisposition == "" {
20- decision.RequestContextDisposition = dto.RequestContextInheritParent
21- }
22-
23- switch {
24- case strings.TrimSpace(req.TargetURI) == "":
25- return completeHostDecision(decision, dto.HostDecisionDeny, dto.HostDecisionReasonEmptyTarget, "empty target URI")
26- case req.AuthIntent:
27- return completeHostDecision(
28- decision, dto.HostDecisionCreateNativePopup, dto.HostDecisionReasonAuthNativePopup,
29- "authentication intent requires a related native popup",
30- )
31- case req.RequiresNativeOpener:
32- return completeHostDecision(
33- decision, dto.HostDecisionCreateNativePopup, dto.HostDecisionReasonNativeOpenerRequired,
34- "native opener relationship is required",
35- )
36- case isAmbiguousNativeBrowsingContext(req):
37- return completeHostDecision(
38- decision, dto.HostDecisionCreateNativePopup, dto.HostDecisionReasonAmbiguousOpenerNative,
39- "ambiguous opener-coupled request prefers a related native popup",
40- )
41- }
42-
43- name := reusableBrowsingContextName(req.TargetFrameName)
44- if namedContextExists && name != "" && !req.NoJavaScriptAccess {
45- decision.ReuseContextName = name
46- decision.BrowsingContextName = name
47- return completeHostDecision(
48- decision, dto.HostDecisionReuseNamedPane, dto.HostDecisionReasonNamedContextReuse,
49- "reuse live named browsing context",
50- )
51- }
52- decision.BrowsingContextName = name
53-
54- if req.SourceHost == dto.SourceHostFloating {
55- return decideFloatingHost(decision, req)
56- }
57- return completeHostDecision(
58- decision, dto.HostDecisionCreatePane, dto.HostDecisionReasonWorkspacePane,
59- "workspace request uses pane hosting",
60- )
61-}
62-
63-func decideFloatingHost(decision dto.HostDecision, req dto.NewBrowsingContextRequest) dto.HostDecision {
64- switch {
65- case req.PopupFeatures.State == dto.PopupFeaturesUnknown && req.TriggerKind != dto.TriggerLinkNewPage:
66- return completeHostDecision(
67- decision, dto.HostDecisionAwaitPopupFeatures, dto.HostDecisionReasonFloatingFeaturesPending,
68- "popup features unavailable until ready-to-show",
69- )
70- case req.PopupFeatures.RequestsPopupHost():
71- return completeHostDecision(
72- decision, dto.HostDecisionCreateNativePopup, dto.HostDecisionReasonFloatingPopupFeatures,
73- "explicit popup features requested",
74- )
75- case reusableBrowsingContextName(req.TargetFrameName) == "":
76- return completeHostDecision(
77- decision, dto.HostDecisionNavigateSource, dto.HostDecisionReasonFloatingFeaturelessBlank,
78- "featureless blank request navigates the source floating pane",
79- )
80- default:
81- return completeHostDecision(
82- decision, dto.HostDecisionCreateBrowserWindow, dto.HostDecisionReasonFloatingBrowserWindow,
83- "floating request opens a detached browser window",
84- )
85- }
86-}
87-
88-func completeHostDecision(
89- decision dto.HostDecision,
90- kind dto.HostDecisionKind,
91- code dto.HostDecisionReasonCode,
92- reason string,
93-) dto.HostDecision {
94- decision.Kind = kind
95- decision.ReasonCode = code
96- decision.Reason = reason
97- return decision
98-}
99-
10012 func logBrowsingContextDecision(logger zerolog.Logger, req dto.NewBrowsingContextRequest, decision dto.HostDecision) {
10113 logger.Debug().
10214 Str("engine", string(req.Engine)).
@@ -170,36 +82,24 @@ func inferPopupWindowDisposition(req port.PopupRequest) dto.WindowDisposition {
17082 func inferPopupTriggerKind(req port.PopupRequest) dto.TriggerKind {
17183 switch inferPopupWindowDisposition(req) {
17284 case dto.WindowDispositionCurrentTab:
173- if reusableBrowsingContextName(req.FrameName) != "" {
85+ if usecase.ReusableBrowsingContextName(req.FrameName) != "" {
17486 return dto.TriggerNamedTargetNavigation
17587 }
17688 return dto.TriggerLinkNewPage
17789 case dto.WindowDispositionNewTab:
178- if reusableBrowsingContextName(req.FrameName) != "" {
90+ if usecase.ReusableBrowsingContextName(req.FrameName) != "" {
17991 return dto.TriggerNamedTargetNavigation
18092 }
18193 return dto.TriggerLinkNewPage
18294 case dto.WindowDispositionNewPopup, dto.WindowDispositionNewWindow:
183- if reusableBrowsingContextName(req.FrameName) != "" {
95+ if usecase.ReusableBrowsingContextName(req.FrameName) != "" {
18496 return dto.TriggerNamedTargetNavigation
18597 }
18698 return dto.TriggerScriptWindowOpen
18799 default:
188- if reusableBrowsingContextName(req.FrameName) != "" {
100+ if usecase.ReusableBrowsingContextName(req.FrameName) != "" {
189101 return dto.TriggerNamedTargetNavigation
190102 }
191103 return dto.TriggerUnknown
192104 }
193105 }
194-
195-func isAmbiguousNativeBrowsingContext(req dto.NewBrowsingContextRequest) bool {
196- return req.TriggerKind == dto.TriggerUnknown && !req.NoJavaScriptAccess
197-}
198-
199-func reusableBrowsingContextName(frameName string) string {
200- name := strings.TrimSpace(frameName)
201- if name == "" || strings.EqualFold(name, "_blank") {
202- return ""
203- }
204- return name
205-}
M internal/ui/coordinator/content/browsing_context_policy_test.go +0 -68
@@ -8,74 +8,6 @@ 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-
39-func TestBrowsingContextPolicyDecide(t *testing.T) {
40- t.Parallel()
41-
42- workspace := dto.NewBrowsingContextRequest{SourceHost: dto.SourceHostWorkspace, TargetURI: "https://example.com/docs", TargetDisposition: dto.WindowDispositionNewTab, TriggerKind: dto.TriggerLinkNewPage}
43- tests := []struct {
44- name string
45- request dto.NewBrowsingContextRequest
46- namedContextExist bool
47- wantKind dto.HostDecisionKind
48- wantName string
49- wantReason dto.HostDecisionReasonCode
50- }{
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 navigates source", request: dto.NewBrowsingContextRequest{SourceHost: dto.SourceHostFloating, TargetURI: "https://example.com/docs", TargetFrameName: "_blank", TargetDisposition: dto.WindowDispositionNewTab, TriggerKind: dto.TriggerLinkNewPage, PopupFeatures: dto.PopupFeatures{State: dto.PopupFeaturesNone}}, wantKind: dto.HostDecisionNavigateSource, wantReason: dto.HostDecisionReasonFloatingFeaturelessBlank},
54- {name: "floating featureless script navigates source", request: dto.NewBrowsingContextRequest{SourceHost: dto.SourceHostFloating, TargetURI: "https://example.com/help", TargetFrameName: "_blank", TargetDisposition: dto.WindowDispositionNewPopup, TriggerKind: dto.TriggerScriptWindowOpen, PopupFeatures: dto.PopupFeatures{State: dto.PopupFeaturesNone}}, wantKind: dto.HostDecisionNavigateSource, wantReason: dto.HostDecisionReasonFloatingFeaturelessBlank},
55- {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},
64- }
65-
66- for _, tt := range tests {
67- t.Run(tt.name, func(t *testing.T) {
68- t.Parallel()
69- got := (browsingContextPolicy{}).Decide(tt.request, tt.namedContextExist)
70- assert.Equal(t, tt.wantKind, got.Kind)
71- assert.Equal(t, tt.wantName, got.BrowsingContextName)
72- assert.Equal(t, tt.wantReason, got.ReasonCode)
73- assert.NotEmpty(t, got.Reason)
74- assert.Equal(t, dto.RequestContextInheritParent, got.RequestContextDisposition)
75- })
76- }
77-}
78-
7911 func TestBuildPopupBrowsingContextRequestCarriesNormalizedMetadata(t *testing.T) {
8012 t.Parallel()
8113
A internal/ui/coordinator/content/popup_browsing_context_test.go +846 -0
@@ -0,0 +1,846 @@
1+package content
2+
3+import (
4+ "bytes"
5+ "context"
6+ "errors"
7+ "testing"
8+
9+ "github.com/rs/zerolog"
10+
11+ "github.com/stretchr/testify/assert"
12+ "github.com/stretchr/testify/mock"
13+ "github.com/stretchr/testify/require"
14+
15+ "github.com/bnema/dumber/internal/application/dto"
16+ "github.com/bnema/dumber/internal/application/port"
17+ "github.com/bnema/dumber/internal/application/port/mocks"
18+ "github.com/bnema/dumber/internal/domain/entity"
19+ domainerrors "github.com/bnema/dumber/internal/domain/errors"
20+ "github.com/bnema/dumber/internal/logging"
21+)
22+
23+// ---------------------------------------------------------------------------
24+// DetectPopupType
25+// ---------------------------------------------------------------------------
26+
27+func newPopupCreateCoordinatorForTest(t *testing.T, popupID port.WebViewID) (context.Context, entity.PaneID, *mocks.MockWebView, *mocks.MockWebView, *Coordinator) {
28+ t.Helper()
29+
30+ ctx := context.Background()
31+ parentPaneID := entity.PaneID("parent-pane")
32+ parentWV := mocks.NewMockWebView(t)
33+ parentWV.EXPECT().ID().Return(port.WebViewID(101)).Once()
34+
35+ popupWV := mocks.NewMockWebView(t)
36+ popupWV.EXPECT().ID().Return(popupID).Maybe()
37+
38+ factory := mocks.NewMockWebViewFactory(t)
39+ factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(popupWV, nil).Once()
40+
41+ c := &Coordinator{
42+ webViews: make(map[entity.PaneID]port.WebView),
43+ popups: newPopupManager(),
44+ }
45+ c.SetPopupConfig(factory, nil, func() string { return "popup-pane" })
46+
47+ return ctx, parentPaneID, parentWV, popupWV, c
48+}
49+
50+func TestPopupFloatingWebKitLinkBlankNavigatesSourceAndDestroysCandidateExactlyOnce(t *testing.T) {
51+ ctx := context.Background()
52+ parentPaneID := entity.PaneID("floating")
53+ parentWV := mocks.NewMockWebView(t)
54+ parentWV.EXPECT().ID().Return(port.WebViewID(101)).Once()
55+ parentWV.EXPECT().LoadURI(mock.Anything, "https://example.com/new").Return(nil).Once()
56+ popupWV := &popupNavigationWebViewStub{MockWebView: mocks.NewMockWebView(t)}
57+ popupWV.EXPECT().Destroy().Once()
58+
59+ factory := mocks.NewMockWebViewFactory(t)
60+ factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(popupWV, nil).Once()
61+ c := &Coordinator{popups: newPopupManager()}
62+ c.SetPopupConfig(factory, &entity.BrowsingContextConfig{OpenInNewPane: false}, func() string { return "detached-pane" })
63+ c.SetPopupSourceHostResolver(func(entity.PaneID) dto.SourceHostKind { return dto.SourceHostFloating })
64+ c.SetOnInsertPopup(func(context.Context, InsertPopupInput) error {
65+ t.Fatal("floating request must not use workspace insertion")
66+ return nil
67+ })
68+ c.SetOnOpenBrowserWindow(func(context.Context, BrowserWindowInput) (BrowserWindowResult, error) {
69+ t.Fatal("featureless _blank must not open a browser window")
70+ return BrowserWindowResult{}, nil
71+ })
72+
73+ got := c.handlePopupCreate(ctx, parentPaneID, parentWV, port.PopupRequest{
74+ Engine: dto.BrowserEngineWebKit,
75+ TargetURI: "https://example.com/new",
76+ FrameName: "_blank",
77+ TargetDisposition: dto.WindowDispositionNewTab,
78+ PopupFeatures: dto.PopupFeatures{State: dto.PopupFeaturesNone},
79+ })
80+
81+ assert.Nil(t, got, "featureless link popup must be discarded after source navigation")
82+ decision, ok := popupWV.BrowsingContextHostDecision()
83+ require.True(t, ok)
84+ assert.Equal(t, dto.HostDecisionNavigateSource, decision.Kind)
85+}
86+
87+func TestPopupFloatingMiddleClickNavigatesSourceWhenWorkspacePopupsDisabled(t *testing.T) {
88+ ctx := context.Background()
89+ parentPaneID := entity.PaneID("floating")
90+ parentWV := mocks.NewMockWebView(t)
91+ parentWV.EXPECT().ID().Return(port.WebViewID(101)).Once()
92+ parentWV.EXPECT().LoadURI(mock.Anything, "https://example.com/middle").Return(nil).Once()
93+ factory := mocks.NewMockWebViewFactory(t)
94+ c := &Coordinator{webViews: map[entity.PaneID]port.WebView{parentPaneID: parentWV}, popups: newPopupManager()}
95+ c.SetPopupConfig(factory, &entity.BrowsingContextConfig{OpenInNewPane: false}, func() string { return "detached-pane" })
96+ c.SetPopupSourceHostResolver(func(entity.PaneID) dto.SourceHostKind { return dto.SourceHostFloating })
97+ c.SetOnInsertPopup(func(context.Context, InsertPopupInput) error {
98+ t.Fatal("floating middle-click must not use workspace insertion")
99+ return nil
100+ })
101+ c.SetOnOpenBrowserWindow(func(context.Context, BrowserWindowInput) (BrowserWindowResult, error) {
102+ t.Fatal("floating middle-click must not open a browser window")
103+ return BrowserWindowResult{}, nil
104+ })
105+
106+ assert.True(t, c.handleLinkMiddleClick(ctx, parentPaneID, "https://example.com/middle"))
107+}
108+
109+func TestPopupWorkspaceBlankRemainsBlockedWhenWorkspacePopupsDisabled(t *testing.T) {
110+ parentWV := mocks.NewMockWebView(t)
111+ factory := mocks.NewMockWebViewFactory(t)
112+ c := &Coordinator{popups: newPopupManager()}
113+ c.SetPopupConfig(factory, &entity.BrowsingContextConfig{OpenInNewPane: false}, nil)
114+ c.SetPopupSourceHostResolver(func(entity.PaneID) dto.SourceHostKind { return dto.SourceHostWorkspace })
115+
116+ got := c.handlePopupCreate(context.Background(), "workspace", parentWV, port.PopupRequest{TargetURI: "https://example.com/new"})
117+
118+ assert.Nil(t, got)
119+}
120+
121+func TestPopupWorkspaceMiddleClickRemainsBlockedWhenWorkspacePopupsDisabled(t *testing.T) {
122+ c := &Coordinator{popups: newPopupManager()}
123+ c.SetPopupConfig(mocks.NewMockWebViewFactory(t), &entity.BrowsingContextConfig{OpenInNewPane: false}, nil)
124+ c.SetPopupSourceHostResolver(func(entity.PaneID) dto.SourceHostKind { return dto.SourceHostWorkspace })
125+
126+ assert.False(t, c.handleLinkMiddleClick(context.Background(), "workspace", "https://example.com/middle"))
127+}
128+
129+func TestPopupFloatingNamedContextReusesDetachedWindow(t *testing.T) {
130+ ctx := context.Background()
131+ parentPaneID := entity.PaneID("floating")
132+ parentWV := mocks.NewMockWebView(t)
133+ parentWV.EXPECT().ID().Return(port.WebViewID(101)).Twice()
134+ popupWV := mocks.NewMockWebView(t)
135+ popupWV.EXPECT().ID().Return(port.WebViewID(201)).Maybe()
136+ popupWV.EXPECT().Generation().Return(uint64(1)).Maybe()
137+ popupWV.EXPECT().SetCallbacks(mock.Anything).Once()
138+ popupWV.EXPECT().IsDestroyed().Return(false).Twice()
139+ popupWV.EXPECT().LoadURI(mock.Anything, "https://example.com/second").Return(nil).Once()
140+
141+ factory := mocks.NewMockWebViewFactory(t)
142+ factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(popupWV, nil).Once()
143+ c := &Coordinator{webViews: make(map[entity.PaneID]port.WebView), popups: newPopupManager()}
144+ c.SetPopupConfig(factory, &entity.BrowsingContextConfig{OpenInNewPane: false}, func() string { return "detached-pane" })
145+ c.SetPopupSourceHostResolver(func(entity.PaneID) dto.SourceHostKind { return dto.SourceHostFloating })
146+ windowByPane := map[entity.PaneID]string{parentPaneID: "owner-window"}
147+ c.SetPopupWindowIDResolver(func(paneID entity.PaneID) (string, bool) {
148+ windowID, ok := windowByPane[paneID]
149+ return windowID, ok
150+ })
151+ hostCalls := 0
152+ c.SetOnOpenBrowserWindow(func(_ context.Context, input BrowserWindowInput) (BrowserWindowResult, error) {
153+ hostCalls++
154+ windowByPane[input.PopupPane.ID] = "detached-window"
155+ c.RegisterPopupWebView(input.PopupPane.ID, input.PopupWebView)
156+ return BrowserWindowResult{WindowID: "detached-window"}, nil
157+ })
158+
159+ first := c.handlePopupCreate(ctx, parentPaneID, parentWV, port.PopupRequest{
160+ Engine: dto.BrowserEngineCEF, TargetURI: "https://example.com/first", FrameName: "shared",
161+ TargetDisposition: dto.WindowDispositionNewPopup, PopupFeatures: dto.PopupFeatures{State: dto.PopupFeaturesNone},
162+ })
163+ second := c.handlePopupCreate(ctx, parentPaneID, parentWV, port.PopupRequest{
164+ Engine: dto.BrowserEngineCEF, TargetURI: "https://example.com/second", FrameName: "shared",
165+ TargetDisposition: dto.WindowDispositionNewPopup, PopupFeatures: dto.PopupFeatures{State: dto.PopupFeaturesNone},
166+ })
167+
168+ assert.Same(t, popupWV, first)
169+ assert.Nil(t, second, "CEF must block native creation after navigating the detached named context")
170+ assert.Equal(t, 1, hostCalls)
171+}
172+
173+func TestPopupFloatingNoopenerDoesNotReuseDetachedNamedContext(t *testing.T) {
174+ ctx := context.Background()
175+ parentPaneID := entity.PaneID("floating")
176+ parentWV := mocks.NewMockWebView(t)
177+ parentWV.EXPECT().ID().Return(port.WebViewID(101)).Twice()
178+ firstWV := mocks.NewMockWebView(t)
179+ firstWV.EXPECT().ID().Return(port.WebViewID(201)).Maybe()
180+ firstWV.EXPECT().Generation().Return(uint64(1)).Maybe()
181+ firstWV.EXPECT().SetCallbacks(mock.Anything).Once()
182+ secondWV := mocks.NewMockWebView(t)
183+ secondWV.EXPECT().ID().Return(port.WebViewID(202)).Maybe()
184+ secondWV.EXPECT().Generation().Return(uint64(1)).Maybe()
185+ secondWV.EXPECT().SetCallbacks(mock.Anything).Once()
186+ factory := mocks.NewMockWebViewFactory(t)
187+ factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(firstWV, nil).Once()
188+ factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(secondWV, nil).Once()
189+ c := &Coordinator{popups: newPopupManager()}
190+ c.SetPopupConfig(factory, &entity.BrowsingContextConfig{OpenInNewPane: false}, func() string { return "detached-pane" })
191+ c.SetPopupSourceHostResolver(func(entity.PaneID) dto.SourceHostKind { return dto.SourceHostFloating })
192+ hostCalls := 0
193+ c.SetOnOpenBrowserWindow(func(_ context.Context, _ BrowserWindowInput) (BrowserWindowResult, error) {
194+ hostCalls++
195+ return BrowserWindowResult{WindowID: "detached-window"}, nil
196+ })
197+ req := port.PopupRequest{
198+ Engine: dto.BrowserEngineCEF, FrameName: "shared", NoJavaScriptAccess: true,
199+ TargetDisposition: dto.WindowDispositionNewPopup, PopupFeatures: dto.PopupFeatures{State: dto.PopupFeaturesNone},
200+ }
201+ req.TargetURI = "https://example.com/first"
202+ first := c.handlePopupCreate(ctx, parentPaneID, parentWV, req)
203+ req.TargetURI = "https://example.com/second"
204+ second := c.handlePopupCreate(ctx, parentPaneID, parentWV, req)
205+
206+ assert.Same(t, firstWV, first)
207+ assert.Same(t, secondWV, second)
208+ assert.Equal(t, 2, hostCalls)
209+}
210+
211+func TestPopupFloatingBrowserHostFailureDestroysWebViewExactlyOnce(t *testing.T) {
212+ parentWV := mocks.NewMockWebView(t)
213+ parentWV.EXPECT().ID().Return(port.WebViewID(101)).Once()
214+ popupWV := mocks.NewMockWebView(t)
215+ popupWV.EXPECT().ID().Return(port.WebViewID(201)).Maybe()
216+ popupWV.EXPECT().Generation().Return(uint64(1)).Maybe()
217+ popupWV.EXPECT().SetCallbacks(mock.Anything).Once()
218+ popupWV.EXPECT().Destroy().Once()
219+ factory := mocks.NewMockWebViewFactory(t)
220+ factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(popupWV, nil).Once()
221+ c := &Coordinator{popups: newPopupManager()}
222+ c.SetPopupConfig(factory, &entity.BrowsingContextConfig{OpenInNewPane: false}, func() string { return "detached-pane" })
223+ c.SetPopupSourceHostResolver(func(entity.PaneID) dto.SourceHostKind { return dto.SourceHostFloating })
224+ c.SetOnOpenBrowserWindow(func(context.Context, BrowserWindowInput) (BrowserWindowResult, error) {
225+ return BrowserWindowResult{}, errors.New("host failed")
226+ })
227+
228+ got := c.handlePopupCreate(context.Background(), "floating", parentWV, port.PopupRequest{
229+ Engine: dto.BrowserEngineCEF, TargetURI: "https://example.com/new", FrameName: "shared",
230+ TargetDisposition: dto.WindowDispositionNewPopup, PopupFeatures: dto.PopupFeatures{State: dto.PopupFeaturesNone},
231+ })
232+
233+ assert.Nil(t, got)
234+}
235+
236+func TestHandlePopupCreate_PrimesPopupNavigationCapability(t *testing.T) {
237+ ctx := context.Background()
238+ parentPaneID := entity.PaneID("parent-pane")
239+ parentWV := mocks.NewMockWebView(t)
240+ parentWV.EXPECT().ID().Return(port.WebViewID(101)).Once()
241+
242+ popupWV := &popupNavigationWebViewStub{MockWebView: mocks.NewMockWebView(t)}
243+ popupWV.EXPECT().ID().Return(port.WebViewID(149)).Maybe()
244+ popupWV.EXPECT().Generation().Return(uint64(1)).Maybe()
245+ popupWV.EXPECT().SetCallbacks(mock.Anything).Once()
246+
247+ factory := mocks.NewMockWebViewFactory(t)
248+ factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(popupWV, nil).Once()
249+
250+ c := &Coordinator{
251+ webViews: make(map[entity.PaneID]port.WebView),
252+ popups: newPopupManager(),
253+ }
254+ c.SetPopupConfig(factory, nil, nil)
255+ c.SetOnInsertPopup(func(_ context.Context, input InsertPopupInput) error {
256+ return nil
257+ })
258+
259+ created := c.handlePopupCreate(ctx, parentPaneID, parentWV, port.PopupRequest{
260+ TargetURI: "https://example.com/popup",
261+ FrameName: "auth-popup",
262+ IsUserGesture: true,
263+ NoJavaScriptAccess: true,
264+ })
265+
266+ require.Same(t, popupWV, created)
267+ require.Equal(t, []string{"https://example.com/popup"}, popupWV.primed)
268+}
269+
270+func TestHandlePopupCreate_RegistersPopupWebViewBeforeWorkspaceInsertion(t *testing.T) {
271+ ctx, parentPaneID, parentWV, popupWV, c := newPopupCreateCoordinatorForTest(t, port.WebViewID(150))
272+ popupWV.EXPECT().Generation().Return(uint64(1)).Maybe()
273+ popupWV.EXPECT().SetCallbacks(mock.Anything).Once()
274+ popupWV.EXPECT().IsLoading().Return(false).Once()
275+ popupWV.EXPECT().URI().Return("").Once()
276+ popupWV.EXPECT().LoadURI(mock.Anything, "https://example.com/popup").Return(nil).Once()
277+
278+ c.SetOnInsertPopup(func(_ context.Context, input InsertPopupInput) error {
279+ require.Equal(t, entity.PaneID("popup-pane"), input.PopupPane.ID)
280+ require.Same(t, popupWV, c.getWebViewLocked(input.PopupPane.ID))
281+ return nil
282+ })
283+
284+ created := c.handlePopupCreate(ctx, parentPaneID, parentWV, port.PopupRequest{
285+ TargetURI: "https://example.com/popup",
286+ FrameName: "auth-popup",
287+ IsUserGesture: true,
288+ NoJavaScriptAccess: true,
289+ })
290+
291+ require.Same(t, popupWV, created)
292+}
293+
294+func TestHandlePopupCreate_CleansUpPreRegisteredPopupWebViewWhenInsertionFails(t *testing.T) {
295+ ctx, parentPaneID, parentWV, popupWV, c := newPopupCreateCoordinatorForTest(t, port.WebViewID(151))
296+ popupWV.EXPECT().Generation().Return(uint64(1)).Maybe()
297+ popupWV.EXPECT().SetCallbacks(mock.Anything).Once()
298+ popupWV.EXPECT().Destroy().Once()
299+
300+ c.SetOnInsertPopup(func(_ context.Context, input InsertPopupInput) error {
301+ require.Same(t, popupWV, c.getWebViewLocked(input.PopupPane.ID))
302+ return assert.AnError
303+ })
304+
305+ created := c.handlePopupCreate(ctx, parentPaneID, parentWV, port.PopupRequest{
306+ TargetURI: "https://example.com/popup",
307+ FrameName: "auth-popup",
308+ IsUserGesture: true,
309+ NoJavaScriptAccess: true,
310+ })
311+
312+ require.Nil(t, created)
313+ require.Nil(t, c.getWebViewLocked(entity.PaneID("popup-pane")))
314+}
315+
316+func TestHandlePopupCreate_UsesRelatedWebViewWhenPopupDisablesJavaScriptAccess(t *testing.T) {
317+ ctx := context.Background()
318+ parentPaneID := entity.PaneID("parent-pane")
319+ parentWV := mocks.NewMockWebView(t)
320+ parentWV.EXPECT().ID().Return(port.WebViewID(101)).Once()
321+
322+ popupWV := mocks.NewMockWebView(t)
323+ popupWV.EXPECT().ID().Return(port.WebViewID(150)).Maybe()
324+ popupWV.EXPECT().Generation().Return(uint64(1)).Maybe()
325+ popupWV.EXPECT().SetCallbacks(mock.Anything).Once()
326+ popupWV.EXPECT().IsLoading().Return(false).Once()
327+ popupWV.EXPECT().URI().Return("").Once()
328+ popupWV.EXPECT().LoadURI(mock.Anything, "https://example.com/noopener").Return(nil).Once()
329+
330+ factory := mocks.NewMockWebViewFactory(t)
331+ factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(popupWV, nil).Once()
332+
333+ c := &Coordinator{
334+ webViews: make(map[entity.PaneID]port.WebView),
335+ popups: newPopupManager(),
336+ }
337+ c.SetPopupConfig(factory, nil, nil)
338+ c.SetOnInsertPopup(func(_ context.Context, input InsertPopupInput) error {
339+ assert.Equal(t, "https://example.com/noopener", input.TargetURI)
340+ return nil
341+ })
342+
343+ created := c.handlePopupCreate(ctx, parentPaneID, parentWV, port.PopupRequest{
344+ TargetURI: "https://example.com/noopener",
345+ FrameName: "auth-popup",
346+ IsUserGesture: true,
347+ NoJavaScriptAccess: true,
348+ })
349+
350+ require.Same(t, popupWV, created)
351+}
352+
353+func TestHandlePopupCreate_DeniesWhenRelatedCreateIsUnsupported(t *testing.T) {
354+ ctx := context.Background()
355+ parentPaneID := entity.PaneID("parent-pane")
356+ parentWV := mocks.NewMockWebView(t)
357+ parentWV.EXPECT().ID().Return(port.WebViewID(101)).Once()
358+
359+ factory := mocks.NewMockWebViewFactory(t)
360+ factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(nil, domainerrors.ErrRelatedWebViewUnsupported).Once()
361+
362+ insertCalls := 0
363+ c := &Coordinator{
364+ webViews: make(map[entity.PaneID]port.WebView),
365+ popups: newPopupManager(),
366+ }
367+ c.SetPopupConfig(factory, nil, nil)
368+ c.SetOnInsertPopup(func(_ context.Context, input InsertPopupInput) error {
369+ insertCalls++
370+ return nil
371+ })
372+
373+ created := c.handlePopupCreate(ctx, parentPaneID, parentWV, port.PopupRequest{
374+ TargetURI: "https://example.com/edit",
375+ FrameName: "_blank",
376+ IsUserGesture: true,
377+ })
378+
379+ require.Nil(t, created)
380+ assert.Equal(t, 0, insertCalls)
381+}
382+
383+func TestHandlePopupCreate_DeniesWhenRelatedCreateFailsUnexpectedly(t *testing.T) {
384+ ctx := context.Background()
385+ parentPaneID := entity.PaneID("parent-pane")
386+ parentWV := mocks.NewMockWebView(t)
387+ parentWV.EXPECT().ID().Return(port.WebViewID(101)).Once()
388+
389+ factory := mocks.NewMockWebViewFactory(t)
390+ factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(nil, errors.New("boom")).Once()
391+
392+ insertCalls := 0
393+ c := &Coordinator{
394+ webViews: make(map[entity.PaneID]port.WebView),
395+ popups: newPopupManager(),
396+ }
397+ c.SetPopupConfig(factory, nil, nil)
398+ c.SetOnInsertPopup(func(_ context.Context, input InsertPopupInput) error {
399+ insertCalls++
400+ return nil
401+ })
402+
403+ created := c.handlePopupCreate(ctx, parentPaneID, parentWV, port.PopupRequest{
404+ TargetURI: "https://example.com/edit",
405+ FrameName: "_blank",
406+ IsUserGesture: true,
407+ })
408+
409+ require.Nil(t, created)
410+ assert.Equal(t, 0, insertCalls)
411+}
412+
413+func TestHandleLinkMiddleClick_UsesRelatedWebView(t *testing.T) {
414+ ctx := context.Background()
415+ parentPaneID := entity.PaneID("parent-pane")
416+ parentWV := mocks.NewMockWebView(t)
417+ parentWV.EXPECT().ID().Return(port.WebViewID(101)).Twice()
418+
419+ newWV := &popupNavigationWebViewStub{MockWebView: mocks.NewMockWebView(t)}
420+ newWV.EXPECT().ID().Return(port.WebViewID(301)).Maybe()
421+ newWV.EXPECT().Generation().Return(uint64(1)).Maybe()
422+ newWV.EXPECT().SetCallbacks(mock.Anything).Maybe()
423+ newWV.EXPECT().LoadURI(mock.Anything, "https://example.com/middle-click").Return(nil).Maybe()
424+
425+ factory := mocks.NewMockWebViewFactory(t)
426+ factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(newWV, nil).Maybe()
427+
428+ insertCalls := 0
429+ c := &Coordinator{
430+ webViews: map[entity.PaneID]port.WebView{parentPaneID: parentWV},
431+ popups: newPopupManager(),
432+ }
433+ c.SetPopupConfig(factory, nil, nil)
434+ c.SetOnInsertPopup(func(_ context.Context, input InsertPopupInput) error {
435+ insertCalls++
436+ assert.Equal(t, parentPaneID, input.ParentPaneID)
437+ assert.Same(t, newWV, input.WebView)
438+ assert.Equal(t, entity.PopupBehaviorSplit, input.Behavior)
439+ return nil
440+ })
441+
442+ handled := c.handleLinkMiddleClick(ctx, parentPaneID, "https://example.com/middle-click")
443+ assert.True(t, handled)
444+ assert.Equal(t, 1, insertCalls)
445+ decision, hasDecision := newWV.BrowsingContextHostDecision()
446+ require.True(t, hasDecision)
447+ assert.Equal(t, dto.HostDecisionCreatePane, decision.Kind)
448+}
449+
450+func TestHandlePopupCreate_OpensNativePopupForAuthIntent(t *testing.T) {
451+ ctx := context.Background()
452+ parentPaneID := entity.PaneID("parent-pane")
453+ parentWV := mocks.NewMockWebView(t)
454+ parentWV.EXPECT().ID().Return(port.WebViewID(101)).Once()
455+ parentWV.EXPECT().URI().Return("https://x.com/i/flow/login").Once()
456+
457+ popupWV := &popupNavigationWebViewStub{MockWebView: mocks.NewMockWebView(t)}
458+ popupWV.EXPECT().ID().Return(port.WebViewID(401)).Maybe()
459+
460+ factory := mocks.NewMockWebViewFactory(t)
461+ factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(popupWV, nil).Once()
462+
463+ insertCalls := 0
464+ nativeCalls := 0
465+ c := &Coordinator{
466+ webViews: make(map[entity.PaneID]port.WebView),
467+ popups: newPopupManager(),
468+ }
469+ c.SetPopupConfig(factory, &entity.BrowsingContextConfig{OpenInNewPane: true, OAuthAutoClose: true}, nil)
470+ c.SetOnInsertPopup(func(context.Context, InsertPopupInput) error {
471+ insertCalls++
472+ return nil
473+ })
474+ c.SetOnOpenNativePopup(func(_ context.Context, input NativePopupInput) error {
475+ nativeCalls++
476+ assert.Equal(t, parentPaneID, input.ParentPaneID)
477+ assert.Equal(t, popupWV, input.PopupWebView)
478+ assert.Equal(t, "https://accounts.google.com/o/oauth2/v2/auth", input.TargetURI)
479+ assert.True(t, input.ObserveOAuthAutoClose)
480+ return nil
481+ })
482+
483+ created := c.handlePopupCreate(ctx, parentPaneID, parentWV, port.PopupRequest{
484+ TargetURI: "https://accounts.google.com/o/oauth2/v2/auth",
485+ FrameName: "oauth-popup",
486+ IsUserGesture: true,
487+ })
488+
489+ require.Equal(t, popupWV, created)
490+ assert.Equal(t, 0, insertCalls)
491+ assert.Equal(t, 1, nativeCalls)
492+ assert.Equal(t, []string{"https://accounts.google.com/o/oauth2/v2/auth"}, popupWV.primed)
493+}
494+
495+func TestHandlePopupCreate_NativePopupDoesNotForceOAuthObservationWhenDisabled(t *testing.T) {
496+ ctx := context.Background()
497+ parentPaneID := entity.PaneID("parent-pane")
498+ parentWV := mocks.NewMockWebView(t)
499+ parentWV.EXPECT().ID().Return(port.WebViewID(101)).Once()
500+
501+ popupWV := &popupNavigationWebViewStub{MockWebView: mocks.NewMockWebView(t)}
502+ popupWV.EXPECT().ID().Return(port.WebViewID(402)).Maybe()
503+
504+ factory := mocks.NewMockWebViewFactory(t)
505+ factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(popupWV, nil).Once()
506+
507+ insertCalls := 0
508+ nativeCalls := 0
509+ c := &Coordinator{
510+ webViews: make(map[entity.PaneID]port.WebView),
511+ popups: newPopupManager(),
512+ }
513+ c.SetPopupConfig(factory, &entity.BrowsingContextConfig{OpenInNewPane: true, OAuthAutoClose: false}, nil)
514+ c.SetOnInsertPopup(func(context.Context, InsertPopupInput) error {
515+ insertCalls++
516+ return nil
517+ })
518+ c.SetOnOpenNativePopup(func(_ context.Context, input NativePopupInput) error {
519+ nativeCalls++
520+ assert.False(t, input.ObserveOAuthAutoClose)
521+ return nil
522+ })
523+
524+ created := c.handlePopupCreate(ctx, parentPaneID, parentWV, port.PopupRequest{
525+ TargetURI: "https://accounts.google.com/o/oauth2/v2/auth",
526+ FrameName: "oauth-popup",
527+ IsUserGesture: true,
528+ })
529+
530+ require.Equal(t, popupWV, created)
531+ assert.Equal(t, 1, nativeCalls)
532+ assert.Equal(t, 0, insertCalls)
533+}
534+
535+func TestPopupNativeAbortFallsBackOnceForNoOpenerVisualPopup(t *testing.T) {
536+ ctx := context.Background()
537+ popupWV := &popupNavigationWebViewStub{MockWebView: mocks.NewMockWebView(t)}
538+ popupWV.EXPECT().ID().Return(port.WebViewID(701)).Once()
539+ factory := mocks.NewMockWebViewFactory(t)
540+ factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(popupWV, nil).Once()
541+
542+ pm := newPopupManager()
543+ pm.setConfig(factory, nil, nil)
544+ browserCalls := 0
545+ pm.setOnOpenBrowserWindow(func(_ context.Context, input BrowserWindowInput) (BrowserWindowResult, error) {
546+ browserCalls++
547+ assert.Same(t, popupWV, input.PopupWebView)
548+ assert.True(t, input.Ready)
549+ return BrowserWindowResult{WindowID: "fallback-window"}, nil
550+ })
551+ pm.setOnOpenNativePopup(func(ctx context.Context, input NativePopupInput) error {
552+ assert.True(t, input.AllowBrowserWindowFallback)
553+ assert.True(t, input.OnNativeHostAbort(ctx, input.PopupWebView))
554+ assert.True(t, input.OnNativeHostAbort(ctx, input.PopupWebView))
555+ return nil
556+ })
557+
558+ got := pm.openNativePopup(ctx, popupCoordinatorHooks{}, "floating", 101, "", port.PopupRequest{
559+ TargetURI: "https://example.com/visual", NoJavaScriptAccess: true,
560+ }, dto.HostDecision{Kind: dto.HostDecisionCreateNativePopup})
561+ require.Same(t, popupWV, got)
562+ assert.Equal(t, 1, browserCalls)
563+}
564+
565+func TestPopupNativeAbortFallsBackForOpenerCapableVisualPopup(t *testing.T) {
566+ ctx := context.Background()
567+ popupWV := &popupOpenerWebViewStub{popupNavigationWebViewStub: &popupNavigationWebViewStub{MockWebView: mocks.NewMockWebView(t)}}
568+ popupWV.EXPECT().ID().Return(port.WebViewID(703)).Once()
569+ factory := mocks.NewMockWebViewFactory(t)
570+ factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(popupWV, nil).Once()
571+
572+ pm := newPopupManager()
573+ pm.setConfig(factory, nil, nil)
574+ browserCalls := 0
575+ pm.setOnOpenBrowserWindow(func(_ context.Context, input BrowserWindowInput) (BrowserWindowResult, error) {
576+ browserCalls++
577+ assert.Same(t, popupWV, input.PopupWebView)
578+ return BrowserWindowResult{WindowID: "fallback-window"}, nil
579+ })
580+ pm.setOnOpenNativePopup(func(ctx context.Context, input NativePopupInput) error {
581+ assert.True(t, input.AllowBrowserWindowFallback)
582+ assert.True(t, input.OnNativeHostAbort(ctx, input.PopupWebView))
583+ return nil
584+ })
585+
586+ got := pm.openNativePopup(ctx, popupCoordinatorHooks{}, "floating", 101, "", port.PopupRequest{
587+ TargetURI: "https://example.com/visual", NoJavaScriptAccess: false,
588+ }, dto.HostDecision{Kind: dto.HostDecisionCreateNativePopup})
589+
590+ require.Same(t, popupWV, got)
591+ assert.Equal(t, 1, browserCalls)
592+}
593+
594+func TestPopupNativeAbortDoesNotFallbackForAuthOrRequiredOpener(t *testing.T) {
595+ for _, tc := range []struct {
596+ name string
597+ target string
598+ decision dto.HostDecision
599+ }{
600+ {name: "auth", target: "https://accounts.google.com/o/oauth2/auth", decision: dto.HostDecision{Kind: dto.HostDecisionCreateNativePopup}},
601+ {name: "required opener", target: "https://example.com/opener", decision: dto.HostDecision{Kind: dto.HostDecisionCreateNativePopup, RequiresNativeOpener: true}},
602+ } {
603+ t.Run(tc.name, func(t *testing.T) {
604+ popupWV := &popupNavigationWebViewStub{MockWebView: mocks.NewMockWebView(t)}
605+ popupWV.EXPECT().Destroy().Once()
606+ factory := mocks.NewMockWebViewFactory(t)
607+ factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(popupWV, nil).Once()
608+ pm := newPopupManager()
609+ pm.setConfig(factory, nil, nil)
610+ pm.setOnOpenBrowserWindow(func(context.Context, BrowserWindowInput) (BrowserWindowResult, error) {
611+ t.Fatal("auth/opener-required popup must not degrade to browser fallback")
612+ return BrowserWindowResult{}, nil
613+ })
614+ pm.setOnOpenNativePopup(func(ctx context.Context, input NativePopupInput) error {
615+ assert.False(t, input.AllowBrowserWindowFallback)
616+ if !input.OnNativeHostAbort(ctx, input.PopupWebView) {
617+ input.PopupWebView.Destroy()
618+ }
619+ return nil
620+ })
621+
622+ got := pm.openNativePopup(context.Background(), popupCoordinatorHooks{}, "floating", 101, "", port.PopupRequest{
623+ TargetURI: tc.target, NoJavaScriptAccess: true,
624+ }, tc.decision)
625+ require.Same(t, popupWV, got)
626+ })
627+ }
628+}
629+
630+func TestPopupNativeFallbackFailureLeavesDestructionToNativeHost(t *testing.T) {
631+ var output bytes.Buffer
632+ logger := zerolog.New(&output)
633+ ctx := logging.WithContext(context.Background(), logger)
634+ popupWV := &popupNavigationWebViewStub{MockWebView: mocks.NewMockWebView(t)}
635+ popupWV.EXPECT().ID().Return(port.WebViewID(702)).Once()
636+ popupWV.EXPECT().Destroy().Once()
637+ factory := mocks.NewMockWebViewFactory(t)
638+ factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(popupWV, nil).Once()
639+ pm := newPopupManager()
640+ pm.setConfig(factory, nil, nil)
641+ pm.setSourceHostResolver(func(entity.PaneID) dto.SourceHostKind { return dto.SourceHostFloating })
642+ pm.setOnOpenBrowserWindow(func(context.Context, BrowserWindowInput) (BrowserWindowResult, error) {
643+ return BrowserWindowResult{}, errors.New("fallback failed")
644+ })
645+ pm.setOnOpenNativePopup(func(ctx context.Context, input NativePopupInput) error {
646+ if !input.OnNativeHostAbort(ctx, input.PopupWebView) {
647+ input.PopupWebView.Destroy()
648+ }
649+ return nil
650+ })
651+
652+ got := pm.openNativePopup(ctx, popupCoordinatorHooks{}, "floating", 101, "", port.PopupRequest{
653+ Engine: dto.BrowserEngineCEF, TargetURI: "https://example.com/visual", NoJavaScriptAccess: true,
654+ TargetDisposition: dto.WindowDispositionNewPopup,
655+ }, dto.HostDecision{Kind: dto.HostDecisionCreateNativePopup})
656+ require.Same(t, popupWV, got)
657+ record := decodeBrowsingContextLog(t, output.Bytes())
658+ assert.Equal(t, "cef", record["engine"])
659+ assert.Equal(t, "floating", record["source_host"])
660+ assert.Equal(t, "create-native-popup", record["decision"])
661+ assert.Equal(t, "new-popup", record["target_disposition"])
662+ assert.Equal(t, "fallback-failed", record["reason_code"])
663+}
664+
665+func TestPopupNativeSetupFailureDestroysCoordinatorOwnedWebViewOnce(t *testing.T) {
666+ popupWV := &popupNavigationWebViewStub{MockWebView: mocks.NewMockWebView(t)}
667+ popupWV.EXPECT().Destroy().Once()
668+ factory := mocks.NewMockWebViewFactory(t)
669+ factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(popupWV, nil).Once()
670+ pm := newPopupManager()
671+ pm.setConfig(factory, nil, nil)
672+ pm.setOnOpenNativePopup(func(context.Context, NativePopupInput) error { return errors.New("setup failed") })
673+
674+ got := pm.openNativePopup(context.Background(), popupCoordinatorHooks{}, "floating", 101, "", port.PopupRequest{
675+ TargetURI: "https://example.com/visual", NoJavaScriptAccess: true,
676+ }, dto.HostDecision{Kind: dto.HostDecisionCreateNativePopup})
677+ assert.Nil(t, got)
678+}
679+
680+func TestHandlePopupCreate_DoesNotReuseNamedPopupWhenNoJavaScriptAccess(t *testing.T) {
681+ ctx := context.Background()
682+ parentPaneID := entity.PaneID("parent-pane")
683+ parentWV := mocks.NewMockWebView(t)
684+ parentWV.EXPECT().ID().Return(port.WebViewID(101)).Twice()
685+
686+ firstWV := mocks.NewMockWebView(t)
687+ firstWV.EXPECT().ID().Return(port.WebViewID(501)).Maybe()
688+ firstWV.EXPECT().Generation().Return(uint64(1)).Maybe()
689+ firstWV.EXPECT().SetCallbacks(mock.Anything).Maybe()
690+ firstWV.EXPECT().IsLoading().Return(false).Maybe()
691+ firstWV.EXPECT().URI().Return("").Maybe()
692+ firstWV.EXPECT().LoadURI(mock.Anything, "https://example.com/first").Return(nil).Maybe()
693+
694+ secondWV := mocks.NewMockWebView(t)
695+ secondWV.EXPECT().ID().Return(port.WebViewID(502)).Maybe()
696+ secondWV.EXPECT().Generation().Return(uint64(1)).Maybe()
697+ secondWV.EXPECT().SetCallbacks(mock.Anything).Maybe()
698+ secondWV.EXPECT().IsLoading().Return(false).Maybe()
699+ secondWV.EXPECT().URI().Return("").Maybe()
700+ secondWV.EXPECT().LoadURI(mock.Anything, "https://example.com/second").Return(nil).Maybe()
701+
702+ factory := mocks.NewMockWebViewFactory(t)
703+ factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(firstWV, nil).Once()
704+ factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(secondWV, nil).Once()
705+
706+ insertCalls := 0
707+ c := &Coordinator{
708+ webViews: make(map[entity.PaneID]port.WebView),
709+ popups: newPopupManager(),
710+ }
711+ c.SetPopupConfig(factory, nil, nil)
712+ c.SetPopupWindowIDResolver(func(entity.PaneID) (string, bool) { return "window-1", true })
713+ c.SetOnInsertPopup(func(context.Context, InsertPopupInput) error {
714+ insertCalls++
715+ return nil
716+ })
717+
718+ first := c.handlePopupCreate(ctx, parentPaneID, parentWV, port.PopupRequest{
719+ TargetURI: "https://example.com/first",
720+ FrameName: "shared-pane",
721+ IsUserGesture: true,
722+ NoJavaScriptAccess: true,
723+ })
724+ second := c.handlePopupCreate(ctx, parentPaneID, parentWV, port.PopupRequest{
725+ TargetURI: "https://example.com/second",
726+ FrameName: "shared-pane",
727+ IsUserGesture: true,
728+ NoJavaScriptAccess: true,
729+ })
730+
731+ require.Same(t, firstWV, first)
732+ require.Same(t, secondWV, second)
733+ assert.Equal(t, 2, insertCalls)
734+}
735+
736+func TestHandlePopupCreate_ReusesNamedPopup(t *testing.T) {
737+ ctx := context.Background()
738+ parentPaneID := entity.PaneID("parent-pane")
739+ parentWV := mocks.NewMockWebView(t)
740+ parentWV.EXPECT().ID().Return(port.WebViewID(101)).Twice()
741+
742+ popupWV := mocks.NewMockWebView(t)
743+ popupWV.EXPECT().ID().Return(port.WebViewID(202)).Maybe()
744+ popupWV.EXPECT().Generation().Return(uint64(1)).Maybe()
745+ popupWV.EXPECT().SetCallbacks(mock.Anything).Maybe()
746+ popupWV.EXPECT().IsLoading().Return(false).Maybe()
747+ currentURI := ""
748+ popupWV.EXPECT().URI().RunAndReturn(func() string { return currentURI }).Maybe()
749+ popupWV.EXPECT().IsDestroyed().Return(false).Maybe()
750+ popupWV.EXPECT().LoadURI(mock.Anything, "https://example.com/first").RunAndReturn(func(context.Context, string) error {
751+ currentURI = "https://example.com/first"
752+ return nil
753+ }).Once()
754+ popupWV.EXPECT().LoadURI(mock.Anything, "https://example.com/second").RunAndReturn(func(context.Context, string) error {
755+ currentURI = "https://example.com/second"
756+ return nil
757+ }).Once()
758+
759+ factory := mocks.NewMockWebViewFactory(t)
760+ factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(popupWV, nil).Once()
761+
762+ insertCalls := 0
763+ c := &Coordinator{
764+ webViews: make(map[entity.PaneID]port.WebView),
765+ popups: newPopupManager(),
766+ }
767+ c.SetPopupConfig(factory, nil, nil)
768+ c.SetPopupWindowIDResolver(func(entity.PaneID) (string, bool) { return "window-1", true })
769+ c.SetOnInsertPopup(func(context.Context, InsertPopupInput) error {
770+ insertCalls++
771+ return nil
772+ })
773+
774+ first := c.handlePopupCreate(ctx, parentPaneID, parentWV, port.PopupRequest{
775+ Engine: dto.BrowserEngineWebKit,
776+ TargetURI: "https://example.com/first",
777+ FrameName: "shared-pane",
778+ IsUserGesture: true,
779+ })
780+ require.Same(t, popupWV, first)
781+
782+ second := c.handlePopupCreate(ctx, parentPaneID, parentWV, port.PopupRequest{
783+ Engine: dto.BrowserEngineWebKit,
784+ TargetURI: "https://example.com/second",
785+ FrameName: "shared-pane",
786+ IsUserGesture: true,
787+ })
788+ require.Nil(t, second, "WebKit must block native creation after navigating the named context")
789+
790+ assert.Equal(t, 1, insertCalls)
791+ assert.Equal(t, "https://example.com/second", currentURI)
792+}
793+
794+func TestHandlePopupCreate_FallsBackToNewPaneWhenNamedReuseBecomesUnavailable(t *testing.T) {
795+ ctx := context.Background()
796+ parentPaneID := entity.PaneID("parent-pane")
797+ stalePaneID := entity.PaneID("stale-pane")
798+ parentWV := mocks.NewMockWebView(t)
799+ parentWV.EXPECT().ID().Return(port.WebViewID(101)).Once()
800+
801+ staleWV := mocks.NewMockWebView(t)
802+ staleWV.EXPECT().IsDestroyed().Return(false).Once()
803+ staleWV.EXPECT().ID().Return(port.WebViewID(202)).Once()
804+ staleWV.EXPECT().IsDestroyed().Return(true).Once()
805+
806+ newWV := &popupNavigationWebViewStub{MockWebView: mocks.NewMockWebView(t)}
807+ newWV.EXPECT().ID().Return(port.WebViewID(303)).Maybe()
808+ newWV.EXPECT().Generation().Return(uint64(1)).Maybe()
809+ newWV.EXPECT().SetCallbacks(mock.Anything).Maybe()
810+
811+ factory := mocks.NewMockWebViewFactory(t)
812+ factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(newWV, nil).Once()
813+
814+ insertCalls := 0
815+ c := &Coordinator{
816+ webViews: map[entity.PaneID]port.WebView{
817+ stalePaneID: staleWV,
818+ },
819+ popups: newPopupManager(),
820+ }
821+ c.SetPopupConfig(factory, nil, nil)
822+ c.SetPopupWindowIDResolver(func(entity.PaneID) (string, bool) { return "window-1", true })
823+ c.popups.namedContexts.Register("window-1", "window-1", "shared-pane", stalePaneID, port.WebViewID(202))
824+ c.SetOnInsertPopup(func(context.Context, InsertPopupInput) error {
825+ insertCalls++
826+ return nil
827+ })
828+
829+ created := c.handlePopupCreate(ctx, parentPaneID, parentWV, port.PopupRequest{
830+ TargetURI: "https://example.com/replacement",
831+ FrameName: "shared-pane",
832+ IsUserGesture: true,
833+ })
834+
835+ require.Same(t, newWV, created)
836+ assert.Equal(t, 1, insertCalls)
837+ decision, hasDecision := newWV.BrowsingContextHostDecision()
838+ require.True(t, hasDecision)
839+ assert.Equal(t, dto.HostDecisionCreatePane, decision.Kind)
840+ assert.Empty(t, decision.ReuseContextName)
841+ assert.Equal(t, "shared-pane", decision.BrowsingContextName)
842+}
843+
844+// ---------------------------------------------------------------------------
845+// trackOAuthPopup / capturePopupOAuthState
846+// ---------------------------------------------------------------------------
M internal/ui/coordinator/content/popup_deferred.go +0 -50
@@ -2,7 +2,6 @@ package content
22
33 import (
44 "context"
5- "sync"
65 "time"
76
87 "github.com/bnema/dumber/internal/application/dto"
@@ -211,52 +210,3 @@ func (pm *popupManager) resolvePendingPopupFeatures(
211210 pm.cleanupDeferredPending(pending)
212211 }
213212 }
214-
215-func (pm *popupManager) openExistingPopupInNativePopup(
216- ctx context.Context,
217- hooks popupCoordinatorHooks,
218- parentPaneID entity.PaneID,
219- parentWebViewID port.WebViewID,
220- parentURIAtOpen string,
221- popupWV port.WebView,
222- req port.PopupRequest,
223- decision dto.HostDecision,
224-) bool {
225- log := logging.FromContext(ctx)
226- normalized := buildPopupBrowsingContextRequest(req)
227- normalized.SourceHost = pm.resolveSourceHost(parentPaneID)
228- if popupWV == nil || pm.onOpenNativePopup == nil {
229- logBrowsingContextFailure(*log, normalized, decision, dto.BrowsingContextFailureHostUnavailable, nil)
230- return false
231- }
232- cfg := pm.currentPopupConfig()
233- allowFallback := !normalized.AuthIntent && !decision.RequiresNativeOpener &&
234- (req.NoJavaScriptAccess || popupSupportsOpenerBridge(popupWV))
235- var abortOnce sync.Once
236- abortResult := false
237- onAbort := func(abortCtx context.Context, abortedWV port.WebView) bool {
238- abortOnce.Do(func() {
239- if !allowFallback {
240- logBrowsingContextFailure(*log, normalized, decision, dto.BrowsingContextFailureNativeArm, nil)
241- return
242- }
243- abortResult = pm.openExistingPopupInBrowserWindow(
244- abortCtx, hooks, parentPaneID, parentWebViewID, abortedWV, req, decision, true,
245- )
246- })
247- return abortResult
248- }
249- if err := pm.onOpenNativePopup(ctx, NativePopupInput{
250- ParentPaneID: parentPaneID, ParentWebViewID: parentWebViewID, ParentURIAtOpen: parentURIAtOpen,
251- PopupWebView: popupWV, TargetURI: req.TargetURI, Request: req,
252- ObserveOAuthAutoClose: cfg != nil && cfg.OAuthAutoClose && IsOAuthURL(req.TargetURI),
253- AllowBrowserWindowFallback: allowFallback, OnNativeHostAbort: onAbort,
254- }); err != nil {
255- logBrowsingContextFailure(*log, normalized, decision, dto.BrowsingContextFailureHostFailed, err)
256- return false
257- }
258- if lifecycle, ok := popupWV.(port.PopupLifecycleCapable); ok {
259- lifecycle.PrimePopupNavigation(req.TargetURI)
260- }
261- return true
262-}
M internal/ui/coordinator/content/popup_hosting.go +109 -60
@@ -35,6 +35,60 @@ func (pm *popupManager) openBrowserWindow(
3535 return nil
3636 }
3737 pm.setBrowsingContextDecision(popupWV, decision)
38+ if !pm.adoptPopupInBrowserWindow(
39+ ctx, hooks, parentPaneID, parentWebViewID, popupWV, req, decision, ready,
40+ dto.BrowsingContextFailureHostUnavailable, dto.BrowsingContextFailureHostFailed,
41+ "browser window host returned an empty window ID",
42+ ) {
43+ popupWV.Destroy()
44+ return nil
45+ }
46+ if lifecycle, ok := popupWV.(port.PopupLifecycleCapable); ok {
47+ lifecycle.PrimePopupNavigation(req.TargetURI)
48+ }
49+ return popupWV
50+}
51+
52+// openExistingPopupInBrowserWindow attempts to transfer an already-created
53+// WebView. On failure ownership remains with the caller.
54+func (pm *popupManager) openExistingPopupInBrowserWindow(
55+ ctx context.Context,
56+ hooks popupCoordinatorHooks,
57+ parentPaneID entity.PaneID,
58+ parentWebViewID port.WebViewID,
59+ popupWV port.WebView,
60+ req port.PopupRequest,
61+ decision dto.HostDecision,
62+ ready bool,
63+) bool {
64+ return pm.adoptPopupInBrowserWindow(
65+ ctx, hooks, parentPaneID, parentWebViewID, popupWV, req, decision, ready,
66+ dto.BrowsingContextFailureFallback, dto.BrowsingContextFailureFallback,
67+ "browser window fallback returned an empty window ID",
68+ )
69+}
70+
71+func (pm *popupManager) adoptPopupInBrowserWindow(
72+ ctx context.Context,
73+ hooks popupCoordinatorHooks,
74+ parentPaneID entity.PaneID,
75+ parentWebViewID port.WebViewID,
76+ popupWV port.WebView,
77+ req port.PopupRequest,
78+ decision dto.HostDecision,
79+ ready bool,
80+ unavailableCode dto.BrowsingContextFailureCode,
81+ failureCode dto.BrowsingContextFailureCode,
82+ emptyWindowError string,
83+) bool {
84+ log := logging.FromContext(ctx)
85+ normalized := buildPopupBrowsingContextRequest(req)
86+ normalized.SourceHost = pm.resolveSourceHost(parentPaneID)
87+ if popupWV == nil || pm.onOpenBrowserWindow == nil {
88+ logBrowsingContextFailure(*log, normalized, decision, unavailableCode, nil)
89+ return false
90+ }
91+
3892 paneID, popupPane := pm.createPopupPane(popupWV.ID(), parentPaneID, req.TargetURI)
3993 if hooks.setupWebViewCallbacks != nil {
4094 hooks.setupWebViewCallbacks(ctx, paneID, popupWV)
@@ -45,19 +99,15 @@ func (pm *popupManager) openBrowserWindow(
4599 })
46100 if err != nil || result.WindowID == "" {
47101 if err == nil {
48- err = fmt.Errorf("browser window host returned an empty window ID")
102+ err = fmt.Errorf("%s", emptyWindowError)
49103 }
50- logBrowsingContextFailure(*log, normalized, decision, dto.BrowsingContextFailureHostFailed, err)
51- popupWV.Destroy()
52- return nil
104+ logBrowsingContextFailure(*log, normalized, decision, failureCode, err)
105+ return false
53106 }
54107 if !req.NoJavaScriptAccess {
55108 pm.storeReusableNamedPopupWithHost(parentPaneID, req.FrameName, paneID, popupWV, result.WindowID)
56109 }
57- if lifecycle, ok := popupWV.(port.PopupLifecycleCapable); ok {
58- lifecycle.PrimePopupNavigation(req.TargetURI)
59- }
60- return popupWV
110+ return true
61111 }
62112
63113 func (pm *popupManager) navigatePopupSource(
@@ -117,82 +167,81 @@ func (pm *popupManager) openNativePopup(
117167 logBrowsingContextFailure(*log, normalized, decision, dto.BrowsingContextFailureHostFailed, err)
118168 return nil
119169 }
120- cfg := pm.currentPopupConfig()
121170 pm.setBrowsingContextDecision(popupWV, decision)
122- allowFallback := !normalized.AuthIntent && !decision.RequiresNativeOpener &&
123- (req.NoJavaScriptAccess || popupSupportsOpenerBridge(popupWV))
124- var abortOnce sync.Once
125- abortResult := false
126- onAbort := func(abortCtx context.Context, abortedWV port.WebView) bool {
127- abortOnce.Do(func() {
128- if !allowFallback {
129- logBrowsingContextFailure(*log, normalized, decision, dto.BrowsingContextFailureNativeArm, nil)
130- return
131- }
132- abortResult = pm.openExistingPopupInBrowserWindow(
133- abortCtx, hooks, parentPaneID, parentID, abortedWV, req, decision, true,
134- )
135- })
136- return abortResult
137- }
138- if err := pm.onOpenNativePopup(ctx, NativePopupInput{
139- ParentPaneID: parentPaneID,
140- ParentWebViewID: parentID,
141- ParentURIAtOpen: parentURIAtOpen,
142- PopupWebView: popupWV,
143- TargetURI: req.TargetURI,
144- Request: req,
145- ObserveOAuthAutoClose: cfg != nil && cfg.OAuthAutoClose && IsOAuthURL(req.TargetURI),
146- AllowBrowserWindowFallback: allowFallback,
147- OnNativeHostAbort: onAbort,
148- }); err != nil {
149- logBrowsingContextFailure(*log, normalized, decision, dto.BrowsingContextFailureNativeArm, err)
171+ if !pm.adoptPopupInNativePopup(
172+ ctx, hooks, parentPaneID, parentID, parentURIAtOpen, popupWV, req, decision,
173+ dto.BrowsingContextFailureNativeArm,
174+ ) {
150175 popupWV.Destroy()
151176 return nil
152177 }
153- if lifecycle, ok := popupWV.(port.PopupLifecycleCapable); ok {
154- lifecycle.PrimePopupNavigation(req.TargetURI)
155- }
156178 return popupWV
157179 }
158180
159-// openExistingPopupInBrowserWindow attempts to transfer an already-created
181+// openExistingPopupInNativePopup attempts to transfer an already-created
160182 // WebView. On failure ownership remains with the caller.
161-func (pm *popupManager) openExistingPopupInBrowserWindow(
183+func (pm *popupManager) openExistingPopupInNativePopup(
162184 ctx context.Context,
163185 hooks popupCoordinatorHooks,
164186 parentPaneID entity.PaneID,
165187 parentWebViewID port.WebViewID,
188+ parentURIAtOpen string,
166189 popupWV port.WebView,
167190 req port.PopupRequest,
168191 decision dto.HostDecision,
169- ready bool,
192+) bool {
193+ return pm.adoptPopupInNativePopup(
194+ ctx, hooks, parentPaneID, parentWebViewID, parentURIAtOpen, popupWV, req, decision,
195+ dto.BrowsingContextFailureHostFailed,
196+ )
197+}
198+
199+func (pm *popupManager) adoptPopupInNativePopup(
200+ ctx context.Context,
201+ hooks popupCoordinatorHooks,
202+ parentPaneID entity.PaneID,
203+ parentWebViewID port.WebViewID,
204+ parentURIAtOpen string,
205+ popupWV port.WebView,
206+ req port.PopupRequest,
207+ decision dto.HostDecision,
208+ failureCode dto.BrowsingContextFailureCode,
170209 ) bool {
171210 log := logging.FromContext(ctx)
172211 normalized := buildPopupBrowsingContextRequest(req)
173212 normalized.SourceHost = pm.resolveSourceHost(parentPaneID)
174- if popupWV == nil || pm.onOpenBrowserWindow == nil {
175- logBrowsingContextFailure(*log, normalized, decision, dto.BrowsingContextFailureFallback, nil)
213+ if popupWV == nil || pm.onOpenNativePopup == nil {
214+ logBrowsingContextFailure(*log, normalized, decision, dto.BrowsingContextFailureHostUnavailable, nil)
176215 return false
177216 }
178-
179- paneID, popupPane := pm.createPopupPane(popupWV.ID(), parentPaneID, req.TargetURI)
180- if hooks.setupWebViewCallbacks != nil {
181- hooks.setupWebViewCallbacks(ctx, paneID, popupWV)
217+ cfg := pm.currentPopupConfig()
218+ allowFallback := !normalized.AuthIntent && !decision.RequiresNativeOpener &&
219+ (req.NoJavaScriptAccess || popupSupportsOpenerBridge(popupWV))
220+ var abortOnce sync.Once
221+ abortResult := false
222+ onAbort := func(abortCtx context.Context, abortedWV port.WebView) bool {
223+ abortOnce.Do(func() {
224+ if !allowFallback {
225+ logBrowsingContextFailure(*log, normalized, decision, dto.BrowsingContextFailureNativeArm, nil)
226+ return
227+ }
228+ abortResult = pm.openExistingPopupInBrowserWindow(
229+ abortCtx, hooks, parentPaneID, parentWebViewID, abortedWV, req, decision, true,
230+ )
231+ })
232+ return abortResult
182233 }
183- result, err := pm.onOpenBrowserWindow(ctx, BrowserWindowInput{
184- ParentPaneID: parentPaneID, ParentWebViewID: parentWebViewID, PopupPane: popupPane,
185- PopupWebView: popupWV, TargetURI: req.TargetURI, Request: req, Ready: ready,
186- })
187- if err != nil || result.WindowID == "" {
188- if err == nil {
189- err = fmt.Errorf("browser window fallback returned an empty window ID")
190- }
191- logBrowsingContextFailure(*log, normalized, decision, dto.BrowsingContextFailureFallback, err)
234+ if err := pm.onOpenNativePopup(ctx, NativePopupInput{
235+ ParentPaneID: parentPaneID, ParentWebViewID: parentWebViewID, ParentURIAtOpen: parentURIAtOpen,
236+ PopupWebView: popupWV, TargetURI: req.TargetURI, Request: req,
237+ ObserveOAuthAutoClose: cfg != nil && cfg.OAuthAutoClose && IsOAuthURL(req.TargetURI),
238+ AllowBrowserWindowFallback: allowFallback, OnNativeHostAbort: onAbort,
239+ }); err != nil {
240+ logBrowsingContextFailure(*log, normalized, decision, failureCode, err)
192241 return false
193242 }
194- if !req.NoJavaScriptAccess {
195- pm.storeReusableNamedPopupWithHost(parentPaneID, req.FrameName, paneID, popupWV, result.WindowID)
243+ if lifecycle, ok := popupWV.(port.PopupLifecycleCapable); ok {
244+ lifecycle.PrimePopupNavigation(req.TargetURI)
196245 }
197246 return true
198247 }
A internal/ui/coordinator/content/popup_lifecycle_test.go +612 -0
@@ -0,0 +1,612 @@
1+package content
2+
3+import (
4+ "bytes"
5+ "context"
6+ "errors"
7+ "sync"
8+ "testing"
9+
10+ "github.com/rs/zerolog"
11+
12+ "github.com/stretchr/testify/assert"
13+ "github.com/stretchr/testify/mock"
14+ "github.com/stretchr/testify/require"
15+
16+ "github.com/bnema/dumber/internal/application/dto"
17+ "github.com/bnema/dumber/internal/application/port"
18+ "github.com/bnema/dumber/internal/application/port/mocks"
19+ "github.com/bnema/dumber/internal/domain/entity"
20+ "github.com/bnema/dumber/internal/logging"
21+)
22+
23+// ---------------------------------------------------------------------------
24+// DetectPopupType
25+// ---------------------------------------------------------------------------
26+
27+func TestTrackOAuthPopup_StoresState(t *testing.T) {
28+ t.Parallel()
29+
30+ c := &Coordinator{
31+ webViews: make(map[entity.PaneID]port.WebView),
32+ popups: newPopupManager(),
33+ }
34+
35+ popupID := port.WebViewID(1)
36+ parentPaneID := entity.PaneID("parent-pane")
37+
38+ c.trackOAuthPopup(popupID, parentPaneID, "https://parent.example.com/login")
39+
40+ c.popups.mu.RLock()
41+ state, ok := c.popups.popupOAuth[popupID]
42+ c.popups.mu.RUnlock()
43+
44+ require.True(t, ok, "oauth state should be tracked after trackOAuthPopup")
45+ require.NotNil(t, state)
46+ assert.Equal(t, parentPaneID, state.ParentPaneID)
47+ assert.Equal(t, "https://parent.example.com/login", state.ParentURIAtOpen)
48+ assert.False(t, state.Seen, "Seen should be false before capturing callback URI")
49+}
50+
51+func TestCapturePopupOAuthState_SuccessCallback(t *testing.T) {
52+ t.Parallel()
53+
54+ c := &Coordinator{
55+ webViews: make(map[entity.PaneID]port.WebView),
56+ popups: newPopupManager(),
57+ }
58+
59+ popupID := port.WebViewID(2)
60+ c.trackOAuthPopup(popupID, entity.PaneID("parent"), "https://parent.example.com/login")
61+ c.capturePopupOAuthState(popupID, "https://app.example.com/callback?code=abc123")
62+
63+ c.popups.mu.RLock()
64+ state := c.popups.popupOAuth[popupID]
65+ c.popups.mu.RUnlock()
66+
67+ require.NotNil(t, state)
68+ assert.True(t, state.Seen)
69+ assert.True(t, state.Success)
70+ assert.False(t, state.Error)
71+ assert.Equal(t, "https://app.example.com/callback?code=abc123", state.CallbackURI)
72+}
73+
74+func TestCapturePopupOAuthState_ErrorCallback(t *testing.T) {
75+ t.Parallel()
76+
77+ c := &Coordinator{
78+ webViews: make(map[entity.PaneID]port.WebView),
79+ popups: newPopupManager(),
80+ }
81+
82+ popupID := port.WebViewID(3)
83+ c.trackOAuthPopup(popupID, entity.PaneID("parent"), "https://parent.example.com/login")
84+ c.capturePopupOAuthState(popupID, "https://app.example.com/callback?error=access_denied")
85+
86+ c.popups.mu.RLock()
87+ state := c.popups.popupOAuth[popupID]
88+ c.popups.mu.RUnlock()
89+
90+ require.NotNil(t, state)
91+ assert.True(t, state.Seen)
92+ assert.False(t, state.Success)
93+ assert.True(t, state.Error)
94+}
95+
96+func TestCapturePopupOAuthState_UnknownPopupIsNoop(t *testing.T) {
97+ t.Parallel()
98+
99+ c := &Coordinator{
100+ webViews: make(map[entity.PaneID]port.WebView),
101+ popups: newPopupManager(),
102+ }
103+
104+ // Should not panic when no tracking entry exists.
105+ c.capturePopupOAuthState(port.WebViewID(999), "https://example.com/callback?code=x")
106+
107+ c.popups.mu.RLock()
108+ _, ok := c.popups.popupOAuth[port.WebViewID(999)]
109+ c.popups.mu.RUnlock()
110+
111+ assert.False(t, ok)
112+}
113+
114+// ---------------------------------------------------------------------------
115+// SetPopupConfig
116+// ---------------------------------------------------------------------------
117+
118+func TestSetPopupConfig_SetsFields(t *testing.T) {
119+ t.Parallel()
120+
121+ c := &Coordinator{}
122+
123+ factory := &stubFactory{}
124+ cfg := &entity.BrowsingContextConfig{Behavior: entity.PopupBehaviorSplit}
125+ genID := func() string { return "test-id" }
126+
127+ c.SetPopupConfig(factory, cfg, genID)
128+
129+ assert.Equal(t, factory, c.popups.factory)
130+ assert.True(t, c.popups.hasPopupConfig)
131+ assert.Equal(t, *cfg, c.popups.popupConfig)
132+ cfg.Behavior = entity.PopupBehaviorWindowed
133+ assert.Equal(t, entity.PopupBehaviorSplit, c.popups.popupConfig.Behavior)
134+ assert.NotNil(t, c.popups.generatePaneID)
135+ assert.Equal(t, "test-id", c.popups.generatePaneID())
136+}
137+
138+func TestSetPopupConfig_NilConfigAllowed(t *testing.T) {
139+ t.Parallel()
140+
141+ c := &Coordinator{}
142+ c.SetPopupConfig(nil, nil, nil)
143+
144+ assert.Nil(t, c.popups.factory)
145+ assert.False(t, c.popups.hasPopupConfig)
146+ assert.Equal(t, entity.BrowsingContextConfig{}, c.popups.popupConfig)
147+ assert.Nil(t, c.popups.generatePaneID)
148+}
149+
150+func TestUpdatePopupConfig_CopiesLatestValue(t *testing.T) {
151+ t.Parallel()
152+
153+ c := &Coordinator{}
154+ initial := &entity.BrowsingContextConfig{OpenInNewPane: true, OAuthAutoClose: false}
155+ c.SetPopupConfig(nil, initial, nil)
156+
157+ c.UpdatePopupConfig(entity.BrowsingContextConfig{OpenInNewPane: false, OAuthAutoClose: true})
158+
159+ assert.True(t, c.popups.hasPopupConfig)
160+ assert.False(t, c.popups.popupConfig.OpenInNewPane)
161+ assert.True(t, c.popups.popupConfig.OAuthAutoClose)
162+ assert.True(t, initial.OpenInNewPane)
163+ assert.False(t, initial.OAuthAutoClose)
164+}
165+
166+type popupOpenerWebViewStub struct {
167+ *popupNavigationWebViewStub
168+}
169+
170+func (*popupOpenerWebViewStub) EnablePopupOpenerBridge(port.WebView, bool) {}
171+func (*popupOpenerWebViewStub) AddOpenerMessageCallback(func()) {}
172+func (*popupOpenerWebViewStub) AddOpenerNavigationCallback(func(string)) {}
173+func (*popupOpenerWebViewStub) HasActivePopupOpenerBridge() bool { return true }
174+
175+type popupNavigationWebViewStub struct {
176+ *mocks.MockWebView
177+ primed []string
178+ browsingContextDecision dto.HostDecision
179+ hasBrowsingContextDecision bool
180+}
181+
182+func (s *popupNavigationWebViewStub) PrimePopupNavigation(uri string) {
183+ s.primed = append(s.primed, uri)
184+}
185+
186+func (s *popupNavigationWebViewStub) SetBrowsingContextHostDecision(decision dto.HostDecision) {
187+ s.browsingContextDecision = decision
188+ s.hasBrowsingContextDecision = true
189+}
190+
191+func (s *popupNavigationWebViewStub) BrowsingContextHostDecision() (dto.HostDecision, bool) {
192+ return s.browsingContextDecision, s.hasBrowsingContextDecision
193+}
194+
195+func (*popupNavigationWebViewStub) SetOnReadyToShow(func()) {}
196+func (*popupNavigationWebViewStub) SetOnClose(func()) {}
197+func (*popupNavigationWebViewStub) Show() {}
198+
199+// stubFactory satisfies port.WebViewFactory without importing the mocks package.
200+type stubFactory struct{}
201+
202+func (s *stubFactory) Create(_ context.Context) (port.WebView, error) {
203+ return nil, nil
204+}
205+
206+func (s *stubFactory) CreateRelated(_ context.Context, _ port.WebViewID) (port.WebView, error) {
207+ return nil, nil
208+}
209+
210+// ---------------------------------------------------------------------------
211+// Concurrent access to pendingPopups
212+// ---------------------------------------------------------------------------
213+
214+func TestPendingPopups_ConcurrentAccess(t *testing.T) {
215+ t.Parallel()
216+
217+ c := &Coordinator{
218+ popups: newPopupManager(),
219+ }
220+
221+ const workers = 20
222+ var wg sync.WaitGroup
223+ wg.Add(workers * 2)
224+
225+ // Writers
226+ for i := range workers {
227+ go func(id int) {
228+ defer wg.Done()
229+ popupID := port.WebViewID(id)
230+ c.popups.mu.Lock()
231+ c.popups.pendingPopups[popupID] = &PendingPopup{
232+ FrameName: "_blank",
233+ PopupType: PopupTypeTab,
234+ }
235+ c.popups.mu.Unlock()
236+ }(i)
237+ }
238+
239+ // Readers
240+ for i := range workers {
241+ go func(id int) {
242+ defer wg.Done()
243+ popupID := port.WebViewID(id)
244+ c.popups.mu.RLock()
245+ _ = c.popups.pendingPopups[popupID]
246+ c.popups.mu.RUnlock()
247+ }(i)
248+ }
249+
250+ wg.Wait()
251+
252+ c.popups.mu.RLock()
253+ count := len(c.popups.pendingPopups)
254+ c.popups.mu.RUnlock()
255+
256+ assert.Equal(t, workers, count, "each writer uses a unique key, so all inserts should be present")
257+}
258+
259+func TestPendingPopups_ConcurrentDeleteAndRead(t *testing.T) {
260+ t.Parallel()
261+
262+ c := &Coordinator{
263+ popups: newPopupManager(),
264+ }
265+
266+ // Pre-populate
267+ for i := range 10 {
268+ c.popups.pendingPopups[port.WebViewID(i)] = &PendingPopup{PopupType: PopupTypePopup}
269+ }
270+
271+ var wg sync.WaitGroup
272+ wg.Add(20)
273+
274+ // Deleters
275+ for i := range 10 {
276+ go func(id int) {
277+ defer wg.Done()
278+ c.popups.mu.Lock()
279+ delete(c.popups.pendingPopups, port.WebViewID(id))
280+ c.popups.mu.Unlock()
281+ }(i)
282+ }
283+
284+ // Readers
285+ for i := range 10 {
286+ go func(id int) {
287+ defer wg.Done()
288+ c.popups.mu.RLock()
289+ _ = c.popups.pendingPopups[port.WebViewID(id)]
290+ c.popups.mu.RUnlock()
291+ }(i)
292+ }
293+
294+ wg.Wait()
295+
296+ c.popups.mu.RLock()
297+ defer c.popups.mu.RUnlock()
298+ assert.Empty(t, c.popups.pendingPopups, "all preloaded popups should have been deleted")
299+}
300+
301+func TestPopupDeferredFeaturelessBlankNavigatesSourceAndCleansStagingExactlyOnce(t *testing.T) {
302+ ctx := context.Background()
303+ parent := mocks.NewMockWebView(t)
304+ parent.EXPECT().ID().Return(port.WebViewID(101)).Once()
305+ parent.EXPECT().LoadURI(mock.Anything, "https://example.com/open").Return(nil).Once()
306+ popup := &deferredPopupWebViewStub{MockWebView: mocks.NewMockWebView(t), features: dto.PopupFeatures{State: dto.PopupFeaturesNone}}
307+ popup.EXPECT().ID().Return(port.WebViewID(201)).Maybe()
308+ popup.EXPECT().Destroy().Once()
309+ factory := mocks.NewMockWebViewFactory(t)
310+ factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(popup, nil).Once()
311+ staging := &popupStagingHostStub{}
312+ c := &Coordinator{popups: newPopupManager()}
313+ c.SetPopupConfig(factory, &entity.BrowsingContextConfig{OpenInNewPane: false}, func() string { return "deferred" })
314+ c.SetPopupSourceHostResolver(func(entity.PaneID) dto.SourceHostKind { return dto.SourceHostFloating })
315+ c.SetOnStagePopup(func(context.Context, StagePopupInput) (PopupStagingHost, error) {
316+ staging.attached = true
317+ return staging, nil
318+ })
319+ c.SetOnOpenBrowserWindow(func(context.Context, BrowserWindowInput) (BrowserWindowResult, error) {
320+ t.Fatal("featureless _blank must not open a browser window")
321+ return BrowserWindowResult{}, nil
322+ })
323+ c.SetOnOpenNativePopup(func(context.Context, NativePopupInput) error {
324+ t.Fatal("featureless _blank must not use native host")
325+ return nil
326+ })
327+
328+ got := c.handlePopupCreate(ctx, "floating", parent, port.PopupRequest{
329+ Engine: dto.BrowserEngineWebKit, TargetURI: "https://example.com/open", FrameName: "_blank",
330+ TargetDisposition: dto.WindowDispositionNewPopup, PopupFeatures: dto.PopupFeatures{State: dto.PopupFeaturesUnknown},
331+ })
332+ require.Same(t, popup, got)
333+ assert.True(t, staging.attached)
334+ assert.False(t, staging.detached)
335+ popup.ready()
336+ popup.ready()
337+ assert.True(t, staging.detached)
338+ assert.Equal(t, 1, staging.destroyCalls)
339+}
340+
341+func TestPopupDeferredFeaturedDetachesBeforeNativeHost(t *testing.T) {
342+ ctx := context.Background()
343+ parent := mocks.NewMockWebView(t)
344+ parent.EXPECT().ID().Return(port.WebViewID(101)).Once()
345+ popup := &deferredPopupWebViewStub{MockWebView: mocks.NewMockWebView(t), features: dto.PopupFeatures{State: dto.PopupFeaturesSpecified, Width: 640, WidthSet: true}}
346+ popup.EXPECT().ID().Return(port.WebViewID(202)).Maybe()
347+ factory := mocks.NewMockWebViewFactory(t)
348+ factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(popup, nil).Once()
349+ staging := &popupStagingHostStub{}
350+ c := &Coordinator{popups: newPopupManager()}
351+ c.SetPopupConfig(factory, &entity.BrowsingContextConfig{OpenInNewPane: false}, func() string { return "deferred" })
352+ c.SetPopupSourceHostResolver(func(entity.PaneID) dto.SourceHostKind { return dto.SourceHostFloating })
353+ c.SetOnStagePopup(func(context.Context, StagePopupInput) (PopupStagingHost, error) {
354+ staging.attached = true
355+ return staging, nil
356+ })
357+ c.SetOnOpenNativePopup(func(_ context.Context, input NativePopupInput) error {
358+ assert.True(t, staging.detached)
359+ assert.Equal(t, dto.PopupFeaturesSpecified, input.Request.PopupFeatures.State)
360+ assert.Same(t, popup, input.PopupWebView)
361+ return nil
362+ })
363+
364+ require.Same(t, popup, c.handlePopupCreate(ctx, "floating", parent, port.PopupRequest{
365+ Engine: dto.BrowserEngineWebKit, TargetURI: "https://example.com/open", FrameName: "_blank",
366+ TargetDisposition: dto.WindowDispositionNewPopup, PopupFeatures: dto.PopupFeatures{State: dto.PopupFeaturesUnknown},
367+ }))
368+ popup.ready()
369+ decision, ok := popup.BrowsingContextHostDecision()
370+ require.True(t, ok)
371+ assert.Equal(t, dto.HostDecisionCreateNativePopup, decision.Kind)
372+}
373+
374+func TestPopupDeferredNamedReuseDestroysNewlyStagedWebViewExactlyOnce(t *testing.T) {
375+ ctx := context.Background()
376+ parentPaneID := entity.PaneID("floating")
377+ existingPaneID := entity.PaneID("existing")
378+ parent := mocks.NewMockWebView(t)
379+ parent.EXPECT().ID().Return(port.WebViewID(101)).Once()
380+ staged := &deferredPopupWebViewStub{MockWebView: mocks.NewMockWebView(t), features: dto.PopupFeatures{State: dto.PopupFeaturesNone}}
381+ staged.EXPECT().ID().Return(port.WebViewID(205)).Maybe()
382+ staged.EXPECT().Destroy().Once()
383+ existing := mocks.NewMockWebView(t)
384+ existing.EXPECT().ID().Return(port.WebViewID(301)).Times(3)
385+ existing.EXPECT().IsDestroyed().Return(false).Twice()
386+ existing.EXPECT().LoadURI(mock.Anything, "https://example.com/reused").Return(nil).Once()
387+ factory := mocks.NewMockWebViewFactory(t)
388+ factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(staged, nil).Once()
389+ staging := &popupStagingHostStub{}
390+ c := &Coordinator{webViews: make(map[entity.PaneID]port.WebView), popups: newPopupManager()}
391+ c.SetPopupConfig(factory, &entity.BrowsingContextConfig{OpenInNewPane: false}, nil)
392+ c.SetPopupSourceHostResolver(func(entity.PaneID) dto.SourceHostKind { return dto.SourceHostFloating })
393+ c.SetPopupWindowIDResolver(func(paneID entity.PaneID) (string, bool) {
394+ windowByPane := map[entity.PaneID]string{parentPaneID: "owner", existingPaneID: "host"}
395+ windowID, ok := windowByPane[paneID]
396+ return windowID, ok
397+ })
398+ c.SetOnStagePopup(func(context.Context, StagePopupInput) (PopupStagingHost, error) { return staging, nil })
399+
400+ require.Same(t, staged, c.handlePopupCreate(ctx, parentPaneID, parent, port.PopupRequest{
401+ Engine: dto.BrowserEngineWebKit, TargetURI: "https://example.com/reused", FrameName: "shared",
402+ PopupFeatures: dto.PopupFeatures{State: dto.PopupFeaturesUnknown},
403+ }))
404+ c.RegisterPopupWebView(existingPaneID, existing)
405+ c.popups.namedContexts.Register("owner", "host", "shared", existingPaneID, port.WebViewID(301))
406+ staged.ready()
407+ staged.ready()
408+
409+ assert.True(t, staging.detached)
410+ assert.Equal(t, 1, staging.destroyCalls)
411+}
412+
413+func TestPopupDeferredNativeAbortWithoutFallbackLogsStructuredFailure(t *testing.T) {
414+ var output bytes.Buffer
415+ logger := zerolog.New(&output).Level(zerolog.WarnLevel)
416+ ctx := logging.WithContext(context.Background(), logger)
417+ parent := mocks.NewMockWebView(t)
418+ parent.EXPECT().ID().Return(port.WebViewID(101)).Once()
419+ popup := &deferredPopupWebViewStub{MockWebView: mocks.NewMockWebView(t), features: dto.PopupFeatures{State: dto.PopupFeaturesSpecified, Width: 640, WidthSet: true}}
420+ popup.EXPECT().ID().Return(port.WebViewID(206)).Maybe()
421+ popup.EXPECT().Destroy().Once()
422+ factory := mocks.NewMockWebViewFactory(t)
423+ factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(popup, nil).Once()
424+ c := &Coordinator{popups: newPopupManager()}
425+ c.SetPopupConfig(factory, &entity.BrowsingContextConfig{OpenInNewPane: false}, nil)
426+ c.SetPopupSourceHostResolver(func(entity.PaneID) dto.SourceHostKind { return dto.SourceHostFloating })
427+ c.SetOnStagePopup(func(context.Context, StagePopupInput) (PopupStagingHost, error) { return &popupStagingHostStub{}, nil })
428+ c.SetOnOpenNativePopup(func(abortCtx context.Context, input NativePopupInput) error {
429+ assert.False(t, input.AllowBrowserWindowFallback)
430+ if !input.OnNativeHostAbort(abortCtx, input.PopupWebView) {
431+ input.PopupWebView.Destroy()
432+ }
433+ return nil
434+ })
435+
436+ require.Same(t, popup, c.handlePopupCreate(ctx, "floating", parent, port.PopupRequest{
437+ Engine: dto.BrowserEngineWebKit, TargetURI: "https://example.com/visual", TargetDisposition: dto.WindowDispositionNewPopup,
438+ PopupFeatures: dto.PopupFeatures{State: dto.PopupFeaturesUnknown},
439+ }))
440+ popup.ready()
441+
442+ record := decodeBrowsingContextLog(t, output.Bytes())
443+ assert.Equal(t, "webkit", record["engine"])
444+ assert.Equal(t, "floating", record["source_host"])
445+ assert.Equal(t, "create-native-popup", record["decision"])
446+ assert.Equal(t, "new-popup", record["target_disposition"])
447+ assert.Equal(t, "native-arm-failed", record["reason_code"])
448+}
449+
450+func TestPopupDeferredBrowserHostFailureCleansUpExactlyOnce(t *testing.T) {
451+ parent := mocks.NewMockWebView(t)
452+ parent.EXPECT().ID().Return(port.WebViewID(101)).Once()
453+ popup := &deferredPopupWebViewStub{MockWebView: mocks.NewMockWebView(t), features: dto.PopupFeatures{State: dto.PopupFeaturesNone}}
454+ popup.EXPECT().ID().Return(port.WebViewID(204)).Maybe()
455+ popup.EXPECT().Generation().Return(uint64(1)).Maybe()
456+ popup.EXPECT().SetCallbacks(mock.Anything).Once()
457+ popup.EXPECT().Destroy().Once()
458+ factory := mocks.NewMockWebViewFactory(t)
459+ factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(popup, nil).Once()
460+ staging := &popupStagingHostStub{}
461+ c := &Coordinator{popups: newPopupManager()}
462+ c.SetPopupConfig(factory, &entity.BrowsingContextConfig{OpenInNewPane: false}, nil)
463+ c.SetPopupSourceHostResolver(func(entity.PaneID) dto.SourceHostKind { return dto.SourceHostFloating })
464+ c.SetOnStagePopup(func(context.Context, StagePopupInput) (PopupStagingHost, error) { return staging, nil })
465+ c.SetOnOpenBrowserWindow(func(context.Context, BrowserWindowInput) (BrowserWindowResult, error) {
466+ return BrowserWindowResult{}, errors.New("host failed")
467+ })
468+
469+ require.Same(t, popup, c.handlePopupCreate(context.Background(), "floating", parent, port.PopupRequest{
470+ Engine: dto.BrowserEngineWebKit, TargetURI: "https://example.com/open", FrameName: "shared",
471+ TargetDisposition: dto.WindowDispositionNewPopup, PopupFeatures: dto.PopupFeatures{State: dto.PopupFeaturesUnknown},
472+ }))
473+ popup.ready()
474+ popup.ready()
475+ assert.True(t, staging.detached)
476+ assert.Equal(t, 1, staging.destroyCalls)
477+}
478+
479+func TestPopupDeferredStagingFailureDestroysCallerOwnedWebViewOnce(t *testing.T) {
480+ parent := mocks.NewMockWebView(t)
481+ parent.EXPECT().ID().Return(port.WebViewID(101)).Once()
482+ popup := &deferredPopupWebViewStub{MockWebView: mocks.NewMockWebView(t)}
483+ popup.EXPECT().Destroy().Once()
484+ factory := mocks.NewMockWebViewFactory(t)
485+ factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(popup, nil).Once()
486+ c := &Coordinator{popups: newPopupManager()}
487+ c.SetPopupConfig(factory, &entity.BrowsingContextConfig{OpenInNewPane: false}, nil)
488+ c.SetPopupSourceHostResolver(func(entity.PaneID) dto.SourceHostKind { return dto.SourceHostFloating })
489+ c.SetOnStagePopup(func(context.Context, StagePopupInput) (PopupStagingHost, error) {
490+ return nil, errors.New("stage failed")
491+ })
492+
493+ assert.Nil(t, c.handlePopupCreate(context.Background(), "floating", parent, port.PopupRequest{Engine: dto.BrowserEngineWebKit, TargetURI: "https://example.com/open", PopupFeatures: dto.PopupFeatures{State: dto.PopupFeaturesUnknown}}))
494+}
495+
496+func TestPopupDeferredExplicitTeardownCleansUpWithoutLifecycleSignals(t *testing.T) {
497+ for _, tc := range []struct {
498+ name string
499+ prepare func(*Coordinator)
500+ trigger func(*Coordinator)
501+ }{
502+ {name: "parent release", trigger: func(c *Coordinator) { c.ReleaseWebView(context.Background(), "floating") }},
503+ {name: "owner window teardown", prepare: func(c *Coordinator) {
504+ c.SetPopupWindowIDResolver(func(entity.PaneID) (string, bool) { return "owner", true })
505+ }, trigger: func(c *Coordinator) { c.ClearPopupNamedContextsForWindow("owner") }},
506+ {name: "coordinator shutdown", trigger: func(c *Coordinator) { c.ShutdownPopups() }},
507+ } {
508+ t.Run(tc.name, func(t *testing.T) {
509+ parent := mocks.NewMockWebView(t)
510+ parent.EXPECT().ID().Return(port.WebViewID(101)).Once()
511+ popup := &deferredPopupWebViewStub{MockWebView: mocks.NewMockWebView(t), features: dto.PopupFeatures{State: dto.PopupFeaturesNone}}
512+ popup.EXPECT().ID().Return(port.WebViewID(207)).Maybe()
513+ popup.EXPECT().Destroy().Once()
514+ factory := mocks.NewMockWebViewFactory(t)
515+ factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(popup, nil).Once()
516+ staging := &popupStagingHostStub{}
517+ c := &Coordinator{popups: newPopupManager()}
518+ c.SetPopupConfig(factory, &entity.BrowsingContextConfig{OpenInNewPane: false}, nil)
519+ c.SetPopupSourceHostResolver(func(entity.PaneID) dto.SourceHostKind { return dto.SourceHostFloating })
520+ c.SetOnStagePopup(func(context.Context, StagePopupInput) (PopupStagingHost, error) { return staging, nil })
521+ if tc.prepare != nil {
522+ tc.prepare(c)
523+ }
524+
525+ require.Same(t, popup, c.handlePopupCreate(context.Background(), "floating", parent, port.PopupRequest{
526+ Engine: dto.BrowserEngineWebKit, TargetURI: "https://example.com/open",
527+ PopupFeatures: dto.PopupFeatures{State: dto.PopupFeaturesUnknown},
528+ }))
529+ require.Len(t, c.popups.deferredPopups, 1)
530+ tc.trigger(c)
531+ tc.trigger(c)
532+
533+ assert.Empty(t, c.popups.deferredPopups)
534+ assert.Empty(t, c.popups.pendingPopups)
535+ assert.Equal(t, 1, staging.destroyCalls)
536+ assert.Nil(t, popup.onReady)
537+ assert.Nil(t, popup.onClose)
538+ })
539+ }
540+}
541+
542+func TestPopupDeferredUnknownAndCloseCleanupExactlyOnce(t *testing.T) {
543+ for _, tc := range []struct {
544+ name string
545+ close bool
546+ }{{name: "unknown at ready"}, {name: "close before ready", close: true}} {
547+ t.Run(tc.name, func(t *testing.T) {
548+ parent := mocks.NewMockWebView(t)
549+ parent.EXPECT().ID().Return(port.WebViewID(101)).Once()
550+ popup := &deferredPopupWebViewStub{MockWebView: mocks.NewMockWebView(t), features: dto.PopupFeatures{State: dto.PopupFeaturesUnknown}}
551+ popup.EXPECT().ID().Return(port.WebViewID(203)).Maybe()
552+ popup.EXPECT().Destroy().Once()
553+ factory := mocks.NewMockWebViewFactory(t)
554+ factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(popup, nil).Once()
555+ staging := &popupStagingHostStub{}
556+ c := &Coordinator{popups: newPopupManager()}
557+ c.SetPopupConfig(factory, &entity.BrowsingContextConfig{OpenInNewPane: false}, nil)
558+ c.SetPopupSourceHostResolver(func(entity.PaneID) dto.SourceHostKind { return dto.SourceHostFloating })
559+ c.SetOnStagePopup(func(context.Context, StagePopupInput) (PopupStagingHost, error) { return staging, nil })
560+ require.Same(t, popup, c.handlePopupCreate(context.Background(), "floating", parent, port.PopupRequest{Engine: dto.BrowserEngineWebKit, TargetURI: "https://example.com/open", PopupFeatures: dto.PopupFeatures{State: dto.PopupFeaturesUnknown}}))
561+ if tc.close {
562+ popup.close()
563+ popup.close()
564+ } else {
565+ popup.ready()
566+ popup.ready()
567+ }
568+ assert.Equal(t, 1, staging.destroyCalls)
569+ })
570+ }
571+}
572+
573+// deferredPopupWebViewStub exposes the optional late-feature and lifecycle ports.
574+type deferredPopupWebViewStub struct {
575+ *mocks.MockWebView
576+ features dto.PopupFeatures
577+ onReady func()
578+ onClose func()
579+ browsingContextDecision dto.HostDecision
580+ hasBrowsingContextDecision bool
581+}
582+
583+func (s *deferredPopupWebViewStub) ResolvePopupFeatures() dto.PopupFeatures { return s.features }
584+func (s *deferredPopupWebViewStub) PrimePopupNavigation(string) {}
585+func (s *deferredPopupWebViewStub) SetOnReadyToShow(fn func()) { s.onReady = fn }
586+func (s *deferredPopupWebViewStub) SetOnClose(fn func()) { s.onClose = fn }
587+func (s *deferredPopupWebViewStub) Show() {}
588+func (s *deferredPopupWebViewStub) SetBrowsingContextHostDecision(decision dto.HostDecision) {
589+ s.browsingContextDecision = decision
590+ s.hasBrowsingContextDecision = true
591+}
592+func (s *deferredPopupWebViewStub) BrowsingContextHostDecision() (dto.HostDecision, bool) {
593+ return s.browsingContextDecision, s.hasBrowsingContextDecision
594+}
595+func (s *deferredPopupWebViewStub) ready() {
596+ if s.onReady != nil {
597+ s.onReady()
598+ }
599+}
600+func (s *deferredPopupWebViewStub) close() {
601+ if s.onClose != nil {
602+ s.onClose()
603+ }
604+}
605+
606+type popupStagingHostStub struct {
607+ attached, detached bool
608+ destroyCalls int
609+}
610+
611+func (s *popupStagingHostStub) Detach() error { s.detached = true; return nil }
612+func (s *popupStagingHostStub) Destroy() { s.destroyCalls++ }
M internal/ui/coordinator/content/popup_manager.go +8 -4
@@ -9,6 +9,7 @@ import (
99
1010 "github.com/bnema/dumber/internal/application/dto"
1111 "github.com/bnema/dumber/internal/application/port"
12+ "github.com/bnema/dumber/internal/application/usecase"
1213 "github.com/bnema/dumber/internal/domain/entity"
1314 "github.com/bnema/dumber/internal/logging"
1415 )
@@ -28,7 +29,7 @@ type popupManager struct {
2829 generatePaneID func() string
2930 windowIDForPane func(entity.PaneID) (string, bool)
3031 sourceHostForPane func(entity.PaneID) dto.SourceHostKind
31- policy browsingContextPolicy
32+ policy usecase.BrowsingContextPolicy
3233 namedContexts *namedBrowsingContextRegistry
3334 pendingPopups map[port.WebViewID]*PendingPopup
3435 deferredPopups map[port.WebViewID]*deferredPendingPopup
@@ -270,7 +271,7 @@ func (pm *popupManager) lookupReusableNamedPopup(
270271 return nil, false
271272 }
272273
273- name := reusableBrowsingContextName(frameName)
274+ name := usecase.ReusableBrowsingContextName(frameName)
274275 if name == "" || pm.windowIDForPane == nil || hooks.getWebView == nil {
275276 return nil, false
276277 }
@@ -308,7 +309,7 @@ func (pm *popupManager) storeReusableNamedPopupWithHost(
308309 if pm == nil || pm.namedContexts == nil || wv == nil || pm.windowIDForPane == nil {
309310 return
310311 }
311- name := reusableBrowsingContextName(frameName)
312+ name := usecase.ReusableBrowsingContextName(frameName)
312313 ownerWindowID, ok := pm.windowIDForPane(parentPaneID)
313314 if name == "" || !ok || ownerWindowID == "" || hostWindowID == "" {
314315 return
@@ -585,7 +586,10 @@ func (pm *popupManager) handlePopupCreate(
585586 reused, ok := pm.reuseNamedPopup(ctx, hooks, parentPaneID, req.FrameName, req.TargetURI)
586587 if ok {
587588 pm.setBrowsingContextDecision(reused, decision)
588- return reused
589+ // The existing browsing context already owns its engine view. Returning
590+ // it would ask CEF/WebKit to create a second native popup around an
591+ // already-hosted view, so block engine creation after navigation.
592+ return nil
589593 }
590594 log.Warn().Str("frame_name", req.FrameName).Msg("named browsing context reuse requested but target was unavailable")
591595 decision.Kind = dto.HostDecisionCreatePane
M internal/ui/coordinator/content/popup_test.go +0 -1419
@@ -1,24 +1,11 @@
11 package content
22
33 import (
4- "bytes"
5- "context"
6- "errors"
7- "sync"
84 "testing"
95
10- "github.com/rs/zerolog"
11-
126 "github.com/stretchr/testify/assert"
13- "github.com/stretchr/testify/mock"
14- "github.com/stretchr/testify/require"
157
16- "github.com/bnema/dumber/internal/application/dto"
17- "github.com/bnema/dumber/internal/application/port"
18- "github.com/bnema/dumber/internal/application/port/mocks"
198 "github.com/bnema/dumber/internal/domain/entity"
20- domainerrors "github.com/bnema/dumber/internal/domain/errors"
21- "github.com/bnema/dumber/internal/logging"
229 )
2310
2411 // ---------------------------------------------------------------------------
@@ -131,1409 +118,3 @@ func TestGetBehavior_TabPopup_BlocksWhenOpenInNewPaneFalse(t *testing.T) {
131118 }
132119 assert.Equal(t, entity.PopupBehaviorSplit, GetBehavior(PopupTypeTab, cfg))
133120 }
134-
135-func newPopupCreateCoordinatorForTest(t *testing.T, popupID port.WebViewID) (context.Context, entity.PaneID, *mocks.MockWebView, *mocks.MockWebView, *Coordinator) {
136- t.Helper()
137-
138- ctx := context.Background()
139- parentPaneID := entity.PaneID("parent-pane")
140- parentWV := mocks.NewMockWebView(t)
141- parentWV.EXPECT().ID().Return(port.WebViewID(101)).Once()
142-
143- popupWV := mocks.NewMockWebView(t)
144- popupWV.EXPECT().ID().Return(popupID).Maybe()
145-
146- factory := mocks.NewMockWebViewFactory(t)
147- factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(popupWV, nil).Once()
148-
149- c := &Coordinator{
150- webViews: make(map[entity.PaneID]port.WebView),
151- popups: newPopupManager(),
152- }
153- c.SetPopupConfig(factory, nil, func() string { return "popup-pane" })
154-
155- return ctx, parentPaneID, parentWV, popupWV, c
156-}
157-
158-func TestPopupFloatingWebKitLinkBlankNavigatesSourceAndDestroysCandidateExactlyOnce(t *testing.T) {
159- ctx := context.Background()
160- parentPaneID := entity.PaneID("floating")
161- parentWV := mocks.NewMockWebView(t)
162- parentWV.EXPECT().ID().Return(port.WebViewID(101)).Once()
163- parentWV.EXPECT().LoadURI(mock.Anything, "https://example.com/new").Return(nil).Once()
164- popupWV := &popupNavigationWebViewStub{MockWebView: mocks.NewMockWebView(t)}
165- popupWV.EXPECT().Destroy().Once()
166-
167- factory := mocks.NewMockWebViewFactory(t)
168- factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(popupWV, nil).Once()
169- c := &Coordinator{popups: newPopupManager()}
170- c.SetPopupConfig(factory, &entity.BrowsingContextConfig{OpenInNewPane: false}, func() string { return "detached-pane" })
171- c.SetPopupSourceHostResolver(func(entity.PaneID) dto.SourceHostKind { return dto.SourceHostFloating })
172- c.SetOnInsertPopup(func(context.Context, InsertPopupInput) error {
173- t.Fatal("floating request must not use workspace insertion")
174- return nil
175- })
176- c.SetOnOpenBrowserWindow(func(context.Context, BrowserWindowInput) (BrowserWindowResult, error) {
177- t.Fatal("featureless _blank must not open a browser window")
178- return BrowserWindowResult{}, nil
179- })
180-
181- got := c.handlePopupCreate(ctx, parentPaneID, parentWV, port.PopupRequest{
182- Engine: dto.BrowserEngineWebKit,
183- TargetURI: "https://example.com/new",
184- FrameName: "_blank",
185- TargetDisposition: dto.WindowDispositionNewTab,
186- PopupFeatures: dto.PopupFeatures{State: dto.PopupFeaturesNone},
187- })
188-
189- assert.Nil(t, got, "featureless link popup must be discarded after source navigation")
190- decision, ok := popupWV.BrowsingContextHostDecision()
191- require.True(t, ok)
192- assert.Equal(t, dto.HostDecisionNavigateSource, decision.Kind)
193-}
194-
195-func TestPopupFloatingMiddleClickNavigatesSourceWhenWorkspacePopupsDisabled(t *testing.T) {
196- ctx := context.Background()
197- parentPaneID := entity.PaneID("floating")
198- parentWV := mocks.NewMockWebView(t)
199- parentWV.EXPECT().ID().Return(port.WebViewID(101)).Once()
200- parentWV.EXPECT().LoadURI(mock.Anything, "https://example.com/middle").Return(nil).Once()
201- factory := mocks.NewMockWebViewFactory(t)
202- c := &Coordinator{webViews: map[entity.PaneID]port.WebView{parentPaneID: parentWV}, popups: newPopupManager()}
203- c.SetPopupConfig(factory, &entity.BrowsingContextConfig{OpenInNewPane: false}, func() string { return "detached-pane" })
204- c.SetPopupSourceHostResolver(func(entity.PaneID) dto.SourceHostKind { return dto.SourceHostFloating })
205- c.SetOnInsertPopup(func(context.Context, InsertPopupInput) error {
206- t.Fatal("floating middle-click must not use workspace insertion")
207- return nil
208- })
209- c.SetOnOpenBrowserWindow(func(context.Context, BrowserWindowInput) (BrowserWindowResult, error) {
210- t.Fatal("floating middle-click must not open a browser window")
211- return BrowserWindowResult{}, nil
212- })
213-
214- assert.True(t, c.handleLinkMiddleClick(ctx, parentPaneID, "https://example.com/middle"))
215-}
216-
217-func TestPopupWorkspaceBlankRemainsBlockedWhenWorkspacePopupsDisabled(t *testing.T) {
218- parentWV := mocks.NewMockWebView(t)
219- factory := mocks.NewMockWebViewFactory(t)
220- c := &Coordinator{popups: newPopupManager()}
221- c.SetPopupConfig(factory, &entity.BrowsingContextConfig{OpenInNewPane: false}, nil)
222- c.SetPopupSourceHostResolver(func(entity.PaneID) dto.SourceHostKind { return dto.SourceHostWorkspace })
223-
224- got := c.handlePopupCreate(context.Background(), "workspace", parentWV, port.PopupRequest{TargetURI: "https://example.com/new"})
225-
226- assert.Nil(t, got)
227-}
228-
229-func TestPopupWorkspaceMiddleClickRemainsBlockedWhenWorkspacePopupsDisabled(t *testing.T) {
230- c := &Coordinator{popups: newPopupManager()}
231- c.SetPopupConfig(mocks.NewMockWebViewFactory(t), &entity.BrowsingContextConfig{OpenInNewPane: false}, nil)
232- c.SetPopupSourceHostResolver(func(entity.PaneID) dto.SourceHostKind { return dto.SourceHostWorkspace })
233-
234- assert.False(t, c.handleLinkMiddleClick(context.Background(), "workspace", "https://example.com/middle"))
235-}
236-
237-func TestPopupFloatingNamedContextReusesDetachedWindow(t *testing.T) {
238- ctx := context.Background()
239- parentPaneID := entity.PaneID("floating")
240- parentWV := mocks.NewMockWebView(t)
241- parentWV.EXPECT().ID().Return(port.WebViewID(101)).Twice()
242- popupWV := mocks.NewMockWebView(t)
243- popupWV.EXPECT().ID().Return(port.WebViewID(201)).Maybe()
244- popupWV.EXPECT().Generation().Return(uint64(1)).Maybe()
245- popupWV.EXPECT().SetCallbacks(mock.Anything).Once()
246- popupWV.EXPECT().IsDestroyed().Return(false).Twice()
247- popupWV.EXPECT().LoadURI(mock.Anything, "https://example.com/second").Return(nil).Once()
248-
249- factory := mocks.NewMockWebViewFactory(t)
250- factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(popupWV, nil).Once()
251- c := &Coordinator{webViews: make(map[entity.PaneID]port.WebView), popups: newPopupManager()}
252- c.SetPopupConfig(factory, &entity.BrowsingContextConfig{OpenInNewPane: false}, func() string { return "detached-pane" })
253- c.SetPopupSourceHostResolver(func(entity.PaneID) dto.SourceHostKind { return dto.SourceHostFloating })
254- windowByPane := map[entity.PaneID]string{parentPaneID: "owner-window"}
255- c.SetPopupWindowIDResolver(func(paneID entity.PaneID) (string, bool) {
256- windowID, ok := windowByPane[paneID]
257- return windowID, ok
258- })
259- hostCalls := 0
260- c.SetOnOpenBrowserWindow(func(_ context.Context, input BrowserWindowInput) (BrowserWindowResult, error) {
261- hostCalls++
262- windowByPane[input.PopupPane.ID] = "detached-window"
263- c.RegisterPopupWebView(input.PopupPane.ID, input.PopupWebView)
264- return BrowserWindowResult{WindowID: "detached-window"}, nil
265- })
266-
267- first := c.handlePopupCreate(ctx, parentPaneID, parentWV, port.PopupRequest{
268- Engine: dto.BrowserEngineCEF, TargetURI: "https://example.com/first", FrameName: "shared",
269- TargetDisposition: dto.WindowDispositionNewPopup, PopupFeatures: dto.PopupFeatures{State: dto.PopupFeaturesNone},
270- })
271- second := c.handlePopupCreate(ctx, parentPaneID, parentWV, port.PopupRequest{
272- Engine: dto.BrowserEngineCEF, TargetURI: "https://example.com/second", FrameName: "shared",
273- TargetDisposition: dto.WindowDispositionNewPopup, PopupFeatures: dto.PopupFeatures{State: dto.PopupFeaturesNone},
274- })
275-
276- assert.Same(t, popupWV, first)
277- assert.Same(t, popupWV, second)
278- assert.Equal(t, 1, hostCalls)
279-}
280-
281-func TestPopupFloatingNoopenerDoesNotReuseDetachedNamedContext(t *testing.T) {
282- ctx := context.Background()
283- parentPaneID := entity.PaneID("floating")
284- parentWV := mocks.NewMockWebView(t)
285- parentWV.EXPECT().ID().Return(port.WebViewID(101)).Twice()
286- firstWV := mocks.NewMockWebView(t)
287- firstWV.EXPECT().ID().Return(port.WebViewID(201)).Maybe()
288- firstWV.EXPECT().Generation().Return(uint64(1)).Maybe()
289- firstWV.EXPECT().SetCallbacks(mock.Anything).Once()
290- secondWV := mocks.NewMockWebView(t)
291- secondWV.EXPECT().ID().Return(port.WebViewID(202)).Maybe()
292- secondWV.EXPECT().Generation().Return(uint64(1)).Maybe()
293- secondWV.EXPECT().SetCallbacks(mock.Anything).Once()
294- factory := mocks.NewMockWebViewFactory(t)
295- factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(firstWV, nil).Once()
296- factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(secondWV, nil).Once()
297- c := &Coordinator{popups: newPopupManager()}
298- c.SetPopupConfig(factory, &entity.BrowsingContextConfig{OpenInNewPane: false}, func() string { return "detached-pane" })
299- c.SetPopupSourceHostResolver(func(entity.PaneID) dto.SourceHostKind { return dto.SourceHostFloating })
300- hostCalls := 0
301- c.SetOnOpenBrowserWindow(func(_ context.Context, _ BrowserWindowInput) (BrowserWindowResult, error) {
302- hostCalls++
303- return BrowserWindowResult{WindowID: "detached-window"}, nil
304- })
305- req := port.PopupRequest{
306- Engine: dto.BrowserEngineCEF, FrameName: "shared", NoJavaScriptAccess: true,
307- TargetDisposition: dto.WindowDispositionNewPopup, PopupFeatures: dto.PopupFeatures{State: dto.PopupFeaturesNone},
308- }
309- req.TargetURI = "https://example.com/first"
310- first := c.handlePopupCreate(ctx, parentPaneID, parentWV, req)
311- req.TargetURI = "https://example.com/second"
312- second := c.handlePopupCreate(ctx, parentPaneID, parentWV, req)
313-
314- assert.Same(t, firstWV, first)
315- assert.Same(t, secondWV, second)
316- assert.Equal(t, 2, hostCalls)
317-}
318-
319-func TestPopupFloatingBrowserHostFailureDestroysWebViewExactlyOnce(t *testing.T) {
320- parentWV := mocks.NewMockWebView(t)
321- parentWV.EXPECT().ID().Return(port.WebViewID(101)).Once()
322- popupWV := mocks.NewMockWebView(t)
323- popupWV.EXPECT().ID().Return(port.WebViewID(201)).Maybe()
324- popupWV.EXPECT().Generation().Return(uint64(1)).Maybe()
325- popupWV.EXPECT().SetCallbacks(mock.Anything).Once()
326- popupWV.EXPECT().Destroy().Once()
327- factory := mocks.NewMockWebViewFactory(t)
328- factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(popupWV, nil).Once()
329- c := &Coordinator{popups: newPopupManager()}
330- c.SetPopupConfig(factory, &entity.BrowsingContextConfig{OpenInNewPane: false}, func() string { return "detached-pane" })
331- c.SetPopupSourceHostResolver(func(entity.PaneID) dto.SourceHostKind { return dto.SourceHostFloating })
332- c.SetOnOpenBrowserWindow(func(context.Context, BrowserWindowInput) (BrowserWindowResult, error) {
333- return BrowserWindowResult{}, errors.New("host failed")
334- })
335-
336- got := c.handlePopupCreate(context.Background(), "floating", parentWV, port.PopupRequest{
337- Engine: dto.BrowserEngineCEF, TargetURI: "https://example.com/new", FrameName: "shared",
338- TargetDisposition: dto.WindowDispositionNewPopup, PopupFeatures: dto.PopupFeatures{State: dto.PopupFeaturesNone},
339- })
340-
341- assert.Nil(t, got)
342-}
343-
344-func TestHandlePopupCreate_PrimesPopupNavigationCapability(t *testing.T) {
345- ctx := context.Background()
346- parentPaneID := entity.PaneID("parent-pane")
347- parentWV := mocks.NewMockWebView(t)
348- parentWV.EXPECT().ID().Return(port.WebViewID(101)).Once()
349-
350- popupWV := &popupNavigationWebViewStub{MockWebView: mocks.NewMockWebView(t)}
351- popupWV.EXPECT().ID().Return(port.WebViewID(149)).Maybe()
352- popupWV.EXPECT().Generation().Return(uint64(1)).Maybe()
353- popupWV.EXPECT().SetCallbacks(mock.Anything).Once()
354-
355- factory := mocks.NewMockWebViewFactory(t)
356- factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(popupWV, nil).Once()
357-
358- c := &Coordinator{
359- webViews: make(map[entity.PaneID]port.WebView),
360- popups: newPopupManager(),
361- }
362- c.SetPopupConfig(factory, nil, nil)
363- c.SetOnInsertPopup(func(_ context.Context, input InsertPopupInput) error {
364- return nil
365- })
366-
367- created := c.handlePopupCreate(ctx, parentPaneID, parentWV, port.PopupRequest{
368- TargetURI: "https://example.com/popup",
369- FrameName: "auth-popup",
370- IsUserGesture: true,
371- NoJavaScriptAccess: true,
372- })
373-
374- require.Same(t, popupWV, created)
375- require.Equal(t, []string{"https://example.com/popup"}, popupWV.primed)
376-}
377-
378-func TestHandlePopupCreate_RegistersPopupWebViewBeforeWorkspaceInsertion(t *testing.T) {
379- ctx, parentPaneID, parentWV, popupWV, c := newPopupCreateCoordinatorForTest(t, port.WebViewID(150))
380- popupWV.EXPECT().Generation().Return(uint64(1)).Maybe()
381- popupWV.EXPECT().SetCallbacks(mock.Anything).Once()
382- popupWV.EXPECT().IsLoading().Return(false).Once()
383- popupWV.EXPECT().URI().Return("").Once()
384- popupWV.EXPECT().LoadURI(mock.Anything, "https://example.com/popup").Return(nil).Once()
385-
386- c.SetOnInsertPopup(func(_ context.Context, input InsertPopupInput) error {
387- require.Equal(t, entity.PaneID("popup-pane"), input.PopupPane.ID)
388- require.Same(t, popupWV, c.getWebViewLocked(input.PopupPane.ID))
389- return nil
390- })
391-
392- created := c.handlePopupCreate(ctx, parentPaneID, parentWV, port.PopupRequest{
393- TargetURI: "https://example.com/popup",
394- FrameName: "auth-popup",
395- IsUserGesture: true,
396- NoJavaScriptAccess: true,
397- })
398-
399- require.Same(t, popupWV, created)
400-}
401-
402-func TestHandlePopupCreate_CleansUpPreRegisteredPopupWebViewWhenInsertionFails(t *testing.T) {
403- ctx, parentPaneID, parentWV, popupWV, c := newPopupCreateCoordinatorForTest(t, port.WebViewID(151))
404- popupWV.EXPECT().Generation().Return(uint64(1)).Maybe()
405- popupWV.EXPECT().SetCallbacks(mock.Anything).Once()
406- popupWV.EXPECT().Destroy().Once()
407-
408- c.SetOnInsertPopup(func(_ context.Context, input InsertPopupInput) error {
409- require.Same(t, popupWV, c.getWebViewLocked(input.PopupPane.ID))
410- return assert.AnError
411- })
412-
413- created := c.handlePopupCreate(ctx, parentPaneID, parentWV, port.PopupRequest{
414- TargetURI: "https://example.com/popup",
415- FrameName: "auth-popup",
416- IsUserGesture: true,
417- NoJavaScriptAccess: true,
418- })
419-
420- require.Nil(t, created)
421- require.Nil(t, c.getWebViewLocked(entity.PaneID("popup-pane")))
422-}
423-
424-func TestHandlePopupCreate_UsesRelatedWebViewWhenPopupDisablesJavaScriptAccess(t *testing.T) {
425- ctx := context.Background()
426- parentPaneID := entity.PaneID("parent-pane")
427- parentWV := mocks.NewMockWebView(t)
428- parentWV.EXPECT().ID().Return(port.WebViewID(101)).Once()
429-
430- popupWV := mocks.NewMockWebView(t)
431- popupWV.EXPECT().ID().Return(port.WebViewID(150)).Maybe()
432- popupWV.EXPECT().Generation().Return(uint64(1)).Maybe()
433- popupWV.EXPECT().SetCallbacks(mock.Anything).Once()
434- popupWV.EXPECT().IsLoading().Return(false).Once()
435- popupWV.EXPECT().URI().Return("").Once()
436- popupWV.EXPECT().LoadURI(mock.Anything, "https://example.com/noopener").Return(nil).Once()
437-
438- factory := mocks.NewMockWebViewFactory(t)
439- factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(popupWV, nil).Once()
440-
441- c := &Coordinator{
442- webViews: make(map[entity.PaneID]port.WebView),
443- popups: newPopupManager(),
444- }
445- c.SetPopupConfig(factory, nil, nil)
446- c.SetOnInsertPopup(func(_ context.Context, input InsertPopupInput) error {
447- assert.Equal(t, "https://example.com/noopener", input.TargetURI)
448- return nil
449- })
450-
451- created := c.handlePopupCreate(ctx, parentPaneID, parentWV, port.PopupRequest{
452- TargetURI: "https://example.com/noopener",
453- FrameName: "auth-popup",
454- IsUserGesture: true,
455- NoJavaScriptAccess: true,
456- })
457-
458- require.Same(t, popupWV, created)
459-}
460-
461-func TestHandlePopupCreate_DeniesWhenRelatedCreateIsUnsupported(t *testing.T) {
462- ctx := context.Background()
463- parentPaneID := entity.PaneID("parent-pane")
464- parentWV := mocks.NewMockWebView(t)
465- parentWV.EXPECT().ID().Return(port.WebViewID(101)).Once()
466-
467- factory := mocks.NewMockWebViewFactory(t)
468- factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(nil, domainerrors.ErrRelatedWebViewUnsupported).Once()
469-
470- insertCalls := 0
471- c := &Coordinator{
472- webViews: make(map[entity.PaneID]port.WebView),
473- popups: newPopupManager(),
474- }
475- c.SetPopupConfig(factory, nil, nil)
476- c.SetOnInsertPopup(func(_ context.Context, input InsertPopupInput) error {
477- insertCalls++
478- return nil
479- })
480-
481- created := c.handlePopupCreate(ctx, parentPaneID, parentWV, port.PopupRequest{
482- TargetURI: "https://example.com/edit",
483- FrameName: "_blank",
484- IsUserGesture: true,
485- })
486-
487- require.Nil(t, created)
488- assert.Equal(t, 0, insertCalls)
489-}
490-
491-func TestHandlePopupCreate_DeniesWhenRelatedCreateFailsUnexpectedly(t *testing.T) {
492- ctx := context.Background()
493- parentPaneID := entity.PaneID("parent-pane")
494- parentWV := mocks.NewMockWebView(t)
495- parentWV.EXPECT().ID().Return(port.WebViewID(101)).Once()
496-
497- factory := mocks.NewMockWebViewFactory(t)
498- factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(nil, errors.New("boom")).Once()
499-
500- insertCalls := 0
501- c := &Coordinator{
502- webViews: make(map[entity.PaneID]port.WebView),
503- popups: newPopupManager(),
504- }
505- c.SetPopupConfig(factory, nil, nil)
506- c.SetOnInsertPopup(func(_ context.Context, input InsertPopupInput) error {
507- insertCalls++
508- return nil
509- })
510-
511- created := c.handlePopupCreate(ctx, parentPaneID, parentWV, port.PopupRequest{
512- TargetURI: "https://example.com/edit",
513- FrameName: "_blank",
514- IsUserGesture: true,
515- })
516-
517- require.Nil(t, created)
518- assert.Equal(t, 0, insertCalls)
519-}
520-
521-func TestHandleLinkMiddleClick_UsesRelatedWebView(t *testing.T) {
522- ctx := context.Background()
523- parentPaneID := entity.PaneID("parent-pane")
524- parentWV := mocks.NewMockWebView(t)
525- parentWV.EXPECT().ID().Return(port.WebViewID(101)).Twice()
526-
527- newWV := &popupNavigationWebViewStub{MockWebView: mocks.NewMockWebView(t)}
528- newWV.EXPECT().ID().Return(port.WebViewID(301)).Maybe()
529- newWV.EXPECT().Generation().Return(uint64(1)).Maybe()
530- newWV.EXPECT().SetCallbacks(mock.Anything).Maybe()
531- newWV.EXPECT().LoadURI(mock.Anything, "https://example.com/middle-click").Return(nil).Maybe()
532-
533- factory := mocks.NewMockWebViewFactory(t)
534- factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(newWV, nil).Maybe()
535-
536- insertCalls := 0
537- c := &Coordinator{
538- webViews: map[entity.PaneID]port.WebView{parentPaneID: parentWV},
539- popups: newPopupManager(),
540- }
541- c.SetPopupConfig(factory, nil, nil)
542- c.SetOnInsertPopup(func(_ context.Context, input InsertPopupInput) error {
543- insertCalls++
544- assert.Equal(t, parentPaneID, input.ParentPaneID)
545- assert.Same(t, newWV, input.WebView)
546- assert.Equal(t, entity.PopupBehaviorSplit, input.Behavior)
547- return nil
548- })
549-
550- handled := c.handleLinkMiddleClick(ctx, parentPaneID, "https://example.com/middle-click")
551- assert.True(t, handled)
552- assert.Equal(t, 1, insertCalls)
553- decision, hasDecision := newWV.BrowsingContextHostDecision()
554- require.True(t, hasDecision)
555- assert.Equal(t, dto.HostDecisionCreatePane, decision.Kind)
556-}
557-
558-func TestHandlePopupCreate_OpensNativePopupForAuthIntent(t *testing.T) {
559- ctx := context.Background()
560- parentPaneID := entity.PaneID("parent-pane")
561- parentWV := mocks.NewMockWebView(t)
562- parentWV.EXPECT().ID().Return(port.WebViewID(101)).Once()
563- parentWV.EXPECT().URI().Return("https://x.com/i/flow/login").Once()
564-
565- popupWV := &popupNavigationWebViewStub{MockWebView: mocks.NewMockWebView(t)}
566- popupWV.EXPECT().ID().Return(port.WebViewID(401)).Maybe()
567-
568- factory := mocks.NewMockWebViewFactory(t)
569- factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(popupWV, nil).Once()
570-
571- insertCalls := 0
572- nativeCalls := 0
573- c := &Coordinator{
574- webViews: make(map[entity.PaneID]port.WebView),
575- popups: newPopupManager(),
576- }
577- c.SetPopupConfig(factory, &entity.BrowsingContextConfig{OpenInNewPane: true, OAuthAutoClose: true}, nil)
578- c.SetOnInsertPopup(func(context.Context, InsertPopupInput) error {
579- insertCalls++
580- return nil
581- })
582- c.SetOnOpenNativePopup(func(_ context.Context, input NativePopupInput) error {
583- nativeCalls++
584- assert.Equal(t, parentPaneID, input.ParentPaneID)
585- assert.Equal(t, popupWV, input.PopupWebView)
586- assert.Equal(t, "https://accounts.google.com/o/oauth2/v2/auth", input.TargetURI)
587- assert.True(t, input.ObserveOAuthAutoClose)
588- return nil
589- })
590-
591- created := c.handlePopupCreate(ctx, parentPaneID, parentWV, port.PopupRequest{
592- TargetURI: "https://accounts.google.com/o/oauth2/v2/auth",
593- FrameName: "oauth-popup",
594- IsUserGesture: true,
595- })
596-
597- require.Equal(t, popupWV, created)
598- assert.Equal(t, 0, insertCalls)
599- assert.Equal(t, 1, nativeCalls)
600- assert.Equal(t, []string{"https://accounts.google.com/o/oauth2/v2/auth"}, popupWV.primed)
601-}
602-
603-func TestHandlePopupCreate_NativePopupDoesNotForceOAuthObservationWhenDisabled(t *testing.T) {
604- ctx := context.Background()
605- parentPaneID := entity.PaneID("parent-pane")
606- parentWV := mocks.NewMockWebView(t)
607- parentWV.EXPECT().ID().Return(port.WebViewID(101)).Once()
608-
609- popupWV := &popupNavigationWebViewStub{MockWebView: mocks.NewMockWebView(t)}
610- popupWV.EXPECT().ID().Return(port.WebViewID(402)).Maybe()
611-
612- factory := mocks.NewMockWebViewFactory(t)
613- factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(popupWV, nil).Once()
614-
615- insertCalls := 0
616- nativeCalls := 0
617- c := &Coordinator{
618- webViews: make(map[entity.PaneID]port.WebView),
619- popups: newPopupManager(),
620- }
621- c.SetPopupConfig(factory, &entity.BrowsingContextConfig{OpenInNewPane: true, OAuthAutoClose: false}, nil)
622- c.SetOnInsertPopup(func(context.Context, InsertPopupInput) error {
623- insertCalls++
624- return nil
625- })
626- c.SetOnOpenNativePopup(func(_ context.Context, input NativePopupInput) error {
627- nativeCalls++
628- assert.False(t, input.ObserveOAuthAutoClose)
629- return nil
630- })
631-
632- created := c.handlePopupCreate(ctx, parentPaneID, parentWV, port.PopupRequest{
633- TargetURI: "https://accounts.google.com/o/oauth2/v2/auth",
634- FrameName: "oauth-popup",
635- IsUserGesture: true,
636- })
637-
638- require.Equal(t, popupWV, created)
639- assert.Equal(t, 1, nativeCalls)
640- assert.Equal(t, 0, insertCalls)
641-}
642-
643-func TestPopupNativeAbortFallsBackOnceForNoOpenerVisualPopup(t *testing.T) {
644- ctx := context.Background()
645- popupWV := &popupNavigationWebViewStub{MockWebView: mocks.NewMockWebView(t)}
646- popupWV.EXPECT().ID().Return(port.WebViewID(701)).Once()
647- factory := mocks.NewMockWebViewFactory(t)
648- factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(popupWV, nil).Once()
649-
650- pm := newPopupManager()
651- pm.setConfig(factory, nil, nil)
652- browserCalls := 0
653- pm.setOnOpenBrowserWindow(func(_ context.Context, input BrowserWindowInput) (BrowserWindowResult, error) {
654- browserCalls++
655- assert.Same(t, popupWV, input.PopupWebView)
656- assert.True(t, input.Ready)
657- return BrowserWindowResult{WindowID: "fallback-window"}, nil
658- })
659- pm.setOnOpenNativePopup(func(ctx context.Context, input NativePopupInput) error {
660- assert.True(t, input.AllowBrowserWindowFallback)
661- assert.True(t, input.OnNativeHostAbort(ctx, input.PopupWebView))
662- assert.True(t, input.OnNativeHostAbort(ctx, input.PopupWebView))
663- return nil
664- })
665-
666- got := pm.openNativePopup(ctx, popupCoordinatorHooks{}, "floating", 101, "", port.PopupRequest{
667- TargetURI: "https://example.com/visual", NoJavaScriptAccess: true,
668- }, dto.HostDecision{Kind: dto.HostDecisionCreateNativePopup})
669- require.Same(t, popupWV, got)
670- assert.Equal(t, 1, browserCalls)
671-}
672-
673-func TestPopupNativeAbortFallsBackForOpenerCapableVisualPopup(t *testing.T) {
674- ctx := context.Background()
675- popupWV := &popupOpenerWebViewStub{popupNavigationWebViewStub: &popupNavigationWebViewStub{MockWebView: mocks.NewMockWebView(t)}}
676- popupWV.EXPECT().ID().Return(port.WebViewID(703)).Once()
677- factory := mocks.NewMockWebViewFactory(t)
678- factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(popupWV, nil).Once()
679-
680- pm := newPopupManager()
681- pm.setConfig(factory, nil, nil)
682- browserCalls := 0
683- pm.setOnOpenBrowserWindow(func(_ context.Context, input BrowserWindowInput) (BrowserWindowResult, error) {
684- browserCalls++
685- assert.Same(t, popupWV, input.PopupWebView)
686- return BrowserWindowResult{WindowID: "fallback-window"}, nil
687- })
688- pm.setOnOpenNativePopup(func(ctx context.Context, input NativePopupInput) error {
689- assert.True(t, input.AllowBrowserWindowFallback)
690- assert.True(t, input.OnNativeHostAbort(ctx, input.PopupWebView))
691- return nil
692- })
693-
694- got := pm.openNativePopup(ctx, popupCoordinatorHooks{}, "floating", 101, "", port.PopupRequest{
695- TargetURI: "https://example.com/visual", NoJavaScriptAccess: false,
696- }, dto.HostDecision{Kind: dto.HostDecisionCreateNativePopup})
697-
698- require.Same(t, popupWV, got)
699- assert.Equal(t, 1, browserCalls)
700-}
701-
702-func TestPopupNativeAbortDoesNotFallbackForAuthOrRequiredOpener(t *testing.T) {
703- for _, tc := range []struct {
704- name string
705- target string
706- decision dto.HostDecision
707- }{
708- {name: "auth", target: "https://accounts.google.com/o/oauth2/auth", decision: dto.HostDecision{Kind: dto.HostDecisionCreateNativePopup}},
709- {name: "required opener", target: "https://example.com/opener", decision: dto.HostDecision{Kind: dto.HostDecisionCreateNativePopup, RequiresNativeOpener: true}},
710- } {
711- t.Run(tc.name, func(t *testing.T) {
712- popupWV := &popupNavigationWebViewStub{MockWebView: mocks.NewMockWebView(t)}
713- popupWV.EXPECT().Destroy().Once()
714- factory := mocks.NewMockWebViewFactory(t)
715- factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(popupWV, nil).Once()
716- pm := newPopupManager()
717- pm.setConfig(factory, nil, nil)
718- pm.setOnOpenBrowserWindow(func(context.Context, BrowserWindowInput) (BrowserWindowResult, error) {
719- t.Fatal("auth/opener-required popup must not degrade to browser fallback")
720- return BrowserWindowResult{}, nil
721- })
722- pm.setOnOpenNativePopup(func(ctx context.Context, input NativePopupInput) error {
723- assert.False(t, input.AllowBrowserWindowFallback)
724- if !input.OnNativeHostAbort(ctx, input.PopupWebView) {
725- input.PopupWebView.Destroy()
726- }
727- return nil
728- })
729-
730- got := pm.openNativePopup(context.Background(), popupCoordinatorHooks{}, "floating", 101, "", port.PopupRequest{
731- TargetURI: tc.target, NoJavaScriptAccess: true,
732- }, tc.decision)
733- require.Same(t, popupWV, got)
734- })
735- }
736-}
737-
738-func TestPopupNativeFallbackFailureLeavesDestructionToNativeHost(t *testing.T) {
739- var output bytes.Buffer
740- logger := zerolog.New(&output)
741- ctx := logging.WithContext(context.Background(), logger)
742- popupWV := &popupNavigationWebViewStub{MockWebView: mocks.NewMockWebView(t)}
743- popupWV.EXPECT().ID().Return(port.WebViewID(702)).Once()
744- popupWV.EXPECT().Destroy().Once()
745- factory := mocks.NewMockWebViewFactory(t)
746- factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(popupWV, nil).Once()
747- pm := newPopupManager()
748- pm.setConfig(factory, nil, nil)
749- pm.setSourceHostResolver(func(entity.PaneID) dto.SourceHostKind { return dto.SourceHostFloating })
750- pm.setOnOpenBrowserWindow(func(context.Context, BrowserWindowInput) (BrowserWindowResult, error) {
751- return BrowserWindowResult{}, errors.New("fallback failed")
752- })
753- pm.setOnOpenNativePopup(func(ctx context.Context, input NativePopupInput) error {
754- if !input.OnNativeHostAbort(ctx, input.PopupWebView) {
755- input.PopupWebView.Destroy()
756- }
757- return nil
758- })
759-
760- got := pm.openNativePopup(ctx, popupCoordinatorHooks{}, "floating", 101, "", port.PopupRequest{
761- Engine: dto.BrowserEngineCEF, TargetURI: "https://example.com/visual", NoJavaScriptAccess: true,
762- TargetDisposition: dto.WindowDispositionNewPopup,
763- }, dto.HostDecision{Kind: dto.HostDecisionCreateNativePopup})
764- require.Same(t, popupWV, got)
765- record := decodeBrowsingContextLog(t, output.Bytes())
766- assert.Equal(t, "cef", record["engine"])
767- assert.Equal(t, "floating", record["source_host"])
768- assert.Equal(t, "create-native-popup", record["decision"])
769- assert.Equal(t, "new-popup", record["target_disposition"])
770- assert.Equal(t, "fallback-failed", record["reason_code"])
771-}
772-
773-func TestPopupNativeSetupFailureDestroysCoordinatorOwnedWebViewOnce(t *testing.T) {
774- popupWV := &popupNavigationWebViewStub{MockWebView: mocks.NewMockWebView(t)}
775- popupWV.EXPECT().Destroy().Once()
776- factory := mocks.NewMockWebViewFactory(t)
777- factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(popupWV, nil).Once()
778- pm := newPopupManager()
779- pm.setConfig(factory, nil, nil)
780- pm.setOnOpenNativePopup(func(context.Context, NativePopupInput) error { return errors.New("setup failed") })
781-
782- got := pm.openNativePopup(context.Background(), popupCoordinatorHooks{}, "floating", 101, "", port.PopupRequest{
783- TargetURI: "https://example.com/visual", NoJavaScriptAccess: true,
784- }, dto.HostDecision{Kind: dto.HostDecisionCreateNativePopup})
785- assert.Nil(t, got)
786-}
787-
788-func TestHandlePopupCreate_DoesNotReuseNamedPopupWhenNoJavaScriptAccess(t *testing.T) {
789- ctx := context.Background()
790- parentPaneID := entity.PaneID("parent-pane")
791- parentWV := mocks.NewMockWebView(t)
792- parentWV.EXPECT().ID().Return(port.WebViewID(101)).Twice()
793-
794- firstWV := mocks.NewMockWebView(t)
795- firstWV.EXPECT().ID().Return(port.WebViewID(501)).Maybe()
796- firstWV.EXPECT().Generation().Return(uint64(1)).Maybe()
797- firstWV.EXPECT().SetCallbacks(mock.Anything).Maybe()
798- firstWV.EXPECT().IsLoading().Return(false).Maybe()
799- firstWV.EXPECT().URI().Return("").Maybe()
800- firstWV.EXPECT().LoadURI(mock.Anything, "https://example.com/first").Return(nil).Maybe()
801-
802- secondWV := mocks.NewMockWebView(t)
803- secondWV.EXPECT().ID().Return(port.WebViewID(502)).Maybe()
804- secondWV.EXPECT().Generation().Return(uint64(1)).Maybe()
805- secondWV.EXPECT().SetCallbacks(mock.Anything).Maybe()
806- secondWV.EXPECT().IsLoading().Return(false).Maybe()
807- secondWV.EXPECT().URI().Return("").Maybe()
808- secondWV.EXPECT().LoadURI(mock.Anything, "https://example.com/second").Return(nil).Maybe()
809-
810- factory := mocks.NewMockWebViewFactory(t)
811- factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(firstWV, nil).Once()
812- factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(secondWV, nil).Once()
813-
814- insertCalls := 0
815- c := &Coordinator{
816- webViews: make(map[entity.PaneID]port.WebView),
817- popups: newPopupManager(),
818- }
819- c.SetPopupConfig(factory, nil, nil)
820- c.SetPopupWindowIDResolver(func(entity.PaneID) (string, bool) { return "window-1", true })
821- c.SetOnInsertPopup(func(context.Context, InsertPopupInput) error {
822- insertCalls++
823- return nil
824- })
825-
826- first := c.handlePopupCreate(ctx, parentPaneID, parentWV, port.PopupRequest{
827- TargetURI: "https://example.com/first",
828- FrameName: "shared-pane",
829- IsUserGesture: true,
830- NoJavaScriptAccess: true,
831- })
832- second := c.handlePopupCreate(ctx, parentPaneID, parentWV, port.PopupRequest{
833- TargetURI: "https://example.com/second",
834- FrameName: "shared-pane",
835- IsUserGesture: true,
836- NoJavaScriptAccess: true,
837- })
838-
839- require.Same(t, firstWV, first)
840- require.Same(t, secondWV, second)
841- assert.Equal(t, 2, insertCalls)
842-}
843-
844-func TestHandlePopupCreate_ReusesNamedPopup(t *testing.T) {
845- ctx := context.Background()
846- parentPaneID := entity.PaneID("parent-pane")
847- parentWV := mocks.NewMockWebView(t)
848- parentWV.EXPECT().ID().Return(port.WebViewID(101)).Twice()
849-
850- popupWV := mocks.NewMockWebView(t)
851- popupWV.EXPECT().ID().Return(port.WebViewID(202)).Maybe()
852- popupWV.EXPECT().Generation().Return(uint64(1)).Maybe()
853- popupWV.EXPECT().SetCallbacks(mock.Anything).Maybe()
854- popupWV.EXPECT().IsLoading().Return(false).Maybe()
855- currentURI := ""
856- popupWV.EXPECT().URI().RunAndReturn(func() string { return currentURI }).Maybe()
857- popupWV.EXPECT().IsDestroyed().Return(false).Maybe()
858- popupWV.EXPECT().LoadURI(mock.Anything, "https://example.com/first").RunAndReturn(func(context.Context, string) error {
859- currentURI = "https://example.com/first"
860- return nil
861- }).Once()
862- popupWV.EXPECT().LoadURI(mock.Anything, "https://example.com/second").RunAndReturn(func(context.Context, string) error {
863- currentURI = "https://example.com/second"
864- return nil
865- }).Once()
866-
867- factory := mocks.NewMockWebViewFactory(t)
868- factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(popupWV, nil).Once()
869-
870- insertCalls := 0
871- c := &Coordinator{
872- webViews: make(map[entity.PaneID]port.WebView),
873- popups: newPopupManager(),
874- }
875- c.SetPopupConfig(factory, nil, nil)
876- c.SetPopupWindowIDResolver(func(entity.PaneID) (string, bool) { return "window-1", true })
877- c.SetOnInsertPopup(func(context.Context, InsertPopupInput) error {
878- insertCalls++
879- return nil
880- })
881-
882- first := c.handlePopupCreate(ctx, parentPaneID, parentWV, port.PopupRequest{
883- TargetURI: "https://example.com/first",
884- FrameName: "shared-pane",
885- IsUserGesture: true,
886- })
887- require.Same(t, popupWV, first)
888-
889- second := c.handlePopupCreate(ctx, parentPaneID, parentWV, port.PopupRequest{
890- TargetURI: "https://example.com/second",
891- FrameName: "shared-pane",
892- IsUserGesture: true,
893- })
894- require.Same(t, popupWV, second)
895-
896- assert.Equal(t, 1, insertCalls)
897- assert.Equal(t, "https://example.com/second", currentURI)
898-}
899-
900-func TestHandlePopupCreate_FallsBackToNewPaneWhenNamedReuseBecomesUnavailable(t *testing.T) {
901- ctx := context.Background()
902- parentPaneID := entity.PaneID("parent-pane")
903- stalePaneID := entity.PaneID("stale-pane")
904- parentWV := mocks.NewMockWebView(t)
905- parentWV.EXPECT().ID().Return(port.WebViewID(101)).Once()
906-
907- staleWV := mocks.NewMockWebView(t)
908- staleWV.EXPECT().IsDestroyed().Return(false).Once()
909- staleWV.EXPECT().ID().Return(port.WebViewID(202)).Once()
910- staleWV.EXPECT().IsDestroyed().Return(true).Once()
911-
912- newWV := &popupNavigationWebViewStub{MockWebView: mocks.NewMockWebView(t)}
913- newWV.EXPECT().ID().Return(port.WebViewID(303)).Maybe()
914- newWV.EXPECT().Generation().Return(uint64(1)).Maybe()
915- newWV.EXPECT().SetCallbacks(mock.Anything).Maybe()
916-
917- factory := mocks.NewMockWebViewFactory(t)
918- factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(newWV, nil).Once()
919-
920- insertCalls := 0
921- c := &Coordinator{
922- webViews: map[entity.PaneID]port.WebView{
923- stalePaneID: staleWV,
924- },
925- popups: newPopupManager(),
926- }
927- c.SetPopupConfig(factory, nil, nil)
928- c.SetPopupWindowIDResolver(func(entity.PaneID) (string, bool) { return "window-1", true })
929- c.popups.namedContexts.Register("window-1", "window-1", "shared-pane", stalePaneID, port.WebViewID(202))
930- c.SetOnInsertPopup(func(context.Context, InsertPopupInput) error {
931- insertCalls++
932- return nil
933- })
934-
935- created := c.handlePopupCreate(ctx, parentPaneID, parentWV, port.PopupRequest{
936- TargetURI: "https://example.com/replacement",
937- FrameName: "shared-pane",
938- IsUserGesture: true,
939- })
940-
941- require.Same(t, newWV, created)
942- assert.Equal(t, 1, insertCalls)
943- decision, hasDecision := newWV.BrowsingContextHostDecision()
944- require.True(t, hasDecision)
945- assert.Equal(t, dto.HostDecisionCreatePane, decision.Kind)
946- assert.Empty(t, decision.ReuseContextName)
947- assert.Equal(t, "shared-pane", decision.BrowsingContextName)
948-}
949-
950-// ---------------------------------------------------------------------------
951-// trackOAuthPopup / capturePopupOAuthState
952-// ---------------------------------------------------------------------------
953-
954-func TestTrackOAuthPopup_StoresState(t *testing.T) {
955- t.Parallel()
956-
957- c := &Coordinator{
958- webViews: make(map[entity.PaneID]port.WebView),
959- popups: newPopupManager(),
960- }
961-
962- popupID := port.WebViewID(1)
963- parentPaneID := entity.PaneID("parent-pane")
964-
965- c.trackOAuthPopup(popupID, parentPaneID, "https://parent.example.com/login")
966-
967- c.popups.mu.RLock()
968- state, ok := c.popups.popupOAuth[popupID]
969- c.popups.mu.RUnlock()
970-
971- require.True(t, ok, "oauth state should be tracked after trackOAuthPopup")
972- require.NotNil(t, state)
973- assert.Equal(t, parentPaneID, state.ParentPaneID)
974- assert.Equal(t, "https://parent.example.com/login", state.ParentURIAtOpen)
975- assert.False(t, state.Seen, "Seen should be false before capturing callback URI")
976-}
977-
978-func TestCapturePopupOAuthState_SuccessCallback(t *testing.T) {
979- t.Parallel()
980-
981- c := &Coordinator{
982- webViews: make(map[entity.PaneID]port.WebView),
983- popups: newPopupManager(),
984- }
985-
986- popupID := port.WebViewID(2)
987- c.trackOAuthPopup(popupID, entity.PaneID("parent"), "https://parent.example.com/login")
988- c.capturePopupOAuthState(popupID, "https://app.example.com/callback?code=abc123")
989-
990- c.popups.mu.RLock()
991- state := c.popups.popupOAuth[popupID]
992- c.popups.mu.RUnlock()
993-
994- require.NotNil(t, state)
995- assert.True(t, state.Seen)
996- assert.True(t, state.Success)
997- assert.False(t, state.Error)
998- assert.Equal(t, "https://app.example.com/callback?code=abc123", state.CallbackURI)
999-}
1000-
1001-func TestCapturePopupOAuthState_ErrorCallback(t *testing.T) {
1002- t.Parallel()
1003-
1004- c := &Coordinator{
1005- webViews: make(map[entity.PaneID]port.WebView),
1006- popups: newPopupManager(),
1007- }
1008-
1009- popupID := port.WebViewID(3)
1010- c.trackOAuthPopup(popupID, entity.PaneID("parent"), "https://parent.example.com/login")
1011- c.capturePopupOAuthState(popupID, "https://app.example.com/callback?error=access_denied")
1012-
1013- c.popups.mu.RLock()
1014- state := c.popups.popupOAuth[popupID]
1015- c.popups.mu.RUnlock()
1016-
1017- require.NotNil(t, state)
1018- assert.True(t, state.Seen)
1019- assert.False(t, state.Success)
1020- assert.True(t, state.Error)
1021-}
1022-
1023-func TestCapturePopupOAuthState_UnknownPopupIsNoop(t *testing.T) {
1024- t.Parallel()
1025-
1026- c := &Coordinator{
1027- webViews: make(map[entity.PaneID]port.WebView),
1028- popups: newPopupManager(),
1029- }
1030-
1031- // Should not panic when no tracking entry exists.
1032- c.capturePopupOAuthState(port.WebViewID(999), "https://example.com/callback?code=x")
1033-
1034- c.popups.mu.RLock()
1035- _, ok := c.popups.popupOAuth[port.WebViewID(999)]
1036- c.popups.mu.RUnlock()
1037-
1038- assert.False(t, ok)
1039-}
1040-
1041-// ---------------------------------------------------------------------------
1042-// SetPopupConfig
1043-// ---------------------------------------------------------------------------
1044-
1045-func TestSetPopupConfig_SetsFields(t *testing.T) {
1046- t.Parallel()
1047-
1048- c := &Coordinator{}
1049-
1050- factory := &stubFactory{}
1051- cfg := &entity.BrowsingContextConfig{Behavior: entity.PopupBehaviorSplit}
1052- genID := func() string { return "test-id" }
1053-
1054- c.SetPopupConfig(factory, cfg, genID)
1055-
1056- assert.Equal(t, factory, c.popups.factory)
1057- assert.True(t, c.popups.hasPopupConfig)
1058- assert.Equal(t, *cfg, c.popups.popupConfig)
1059- cfg.Behavior = entity.PopupBehaviorWindowed
1060- assert.Equal(t, entity.PopupBehaviorSplit, c.popups.popupConfig.Behavior)
1061- assert.NotNil(t, c.popups.generatePaneID)
1062- assert.Equal(t, "test-id", c.popups.generatePaneID())
1063-}
1064-
1065-func TestSetPopupConfig_NilConfigAllowed(t *testing.T) {
1066- t.Parallel()
1067-
1068- c := &Coordinator{}
1069- c.SetPopupConfig(nil, nil, nil)
1070-
1071- assert.Nil(t, c.popups.factory)
1072- assert.False(t, c.popups.hasPopupConfig)
1073- assert.Equal(t, entity.BrowsingContextConfig{}, c.popups.popupConfig)
1074- assert.Nil(t, c.popups.generatePaneID)
1075-}
1076-
1077-func TestUpdatePopupConfig_CopiesLatestValue(t *testing.T) {
1078- t.Parallel()
1079-
1080- c := &Coordinator{}
1081- initial := &entity.BrowsingContextConfig{OpenInNewPane: true, OAuthAutoClose: false}
1082- c.SetPopupConfig(nil, initial, nil)
1083-
1084- c.UpdatePopupConfig(entity.BrowsingContextConfig{OpenInNewPane: false, OAuthAutoClose: true})
1085-
1086- assert.True(t, c.popups.hasPopupConfig)
1087- assert.False(t, c.popups.popupConfig.OpenInNewPane)
1088- assert.True(t, c.popups.popupConfig.OAuthAutoClose)
1089- assert.True(t, initial.OpenInNewPane)
1090- assert.False(t, initial.OAuthAutoClose)
1091-}
1092-
1093-type popupOpenerWebViewStub struct {
1094- *popupNavigationWebViewStub
1095-}
1096-
1097-func (*popupOpenerWebViewStub) EnablePopupOpenerBridge(port.WebView, bool) {}
1098-func (*popupOpenerWebViewStub) AddOpenerMessageCallback(func()) {}
1099-func (*popupOpenerWebViewStub) AddOpenerNavigationCallback(func(string)) {}
1100-func (*popupOpenerWebViewStub) HasActivePopupOpenerBridge() bool { return true }
1101-
1102-type popupNavigationWebViewStub struct {
1103- *mocks.MockWebView
1104- primed []string
1105- browsingContextDecision dto.HostDecision
1106- hasBrowsingContextDecision bool
1107-}
1108-
1109-func (s *popupNavigationWebViewStub) PrimePopupNavigation(uri string) {
1110- s.primed = append(s.primed, uri)
1111-}
1112-
1113-func (s *popupNavigationWebViewStub) SetBrowsingContextHostDecision(decision dto.HostDecision) {
1114- s.browsingContextDecision = decision
1115- s.hasBrowsingContextDecision = true
1116-}
1117-
1118-func (s *popupNavigationWebViewStub) BrowsingContextHostDecision() (dto.HostDecision, bool) {
1119- return s.browsingContextDecision, s.hasBrowsingContextDecision
1120-}
1121-
1122-func (*popupNavigationWebViewStub) SetOnReadyToShow(func()) {}
1123-func (*popupNavigationWebViewStub) SetOnClose(func()) {}
1124-func (*popupNavigationWebViewStub) Show() {}
1125-
1126-// stubFactory satisfies port.WebViewFactory without importing the mocks package.
1127-type stubFactory struct{}
1128-
1129-func (s *stubFactory) Create(_ context.Context) (port.WebView, error) {
1130- return nil, nil
1131-}
1132-
1133-func (s *stubFactory) CreateRelated(_ context.Context, _ port.WebViewID) (port.WebView, error) {
1134- return nil, nil
1135-}
1136-
1137-// ---------------------------------------------------------------------------
1138-// Concurrent access to pendingPopups
1139-// ---------------------------------------------------------------------------
1140-
1141-func TestPendingPopups_ConcurrentAccess(t *testing.T) {
1142- t.Parallel()
1143-
1144- c := &Coordinator{
1145- popups: newPopupManager(),
1146- }
1147-
1148- const workers = 20
1149- var wg sync.WaitGroup
1150- wg.Add(workers * 2)
1151-
1152- // Writers
1153- for i := range workers {
1154- go func(id int) {
1155- defer wg.Done()
1156- popupID := port.WebViewID(id)
1157- c.popups.mu.Lock()
1158- c.popups.pendingPopups[popupID] = &PendingPopup{
1159- FrameName: "_blank",
1160- PopupType: PopupTypeTab,
1161- }
1162- c.popups.mu.Unlock()
1163- }(i)
1164- }
1165-
1166- // Readers
1167- for i := range workers {
1168- go func(id int) {
1169- defer wg.Done()
1170- popupID := port.WebViewID(id)
1171- c.popups.mu.RLock()
1172- _ = c.popups.pendingPopups[popupID]
1173- c.popups.mu.RUnlock()
1174- }(i)
1175- }
1176-
1177- wg.Wait()
1178-
1179- c.popups.mu.RLock()
1180- count := len(c.popups.pendingPopups)
1181- c.popups.mu.RUnlock()
1182-
1183- assert.Equal(t, workers, count, "each writer uses a unique key, so all inserts should be present")
1184-}
1185-
1186-func TestPendingPopups_ConcurrentDeleteAndRead(t *testing.T) {
1187- t.Parallel()
1188-
1189- c := &Coordinator{
1190- popups: newPopupManager(),
1191- }
1192-
1193- // Pre-populate
1194- for i := range 10 {
1195- c.popups.pendingPopups[port.WebViewID(i)] = &PendingPopup{PopupType: PopupTypePopup}
1196- }
1197-
1198- var wg sync.WaitGroup
1199- wg.Add(20)
1200-
1201- // Deleters
1202- for i := range 10 {
1203- go func(id int) {
1204- defer wg.Done()
1205- c.popups.mu.Lock()
1206- delete(c.popups.pendingPopups, port.WebViewID(id))
1207- c.popups.mu.Unlock()
1208- }(i)
1209- }
1210-
1211- // Readers
1212- for i := range 10 {
1213- go func(id int) {
1214- defer wg.Done()
1215- c.popups.mu.RLock()
1216- _ = c.popups.pendingPopups[port.WebViewID(id)]
1217- c.popups.mu.RUnlock()
1218- }(i)
1219- }
1220-
1221- wg.Wait()
1222-
1223- c.popups.mu.RLock()
1224- defer c.popups.mu.RUnlock()
1225- assert.Empty(t, c.popups.pendingPopups, "all preloaded popups should have been deleted")
1226-}
1227-
1228-func TestPopupDeferredFeaturelessBlankNavigatesSourceAndCleansStagingExactlyOnce(t *testing.T) {
1229- ctx := context.Background()
1230- parent := mocks.NewMockWebView(t)
1231- parent.EXPECT().ID().Return(port.WebViewID(101)).Once()
1232- parent.EXPECT().LoadURI(mock.Anything, "https://example.com/open").Return(nil).Once()
1233- popup := &deferredPopupWebViewStub{MockWebView: mocks.NewMockWebView(t), features: dto.PopupFeatures{State: dto.PopupFeaturesNone}}
1234- popup.EXPECT().ID().Return(port.WebViewID(201)).Maybe()
1235- popup.EXPECT().Destroy().Once()
1236- factory := mocks.NewMockWebViewFactory(t)
1237- factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(popup, nil).Once()
1238- staging := &popupStagingHostStub{}
1239- c := &Coordinator{popups: newPopupManager()}
1240- c.SetPopupConfig(factory, &entity.BrowsingContextConfig{OpenInNewPane: false}, func() string { return "deferred" })
1241- c.SetPopupSourceHostResolver(func(entity.PaneID) dto.SourceHostKind { return dto.SourceHostFloating })
1242- c.SetOnStagePopup(func(context.Context, StagePopupInput) (PopupStagingHost, error) {
1243- staging.attached = true
1244- return staging, nil
1245- })
1246- c.SetOnOpenBrowserWindow(func(context.Context, BrowserWindowInput) (BrowserWindowResult, error) {
1247- t.Fatal("featureless _blank must not open a browser window")
1248- return BrowserWindowResult{}, nil
1249- })
1250- c.SetOnOpenNativePopup(func(context.Context, NativePopupInput) error {
1251- t.Fatal("featureless _blank must not use native host")
1252- return nil
1253- })
1254-
1255- got := c.handlePopupCreate(ctx, "floating", parent, port.PopupRequest{
1256- Engine: dto.BrowserEngineWebKit, TargetURI: "https://example.com/open", FrameName: "_blank",
1257- TargetDisposition: dto.WindowDispositionNewPopup, PopupFeatures: dto.PopupFeatures{State: dto.PopupFeaturesUnknown},
1258- })
1259- require.Same(t, popup, got)
1260- assert.True(t, staging.attached)
1261- assert.False(t, staging.detached)
1262- popup.ready()
1263- popup.ready()
1264- assert.True(t, staging.detached)
1265- assert.Equal(t, 1, staging.destroyCalls)
1266-}
1267-
1268-func TestPopupDeferredFeaturedDetachesBeforeNativeHost(t *testing.T) {
1269- ctx := context.Background()
1270- parent := mocks.NewMockWebView(t)
1271- parent.EXPECT().ID().Return(port.WebViewID(101)).Once()
1272- popup := &deferredPopupWebViewStub{MockWebView: mocks.NewMockWebView(t), features: dto.PopupFeatures{State: dto.PopupFeaturesSpecified, Width: 640, WidthSet: true}}
1273- popup.EXPECT().ID().Return(port.WebViewID(202)).Maybe()
1274- factory := mocks.NewMockWebViewFactory(t)
1275- factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(popup, nil).Once()
1276- staging := &popupStagingHostStub{}
1277- c := &Coordinator{popups: newPopupManager()}
1278- c.SetPopupConfig(factory, &entity.BrowsingContextConfig{OpenInNewPane: false}, func() string { return "deferred" })
1279- c.SetPopupSourceHostResolver(func(entity.PaneID) dto.SourceHostKind { return dto.SourceHostFloating })
1280- c.SetOnStagePopup(func(context.Context, StagePopupInput) (PopupStagingHost, error) {
1281- staging.attached = true
1282- return staging, nil
1283- })
1284- c.SetOnOpenNativePopup(func(_ context.Context, input NativePopupInput) error {
1285- assert.True(t, staging.detached)
1286- assert.Equal(t, dto.PopupFeaturesSpecified, input.Request.PopupFeatures.State)
1287- assert.Same(t, popup, input.PopupWebView)
1288- return nil
1289- })
1290-
1291- require.Same(t, popup, c.handlePopupCreate(ctx, "floating", parent, port.PopupRequest{
1292- Engine: dto.BrowserEngineWebKit, TargetURI: "https://example.com/open", FrameName: "_blank",
1293- TargetDisposition: dto.WindowDispositionNewPopup, PopupFeatures: dto.PopupFeatures{State: dto.PopupFeaturesUnknown},
1294- }))
1295- popup.ready()
1296- decision, ok := popup.BrowsingContextHostDecision()
1297- require.True(t, ok)
1298- assert.Equal(t, dto.HostDecisionCreateNativePopup, decision.Kind)
1299-}
1300-
1301-func TestPopupDeferredNamedReuseDestroysNewlyStagedWebViewExactlyOnce(t *testing.T) {
1302- ctx := context.Background()
1303- parentPaneID := entity.PaneID("floating")
1304- existingPaneID := entity.PaneID("existing")
1305- parent := mocks.NewMockWebView(t)
1306- parent.EXPECT().ID().Return(port.WebViewID(101)).Once()
1307- staged := &deferredPopupWebViewStub{MockWebView: mocks.NewMockWebView(t), features: dto.PopupFeatures{State: dto.PopupFeaturesNone}}
1308- staged.EXPECT().ID().Return(port.WebViewID(205)).Maybe()
1309- staged.EXPECT().Destroy().Once()
1310- existing := mocks.NewMockWebView(t)
1311- existing.EXPECT().ID().Return(port.WebViewID(301)).Times(3)
1312- existing.EXPECT().IsDestroyed().Return(false).Twice()
1313- existing.EXPECT().LoadURI(mock.Anything, "https://example.com/reused").Return(nil).Once()
1314- factory := mocks.NewMockWebViewFactory(t)
1315- factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(staged, nil).Once()
1316- staging := &popupStagingHostStub{}
1317- c := &Coordinator{webViews: make(map[entity.PaneID]port.WebView), popups: newPopupManager()}
1318- c.SetPopupConfig(factory, &entity.BrowsingContextConfig{OpenInNewPane: false}, nil)
1319- c.SetPopupSourceHostResolver(func(entity.PaneID) dto.SourceHostKind { return dto.SourceHostFloating })
1320- c.SetPopupWindowIDResolver(func(paneID entity.PaneID) (string, bool) {
1321- windowByPane := map[entity.PaneID]string{parentPaneID: "owner", existingPaneID: "host"}
1322- windowID, ok := windowByPane[paneID]
1323- return windowID, ok
1324- })
1325- c.SetOnStagePopup(func(context.Context, StagePopupInput) (PopupStagingHost, error) { return staging, nil })
1326-
1327- require.Same(t, staged, c.handlePopupCreate(ctx, parentPaneID, parent, port.PopupRequest{
1328- Engine: dto.BrowserEngineWebKit, TargetURI: "https://example.com/reused", FrameName: "shared",
1329- PopupFeatures: dto.PopupFeatures{State: dto.PopupFeaturesUnknown},
1330- }))
1331- c.RegisterPopupWebView(existingPaneID, existing)
1332- c.popups.namedContexts.Register("owner", "host", "shared", existingPaneID, port.WebViewID(301))
1333- staged.ready()
1334- staged.ready()
1335-
1336- assert.True(t, staging.detached)
1337- assert.Equal(t, 1, staging.destroyCalls)
1338-}
1339-
1340-func TestPopupDeferredNativeAbortWithoutFallbackLogsStructuredFailure(t *testing.T) {
1341- var output bytes.Buffer
1342- logger := zerolog.New(&output).Level(zerolog.WarnLevel)
1343- ctx := logging.WithContext(context.Background(), logger)
1344- parent := mocks.NewMockWebView(t)
1345- parent.EXPECT().ID().Return(port.WebViewID(101)).Once()
1346- popup := &deferredPopupWebViewStub{MockWebView: mocks.NewMockWebView(t), features: dto.PopupFeatures{State: dto.PopupFeaturesSpecified, Width: 640, WidthSet: true}}
1347- popup.EXPECT().ID().Return(port.WebViewID(206)).Maybe()
1348- popup.EXPECT().Destroy().Once()
1349- factory := mocks.NewMockWebViewFactory(t)
1350- factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(popup, nil).Once()
1351- c := &Coordinator{popups: newPopupManager()}
1352- c.SetPopupConfig(factory, &entity.BrowsingContextConfig{OpenInNewPane: false}, nil)
1353- c.SetPopupSourceHostResolver(func(entity.PaneID) dto.SourceHostKind { return dto.SourceHostFloating })
1354- c.SetOnStagePopup(func(context.Context, StagePopupInput) (PopupStagingHost, error) { return &popupStagingHostStub{}, nil })
1355- c.SetOnOpenNativePopup(func(abortCtx context.Context, input NativePopupInput) error {
1356- assert.False(t, input.AllowBrowserWindowFallback)
1357- if !input.OnNativeHostAbort(abortCtx, input.PopupWebView) {
1358- input.PopupWebView.Destroy()
1359- }
1360- return nil
1361- })
1362-
1363- require.Same(t, popup, c.handlePopupCreate(ctx, "floating", parent, port.PopupRequest{
1364- Engine: dto.BrowserEngineWebKit, TargetURI: "https://example.com/visual", TargetDisposition: dto.WindowDispositionNewPopup,
1365- PopupFeatures: dto.PopupFeatures{State: dto.PopupFeaturesUnknown},
1366- }))
1367- popup.ready()
1368-
1369- record := decodeBrowsingContextLog(t, output.Bytes())
1370- assert.Equal(t, "webkit", record["engine"])
1371- assert.Equal(t, "floating", record["source_host"])
1372- assert.Equal(t, "create-native-popup", record["decision"])
1373- assert.Equal(t, "new-popup", record["target_disposition"])
1374- assert.Equal(t, "native-arm-failed", record["reason_code"])
1375-}
1376-
1377-func TestPopupDeferredBrowserHostFailureCleansUpExactlyOnce(t *testing.T) {
1378- parent := mocks.NewMockWebView(t)
1379- parent.EXPECT().ID().Return(port.WebViewID(101)).Once()
1380- popup := &deferredPopupWebViewStub{MockWebView: mocks.NewMockWebView(t), features: dto.PopupFeatures{State: dto.PopupFeaturesNone}}
1381- popup.EXPECT().ID().Return(port.WebViewID(204)).Maybe()
1382- popup.EXPECT().Generation().Return(uint64(1)).Maybe()
1383- popup.EXPECT().SetCallbacks(mock.Anything).Once()
1384- popup.EXPECT().Destroy().Once()
1385- factory := mocks.NewMockWebViewFactory(t)
1386- factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(popup, nil).Once()
1387- staging := &popupStagingHostStub{}
1388- c := &Coordinator{popups: newPopupManager()}
1389- c.SetPopupConfig(factory, &entity.BrowsingContextConfig{OpenInNewPane: false}, nil)
1390- c.SetPopupSourceHostResolver(func(entity.PaneID) dto.SourceHostKind { return dto.SourceHostFloating })
1391- c.SetOnStagePopup(func(context.Context, StagePopupInput) (PopupStagingHost, error) { return staging, nil })
1392- c.SetOnOpenBrowserWindow(func(context.Context, BrowserWindowInput) (BrowserWindowResult, error) {
1393- return BrowserWindowResult{}, errors.New("host failed")
1394- })
1395-
1396- require.Same(t, popup, c.handlePopupCreate(context.Background(), "floating", parent, port.PopupRequest{
1397- Engine: dto.BrowserEngineWebKit, TargetURI: "https://example.com/open", FrameName: "shared",
1398- TargetDisposition: dto.WindowDispositionNewPopup, PopupFeatures: dto.PopupFeatures{State: dto.PopupFeaturesUnknown},
1399- }))
1400- popup.ready()
1401- popup.ready()
1402- assert.True(t, staging.detached)
1403- assert.Equal(t, 1, staging.destroyCalls)
1404-}
1405-
1406-func TestPopupDeferredStagingFailureDestroysCallerOwnedWebViewOnce(t *testing.T) {
1407- parent := mocks.NewMockWebView(t)
1408- parent.EXPECT().ID().Return(port.WebViewID(101)).Once()
1409- popup := &deferredPopupWebViewStub{MockWebView: mocks.NewMockWebView(t)}
1410- popup.EXPECT().Destroy().Once()
1411- factory := mocks.NewMockWebViewFactory(t)
1412- factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(popup, nil).Once()
1413- c := &Coordinator{popups: newPopupManager()}
1414- c.SetPopupConfig(factory, &entity.BrowsingContextConfig{OpenInNewPane: false}, nil)
1415- c.SetPopupSourceHostResolver(func(entity.PaneID) dto.SourceHostKind { return dto.SourceHostFloating })
1416- c.SetOnStagePopup(func(context.Context, StagePopupInput) (PopupStagingHost, error) {
1417- return nil, errors.New("stage failed")
1418- })
1419-
1420- assert.Nil(t, c.handlePopupCreate(context.Background(), "floating", parent, port.PopupRequest{Engine: dto.BrowserEngineWebKit, TargetURI: "https://example.com/open", PopupFeatures: dto.PopupFeatures{State: dto.PopupFeaturesUnknown}}))
1421-}
1422-
1423-func TestPopupDeferredExplicitTeardownCleansUpWithoutLifecycleSignals(t *testing.T) {
1424- for _, tc := range []struct {
1425- name string
1426- prepare func(*Coordinator)
1427- trigger func(*Coordinator)
1428- }{
1429- {name: "parent release", trigger: func(c *Coordinator) { c.ReleaseWebView(context.Background(), "floating") }},
1430- {name: "owner window teardown", prepare: func(c *Coordinator) {
1431- c.SetPopupWindowIDResolver(func(entity.PaneID) (string, bool) { return "owner", true })
1432- }, trigger: func(c *Coordinator) { c.ClearPopupNamedContextsForWindow("owner") }},
1433- {name: "coordinator shutdown", trigger: func(c *Coordinator) { c.ShutdownPopups() }},
1434- } {
1435- t.Run(tc.name, func(t *testing.T) {
1436- parent := mocks.NewMockWebView(t)
1437- parent.EXPECT().ID().Return(port.WebViewID(101)).Once()
1438- popup := &deferredPopupWebViewStub{MockWebView: mocks.NewMockWebView(t), features: dto.PopupFeatures{State: dto.PopupFeaturesNone}}
1439- popup.EXPECT().ID().Return(port.WebViewID(207)).Maybe()
1440- popup.EXPECT().Destroy().Once()
1441- factory := mocks.NewMockWebViewFactory(t)
1442- factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(popup, nil).Once()
1443- staging := &popupStagingHostStub{}
1444- c := &Coordinator{popups: newPopupManager()}
1445- c.SetPopupConfig(factory, &entity.BrowsingContextConfig{OpenInNewPane: false}, nil)
1446- c.SetPopupSourceHostResolver(func(entity.PaneID) dto.SourceHostKind { return dto.SourceHostFloating })
1447- c.SetOnStagePopup(func(context.Context, StagePopupInput) (PopupStagingHost, error) { return staging, nil })
1448- if tc.prepare != nil {
1449- tc.prepare(c)
1450- }
1451-
1452- require.Same(t, popup, c.handlePopupCreate(context.Background(), "floating", parent, port.PopupRequest{
1453- Engine: dto.BrowserEngineWebKit, TargetURI: "https://example.com/open",
1454- PopupFeatures: dto.PopupFeatures{State: dto.PopupFeaturesUnknown},
1455- }))
1456- require.Len(t, c.popups.deferredPopups, 1)
1457- tc.trigger(c)
1458- tc.trigger(c)
1459-
1460- assert.Empty(t, c.popups.deferredPopups)
1461- assert.Empty(t, c.popups.pendingPopups)
1462- assert.Equal(t, 1, staging.destroyCalls)
1463- assert.Nil(t, popup.onReady)
1464- assert.Nil(t, popup.onClose)
1465- })
1466- }
1467-}
1468-
1469-func TestPopupDeferredUnknownAndCloseCleanupExactlyOnce(t *testing.T) {
1470- for _, tc := range []struct {
1471- name string
1472- close bool
1473- }{{name: "unknown at ready"}, {name: "close before ready", close: true}} {
1474- t.Run(tc.name, func(t *testing.T) {
1475- parent := mocks.NewMockWebView(t)
1476- parent.EXPECT().ID().Return(port.WebViewID(101)).Once()
1477- popup := &deferredPopupWebViewStub{MockWebView: mocks.NewMockWebView(t), features: dto.PopupFeatures{State: dto.PopupFeaturesUnknown}}
1478- popup.EXPECT().ID().Return(port.WebViewID(203)).Maybe()
1479- popup.EXPECT().Destroy().Once()
1480- factory := mocks.NewMockWebViewFactory(t)
1481- factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(popup, nil).Once()
1482- staging := &popupStagingHostStub{}
1483- c := &Coordinator{popups: newPopupManager()}
1484- c.SetPopupConfig(factory, &entity.BrowsingContextConfig{OpenInNewPane: false}, nil)
1485- c.SetPopupSourceHostResolver(func(entity.PaneID) dto.SourceHostKind { return dto.SourceHostFloating })
1486- c.SetOnStagePopup(func(context.Context, StagePopupInput) (PopupStagingHost, error) { return staging, nil })
1487- require.Same(t, popup, c.handlePopupCreate(context.Background(), "floating", parent, port.PopupRequest{Engine: dto.BrowserEngineWebKit, TargetURI: "https://example.com/open", PopupFeatures: dto.PopupFeatures{State: dto.PopupFeaturesUnknown}}))
1488- if tc.close {
1489- popup.close()
1490- popup.close()
1491- } else {
1492- popup.ready()
1493- popup.ready()
1494- }
1495- assert.Equal(t, 1, staging.destroyCalls)
1496- })
1497- }
1498-}
1499-
1500-// deferredPopupWebViewStub exposes the optional late-feature and lifecycle ports.
1501-type deferredPopupWebViewStub struct {
1502- *mocks.MockWebView
1503- features dto.PopupFeatures
1504- onReady func()
1505- onClose func()
1506- browsingContextDecision dto.HostDecision
1507- hasBrowsingContextDecision bool
1508-}
1509-
1510-func (s *deferredPopupWebViewStub) ResolvePopupFeatures() dto.PopupFeatures { return s.features }
1511-func (s *deferredPopupWebViewStub) PrimePopupNavigation(string) {}
1512-func (s *deferredPopupWebViewStub) SetOnReadyToShow(fn func()) { s.onReady = fn }
1513-func (s *deferredPopupWebViewStub) SetOnClose(fn func()) { s.onClose = fn }
1514-func (s *deferredPopupWebViewStub) Show() {}
1515-func (s *deferredPopupWebViewStub) SetBrowsingContextHostDecision(decision dto.HostDecision) {
1516- s.browsingContextDecision = decision
1517- s.hasBrowsingContextDecision = true
1518-}
1519-func (s *deferredPopupWebViewStub) BrowsingContextHostDecision() (dto.HostDecision, bool) {
1520- return s.browsingContextDecision, s.hasBrowsingContextDecision
1521-}
1522-func (s *deferredPopupWebViewStub) ready() {
1523- if s.onReady != nil {
1524- s.onReady()
1525- }
1526-}
1527-func (s *deferredPopupWebViewStub) close() {
1528- if s.onClose != nil {
1529- s.onClose()
1530- }
1531-}
1532-
1533-type popupStagingHostStub struct {
1534- attached, detached bool
1535- destroyCalls int
1536-}
1537-
1538-func (s *popupStagingHostStub) Detach() error { s.detached = true; return nil }
1539-func (s *popupStagingHostStub) Destroy() { s.destroyCalls++ }

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