1package content
2
3import (
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
27func 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
51func 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
74func 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
96func 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
118func 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
138func 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
150func 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
166type popupOpenerWebViewStub struct {
167 *popupNavigationWebViewStub
168}
169
170func (*popupOpenerWebViewStub) EnablePopupOpenerBridge(port.WebView, bool) {}
171func (*popupOpenerWebViewStub) AddOpenerMessageCallback(func()) {}
172func (*popupOpenerWebViewStub) AddOpenerNavigationCallback(func(string)) {}
173func (*popupOpenerWebViewStub) HasActivePopupOpenerBridge() bool { return true }
174
175type popupNavigationWebViewStub struct {
176 *mocks.MockWebView
177 primed []string
178 browsingContextDecision dto.HostDecision
179 hasBrowsingContextDecision bool
180}
181
182func (s *popupNavigationWebViewStub) PrimePopupNavigation(uri string) {
183 s.primed = append(s.primed, uri)
184}
185
186func (s *popupNavigationWebViewStub) SetBrowsingContextHostDecision(decision dto.HostDecision) {
187 s.browsingContextDecision = decision
188 s.hasBrowsingContextDecision = true
189}
190
191func (s *popupNavigationWebViewStub) BrowsingContextHostDecision() (dto.HostDecision, bool) {
192 return s.browsingContextDecision, s.hasBrowsingContextDecision
193}
194
195func (*popupNavigationWebViewStub) SetOnReadyToShow(func()) {}
196func (*popupNavigationWebViewStub) SetOnClose(func()) {}
197func (*popupNavigationWebViewStub) Show() {}
198
199// stubFactory satisfies port.WebViewFactory without importing the mocks package.
200type stubFactory struct{}
201
202func (s *stubFactory) Create(_ context.Context) (port.WebView, error) {
203 return nil, nil
204}
205
206func (s *stubFactory) CreateRelated(_ context.Context, _ port.WebViewID) (port.WebView, error) {
207 return nil, nil
208}
209
210// ---------------------------------------------------------------------------
211// Concurrent access to pendingPopups
212// ---------------------------------------------------------------------------
213
214func 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
259func 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
301func 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
341func 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
374func 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
413func TestPopupDeferredNamedReuseRaceLogsHostUnavailableAndCleansStagingExactlyOnce(t *testing.T) {
414 var output bytes.Buffer
415 logger := zerolog.New(&output).Level(zerolog.WarnLevel)
416 ctx := logging.WithContext(context.Background(), logger)
417 parentPaneID := entity.PaneID("floating")
418 existingPaneID := entity.PaneID("existing")
419 parent := mocks.NewMockWebView(t)
420 parent.EXPECT().ID().Return(port.WebViewID(101)).Once()
421 staged := &deferredPopupWebViewStub{MockWebView: mocks.NewMockWebView(t), features: dto.PopupFeatures{State: dto.PopupFeaturesNone}}
422 staged.EXPECT().ID().Return(port.WebViewID(208)).Maybe()
423 staged.EXPECT().Destroy().Once()
424 existing := mocks.NewMockWebView(t)
425 destroyedChecks := 0
426 existing.EXPECT().IsDestroyed().RunAndReturn(func() bool {
427 destroyedChecks++
428 return destroyedChecks > 1
429 }).Twice()
430 existing.EXPECT().ID().Return(port.WebViewID(301)).Once()
431 factory := mocks.NewMockWebViewFactory(t)
432 factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(staged, nil).Once()
433 staging := &popupStagingHostStub{}
434 c := &Coordinator{webViews: make(map[entity.PaneID]port.WebView), popups: newPopupManager()}
435 c.SetPopupConfig(factory, &entity.BrowsingContextConfig{OpenInNewPane: false}, nil)
436 c.SetPopupSourceHostResolver(func(entity.PaneID) dto.SourceHostKind { return dto.SourceHostFloating })
437 c.SetPopupWindowIDResolver(func(paneID entity.PaneID) (string, bool) {
438 windowByPane := map[entity.PaneID]string{parentPaneID: "owner", existingPaneID: "host"}
439 windowID, ok := windowByPane[paneID]
440 return windowID, ok
441 })
442 c.SetOnStagePopup(func(context.Context, StagePopupInput) (PopupStagingHost, error) { return staging, nil })
443
444 require.Same(t, staged, c.handlePopupCreate(ctx, parentPaneID, parent, port.PopupRequest{
445 Engine: dto.BrowserEngineWebKit, TargetURI: "https://example.com/reused", FrameName: "shared",
446 PopupFeatures: dto.PopupFeatures{State: dto.PopupFeaturesUnknown},
447 }))
448 c.RegisterPopupWebView(existingPaneID, existing)
449 c.popups.namedContexts.Register("owner", "host", "shared", existingPaneID, port.WebViewID(301))
450 staged.ready()
451 staged.ready()
452
453 assert.Equal(t, 2, destroyedChecks)
454 assert.Equal(t, 1, staging.destroyCalls)
455 record := decodeBrowsingContextLog(t, output.Bytes())
456 assert.Equal(t, "host-unavailable", record["reason_code"])
457}
458
459func TestPopupDeferredNativeAbortWithoutFallbackLogsStructuredFailure(t *testing.T) {
460 var output bytes.Buffer
461 logger := zerolog.New(&output).Level(zerolog.WarnLevel)
462 ctx := logging.WithContext(context.Background(), logger)
463 parent := mocks.NewMockWebView(t)
464 parent.EXPECT().ID().Return(port.WebViewID(101)).Once()
465 popup := &deferredPopupWebViewStub{MockWebView: mocks.NewMockWebView(t), features: dto.PopupFeatures{State: dto.PopupFeaturesSpecified, Width: 640, WidthSet: true}}
466 popup.EXPECT().ID().Return(port.WebViewID(206)).Maybe()
467 popup.EXPECT().Destroy().Once()
468 factory := mocks.NewMockWebViewFactory(t)
469 factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(popup, nil).Once()
470 c := &Coordinator{popups: newPopupManager()}
471 c.SetPopupConfig(factory, &entity.BrowsingContextConfig{OpenInNewPane: false}, nil)
472 c.SetPopupSourceHostResolver(func(entity.PaneID) dto.SourceHostKind { return dto.SourceHostFloating })
473 c.SetOnStagePopup(func(context.Context, StagePopupInput) (PopupStagingHost, error) { return &popupStagingHostStub{}, nil })
474 c.SetOnOpenNativePopup(func(abortCtx context.Context, input NativePopupInput) error {
475 assert.False(t, input.AllowBrowserWindowFallback)
476 if !input.OnNativeHostAbort(abortCtx, input.PopupWebView) {
477 input.PopupWebView.Destroy()
478 }
479 return nil
480 })
481
482 require.Same(t, popup, c.handlePopupCreate(ctx, "floating", parent, port.PopupRequest{
483 Engine: dto.BrowserEngineWebKit, TargetURI: "https://example.com/visual", TargetDisposition: dto.WindowDispositionNewPopup,
484 PopupFeatures: dto.PopupFeatures{State: dto.PopupFeaturesUnknown},
485 }))
486 popup.ready()
487
488 record := decodeBrowsingContextLog(t, output.Bytes())
489 assert.Equal(t, "webkit", record["engine"])
490 assert.Equal(t, "floating", record["source_host"])
491 assert.Equal(t, "create-native-popup", record["decision"])
492 assert.Equal(t, "new-popup", record["target_disposition"])
493 assert.Equal(t, "native-arm-failed", record["reason_code"])
494}
495
496func TestPopupDeferredBrowserHostFailureCleansUpExactlyOnce(t *testing.T) {
497 parent := mocks.NewMockWebView(t)
498 parent.EXPECT().ID().Return(port.WebViewID(101)).Once()
499 popup := &deferredPopupWebViewStub{MockWebView: mocks.NewMockWebView(t), features: dto.PopupFeatures{State: dto.PopupFeaturesNone}}
500 popup.EXPECT().ID().Return(port.WebViewID(204)).Maybe()
501 popup.EXPECT().Generation().Return(uint64(1)).Maybe()
502 popup.EXPECT().SetCallbacks(mock.Anything).Once()
503 popup.EXPECT().Destroy().Once()
504 factory := mocks.NewMockWebViewFactory(t)
505 factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(popup, nil).Once()
506 staging := &popupStagingHostStub{}
507 c := &Coordinator{popups: newPopupManager()}
508 c.SetPopupConfig(factory, &entity.BrowsingContextConfig{OpenInNewPane: false}, nil)
509 c.SetPopupSourceHostResolver(func(entity.PaneID) dto.SourceHostKind { return dto.SourceHostFloating })
510 c.SetOnStagePopup(func(context.Context, StagePopupInput) (PopupStagingHost, error) { return staging, nil })
511 c.SetOnOpenBrowserWindow(func(context.Context, BrowserWindowInput) (BrowserWindowResult, error) {
512 return BrowserWindowResult{}, errors.New("host failed")
513 })
514
515 require.Same(t, popup, c.handlePopupCreate(context.Background(), "floating", parent, port.PopupRequest{
516 Engine: dto.BrowserEngineWebKit, TargetURI: "https://example.com/open", FrameName: "shared",
517 TargetDisposition: dto.WindowDispositionNewPopup, PopupFeatures: dto.PopupFeatures{State: dto.PopupFeaturesUnknown},
518 }))
519 popup.ready()
520 popup.ready()
521 assert.True(t, staging.detached)
522 assert.Equal(t, 1, staging.destroyCalls)
523}
524
525func TestPopupDeferredStagingDetachFailureCleansUpExactlyOnce(t *testing.T) {
526 parent := mocks.NewMockWebView(t)
527 parent.EXPECT().ID().Return(port.WebViewID(101)).Once()
528 popup := &deferredPopupWebViewStub{MockWebView: mocks.NewMockWebView(t), features: dto.PopupFeatures{State: dto.PopupFeaturesNone}}
529 popup.EXPECT().ID().Return(port.WebViewID(209)).Maybe()
530 popup.EXPECT().Destroy().Once()
531 factory := mocks.NewMockWebViewFactory(t)
532 factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(popup, nil).Once()
533 staging := &popupStagingHostStub{detachErr: errors.New("detach failed")}
534 c := &Coordinator{popups: newPopupManager()}
535 c.SetPopupConfig(factory, &entity.BrowsingContextConfig{OpenInNewPane: false}, nil)
536 c.SetPopupSourceHostResolver(func(entity.PaneID) dto.SourceHostKind { return dto.SourceHostFloating })
537 c.SetOnStagePopup(func(context.Context, StagePopupInput) (PopupStagingHost, error) { return staging, nil })
538
539 require.Same(t, popup, c.handlePopupCreate(context.Background(), "floating", parent, port.PopupRequest{
540 Engine: dto.BrowserEngineWebKit, TargetURI: "https://example.com/open",
541 PopupFeatures: dto.PopupFeatures{State: dto.PopupFeaturesUnknown},
542 }))
543 popup.ready()
544 popup.ready()
545
546 assert.True(t, staging.detached)
547 assert.Equal(t, 1, staging.destroyCalls)
548}
549
550func TestPopupDeferredStagingFailureDestroysCallerOwnedWebViewOnce(t *testing.T) {
551 parent := mocks.NewMockWebView(t)
552 parent.EXPECT().ID().Return(port.WebViewID(101)).Once()
553 popup := &deferredPopupWebViewStub{MockWebView: mocks.NewMockWebView(t)}
554 popup.EXPECT().Destroy().Once()
555 factory := mocks.NewMockWebViewFactory(t)
556 factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(popup, nil).Once()
557 c := &Coordinator{popups: newPopupManager()}
558 c.SetPopupConfig(factory, &entity.BrowsingContextConfig{OpenInNewPane: false}, nil)
559 c.SetPopupSourceHostResolver(func(entity.PaneID) dto.SourceHostKind { return dto.SourceHostFloating })
560 c.SetOnStagePopup(func(context.Context, StagePopupInput) (PopupStagingHost, error) {
561 return nil, errors.New("stage failed")
562 })
563
564 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}}))
565}
566
567func TestPopupDeferredExplicitTeardownCleansUpWithoutLifecycleSignals(t *testing.T) {
568 for _, tc := range []struct {
569 name string
570 prepare func(*Coordinator)
571 trigger func(*Coordinator)
572 }{
573 {name: "parent release", trigger: func(c *Coordinator) { c.ReleaseWebView(context.Background(), "floating") }},
574 {name: "owner window teardown", prepare: func(c *Coordinator) {
575 c.SetPopupWindowIDResolver(func(entity.PaneID) (string, bool) { return "owner", true })
576 }, trigger: func(c *Coordinator) { c.ClearPopupNamedContextsForWindow("owner") }},
577 {name: "coordinator shutdown", trigger: func(c *Coordinator) { c.ShutdownPopups() }},
578 } {
579 t.Run(tc.name, func(t *testing.T) {
580 parent := mocks.NewMockWebView(t)
581 parent.EXPECT().ID().Return(port.WebViewID(101)).Once()
582 popup := &deferredPopupWebViewStub{MockWebView: mocks.NewMockWebView(t), features: dto.PopupFeatures{State: dto.PopupFeaturesNone}}
583 popup.EXPECT().ID().Return(port.WebViewID(207)).Maybe()
584 popup.EXPECT().Destroy().Once()
585 factory := mocks.NewMockWebViewFactory(t)
586 factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(popup, nil).Once()
587 staging := &popupStagingHostStub{}
588 c := &Coordinator{popups: newPopupManager()}
589 c.SetPopupConfig(factory, &entity.BrowsingContextConfig{OpenInNewPane: false}, nil)
590 c.SetPopupSourceHostResolver(func(entity.PaneID) dto.SourceHostKind { return dto.SourceHostFloating })
591 c.SetOnStagePopup(func(context.Context, StagePopupInput) (PopupStagingHost, error) { return staging, nil })
592 if tc.prepare != nil {
593 tc.prepare(c)
594 }
595
596 require.Same(t, popup, c.handlePopupCreate(context.Background(), "floating", parent, port.PopupRequest{
597 Engine: dto.BrowserEngineWebKit, TargetURI: "https://example.com/open",
598 PopupFeatures: dto.PopupFeatures{State: dto.PopupFeaturesUnknown},
599 }))
600 require.Len(t, c.popups.deferredPopups, 1)
601 tc.trigger(c)
602 tc.trigger(c)
603
604 assert.Empty(t, c.popups.deferredPopups)
605 assert.Empty(t, c.popups.pendingPopups)
606 assert.Equal(t, 1, staging.destroyCalls)
607 assert.Nil(t, popup.onReady)
608 assert.Nil(t, popup.onClose)
609 })
610 }
611}
612
613func TestPopupDeferredUnknownAndCloseCleanupExactlyOnce(t *testing.T) {
614 for _, tc := range []struct {
615 name string
616 close bool
617 }{{name: "unknown at ready"}, {name: "close before ready", close: true}} {
618 t.Run(tc.name, func(t *testing.T) {
619 parent := mocks.NewMockWebView(t)
620 parent.EXPECT().ID().Return(port.WebViewID(101)).Once()
621 popup := &deferredPopupWebViewStub{MockWebView: mocks.NewMockWebView(t), features: dto.PopupFeatures{State: dto.PopupFeaturesUnknown}}
622 popup.EXPECT().ID().Return(port.WebViewID(203)).Maybe()
623 popup.EXPECT().Destroy().Once()
624 factory := mocks.NewMockWebViewFactory(t)
625 factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(popup, nil).Once()
626 staging := &popupStagingHostStub{}
627 c := &Coordinator{popups: newPopupManager()}
628 c.SetPopupConfig(factory, &entity.BrowsingContextConfig{OpenInNewPane: false}, nil)
629 c.SetPopupSourceHostResolver(func(entity.PaneID) dto.SourceHostKind { return dto.SourceHostFloating })
630 c.SetOnStagePopup(func(context.Context, StagePopupInput) (PopupStagingHost, error) { return staging, nil })
631 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}}))
632 if tc.close {
633 popup.close()
634 popup.close()
635 } else {
636 popup.ready()
637 popup.ready()
638 }
639 assert.Equal(t, 1, staging.destroyCalls)
640 })
641 }
642}
643
644// deferredPopupWebViewStub exposes the optional late-feature and lifecycle ports.
645type deferredPopupWebViewStub struct {
646 *mocks.MockWebView
647 features dto.PopupFeatures
648 onReady func()
649 onClose func()
650 browsingContextDecision dto.HostDecision
651 hasBrowsingContextDecision bool
652}
653
654func (s *deferredPopupWebViewStub) ResolvePopupFeatures() dto.PopupFeatures { return s.features }
655func (s *deferredPopupWebViewStub) PrimePopupNavigation(string) {}
656func (s *deferredPopupWebViewStub) SetOnReadyToShow(fn func()) { s.onReady = fn }
657func (s *deferredPopupWebViewStub) SetOnClose(fn func()) { s.onClose = fn }
658func (s *deferredPopupWebViewStub) Show() {}
659func (s *deferredPopupWebViewStub) SetBrowsingContextHostDecision(decision dto.HostDecision) {
660 s.browsingContextDecision = decision
661 s.hasBrowsingContextDecision = true
662}
663func (s *deferredPopupWebViewStub) BrowsingContextHostDecision() (dto.HostDecision, bool) {
664 return s.browsingContextDecision, s.hasBrowsingContextDecision
665}
666func (s *deferredPopupWebViewStub) ready() {
667 if s.onReady != nil {
668 s.onReady()
669 }
670}
671func (s *deferredPopupWebViewStub) close() {
672 if s.onClose != nil {
673 s.onClose()
674 }
675}
676
677type popupStagingHostStub struct {
678 attached, detached bool
679 destroyCalls int
680 detachErr error
681}
682
683func (s *popupStagingHostStub) Detach() error { s.detached = true; return s.detachErr }
684func (s *popupStagingHostStub) Destroy() { s.destroyCalls++ }