1package content
2
3import (
4 "context"
5 "errors"
6 "sync"
7 "testing"
8
9 "github.com/stretchr/testify/assert"
10 "github.com/stretchr/testify/mock"
11 "github.com/stretchr/testify/require"
12
13 "github.com/bnema/dumber/internal/application/dto"
14 "github.com/bnema/dumber/internal/application/port"
15 "github.com/bnema/dumber/internal/application/port/mocks"
16 "github.com/bnema/dumber/internal/domain/entity"
17 domainerrors "github.com/bnema/dumber/internal/domain/errors"
18)
19
20// ---------------------------------------------------------------------------
21// DetectPopupType
22// ---------------------------------------------------------------------------
23
24func TestDetectPopupType_BlankIsTab(t *testing.T) {
25 t.Parallel()
26
27 assert.Equal(t, PopupTypeTab, DetectPopupType("_blank"))
28}
29
30func TestDetectPopupType_EmptyIsPopup(t *testing.T) {
31 t.Parallel()
32
33 assert.Equal(t, PopupTypePopup, DetectPopupType(""))
34}
35
36func TestDetectPopupType_NamedFrameIsPopup(t *testing.T) {
37 t.Parallel()
38
39 assert.Equal(t, PopupTypePopup, DetectPopupType("myFrame"))
40}
41
42// ---------------------------------------------------------------------------
43// PopupType.String()
44// ---------------------------------------------------------------------------
45
46func TestPopupTypeString_Tab(t *testing.T) {
47 t.Parallel()
48
49 assert.Equal(t, "tab", PopupTypeTab.String())
50}
51
52func TestPopupTypeString_Popup(t *testing.T) {
53 t.Parallel()
54
55 assert.Equal(t, "popup", PopupTypePopup.String())
56}
57
58func TestPopupTypeString_Unknown(t *testing.T) {
59 t.Parallel()
60
61 assert.Equal(t, "unknown", PopupType(99).String())
62}
63
64// ---------------------------------------------------------------------------
65// GetBehavior
66// ---------------------------------------------------------------------------
67
68func TestGetBehavior_NilConfigReturnsSplit(t *testing.T) {
69 t.Parallel()
70
71 assert.Equal(t, entity.PopupBehaviorSplit, GetBehavior(PopupTypeTab, nil))
72 assert.Equal(t, entity.PopupBehaviorSplit, GetBehavior(PopupTypePopup, nil))
73}
74
75func TestGetBehavior_TabPopup_DefaultConfig(t *testing.T) {
76 t.Parallel()
77
78 // BlankTargetBehavior is empty → falls through to default "stacked"
79 cfg := &entity.BrowsingContextConfig{}
80 assert.Equal(t, entity.PopupBehaviorStacked, GetBehavior(PopupTypeTab, cfg))
81}
82
83func TestGetBehavior_TabPopup_SplitConfig(t *testing.T) {
84 t.Parallel()
85
86 cfg := &entity.BrowsingContextConfig{BlankTargetBehavior: "split"}
87 assert.Equal(t, entity.PopupBehaviorSplit, GetBehavior(PopupTypeTab, cfg))
88}
89
90func TestGetBehavior_TabPopup_StackedConfig(t *testing.T) {
91 t.Parallel()
92
93 cfg := &entity.BrowsingContextConfig{BlankTargetBehavior: "stacked"}
94 assert.Equal(t, entity.PopupBehaviorStacked, GetBehavior(PopupTypeTab, cfg))
95}
96
97func TestGetBehavior_TabPopup_TabbedConfig(t *testing.T) {
98 t.Parallel()
99
100 cfg := &entity.BrowsingContextConfig{BlankTargetBehavior: "tabbed"}
101 assert.Equal(t, entity.PopupBehaviorTabbed, GetBehavior(PopupTypeTab, cfg))
102}
103
104func TestGetBehavior_JSPopup_UsesBehaviorField(t *testing.T) {
105 t.Parallel()
106
107 cfg := &entity.BrowsingContextConfig{Behavior: entity.PopupBehaviorWindowed}
108 assert.Equal(t, entity.PopupBehaviorWindowed, GetBehavior(PopupTypePopup, cfg))
109}
110
111func TestGetBehavior_JSPopup_DefaultConfig(t *testing.T) {
112 t.Parallel()
113
114 // Behavior zero-value is empty string; GetBehavior returns it as-is.
115 cfg := &entity.BrowsingContextConfig{}
116 assert.Equal(t, entity.PopupBehavior(""), GetBehavior(PopupTypePopup, cfg))
117}
118
119func TestGetBehavior_TabPopup_BlocksWhenOpenInNewPaneFalse(t *testing.T) {
120 t.Parallel()
121
122 // GetBehavior itself does not honor OpenInNewPane — that is enforced by
123 // handlePopupCreate. GetBehavior still returns the configured value.
124 cfg := &entity.BrowsingContextConfig{
125 OpenInNewPane: false,
126 BlankTargetBehavior: "split",
127 }
128 assert.Equal(t, entity.PopupBehaviorSplit, GetBehavior(PopupTypeTab, cfg))
129}
130
131func newPopupCreateCoordinatorForTest(t *testing.T, popupID port.WebViewID) (context.Context, entity.PaneID, *mocks.MockWebView, *mocks.MockWebView, *Coordinator) {
132 t.Helper()
133
134 ctx := context.Background()
135 parentPaneID := entity.PaneID("parent-pane")
136 parentWV := mocks.NewMockWebView(t)
137 parentWV.EXPECT().ID().Return(port.WebViewID(101)).Once()
138
139 popupWV := mocks.NewMockWebView(t)
140 popupWV.EXPECT().ID().Return(popupID).Once()
141
142 factory := mocks.NewMockWebViewFactory(t)
143 factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(popupWV, nil).Once()
144
145 c := &Coordinator{
146 webViews: make(map[entity.PaneID]port.WebView),
147 popups: newPopupManager(),
148 }
149 c.SetPopupConfig(factory, nil, func() string { return "popup-pane" })
150
151 return ctx, parentPaneID, parentWV, popupWV, c
152}
153
154func TestHandlePopupCreate_PrimesPopupNavigationCapability(t *testing.T) {
155 ctx := context.Background()
156 parentPaneID := entity.PaneID("parent-pane")
157 parentWV := mocks.NewMockWebView(t)
158 parentWV.EXPECT().ID().Return(port.WebViewID(101)).Once()
159
160 popupWV := &popupNavigationWebViewStub{MockWebView: mocks.NewMockWebView(t)}
161 popupWV.EXPECT().ID().Return(port.WebViewID(149)).Once()
162 popupWV.EXPECT().Generation().Return(uint64(1)).Once()
163 popupWV.EXPECT().SetCallbacks(mock.Anything).Once()
164
165 factory := mocks.NewMockWebViewFactory(t)
166 factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(popupWV, nil).Once()
167
168 c := &Coordinator{
169 webViews: make(map[entity.PaneID]port.WebView),
170 popups: newPopupManager(),
171 }
172 c.SetPopupConfig(factory, nil, nil)
173 c.SetOnInsertPopup(func(_ context.Context, input InsertPopupInput) error {
174 return nil
175 })
176
177 created := c.handlePopupCreate(ctx, parentPaneID, parentWV, port.PopupRequest{
178 TargetURI: "https://example.com/popup",
179 FrameName: "auth-popup",
180 IsUserGesture: true,
181 NoJavaScriptAccess: true,
182 })
183
184 require.Same(t, popupWV, created)
185 require.Equal(t, []string{"https://example.com/popup"}, popupWV.primed)
186}
187
188func TestHandlePopupCreate_RegistersPopupWebViewBeforeWorkspaceInsertion(t *testing.T) {
189 ctx, parentPaneID, parentWV, popupWV, c := newPopupCreateCoordinatorForTest(t, port.WebViewID(150))
190 popupWV.EXPECT().Generation().Return(uint64(1)).Once()
191 popupWV.EXPECT().SetCallbacks(mock.Anything).Once()
192 popupWV.EXPECT().IsLoading().Return(false).Once()
193 popupWV.EXPECT().URI().Return("").Once()
194 popupWV.EXPECT().LoadURI(mock.Anything, "https://example.com/popup").Return(nil).Once()
195
196 c.SetOnInsertPopup(func(_ context.Context, input InsertPopupInput) error {
197 require.Equal(t, entity.PaneID("popup-pane"), input.PopupPane.ID)
198 require.Same(t, popupWV, c.getWebViewLocked(input.PopupPane.ID))
199 return nil
200 })
201
202 created := c.handlePopupCreate(ctx, parentPaneID, parentWV, port.PopupRequest{
203 TargetURI: "https://example.com/popup",
204 FrameName: "auth-popup",
205 IsUserGesture: true,
206 NoJavaScriptAccess: true,
207 })
208
209 require.Same(t, popupWV, created)
210}
211
212func TestHandlePopupCreate_CleansUpPreRegisteredPopupWebViewWhenInsertionFails(t *testing.T) {
213 ctx, parentPaneID, parentWV, popupWV, c := newPopupCreateCoordinatorForTest(t, port.WebViewID(151))
214 popupWV.EXPECT().Generation().Return(uint64(1)).Once()
215 popupWV.EXPECT().SetCallbacks(mock.Anything).Once()
216 popupWV.EXPECT().Destroy().Once()
217
218 c.SetOnInsertPopup(func(_ context.Context, input InsertPopupInput) error {
219 require.Same(t, popupWV, c.getWebViewLocked(input.PopupPane.ID))
220 return assert.AnError
221 })
222
223 created := c.handlePopupCreate(ctx, parentPaneID, parentWV, port.PopupRequest{
224 TargetURI: "https://example.com/popup",
225 FrameName: "auth-popup",
226 IsUserGesture: true,
227 NoJavaScriptAccess: true,
228 })
229
230 require.Nil(t, created)
231 require.Nil(t, c.getWebViewLocked(entity.PaneID("popup-pane")))
232}
233
234func TestHandlePopupCreate_UsesRelatedWebViewWhenPopupDisablesJavaScriptAccess(t *testing.T) {
235 ctx := context.Background()
236 parentPaneID := entity.PaneID("parent-pane")
237 parentWV := mocks.NewMockWebView(t)
238 parentWV.EXPECT().ID().Return(port.WebViewID(101)).Once()
239
240 popupWV := mocks.NewMockWebView(t)
241 popupWV.EXPECT().ID().Return(port.WebViewID(150)).Once()
242 popupWV.EXPECT().Generation().Return(uint64(1)).Once()
243 popupWV.EXPECT().SetCallbacks(mock.Anything).Once()
244 popupWV.EXPECT().IsLoading().Return(false).Once()
245 popupWV.EXPECT().URI().Return("").Once()
246 popupWV.EXPECT().LoadURI(mock.Anything, "https://example.com/noopener").Return(nil).Once()
247
248 factory := mocks.NewMockWebViewFactory(t)
249 factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(popupWV, nil).Once()
250
251 c := &Coordinator{
252 webViews: make(map[entity.PaneID]port.WebView),
253 popups: newPopupManager(),
254 }
255 c.SetPopupConfig(factory, nil, nil)
256 c.SetOnInsertPopup(func(_ context.Context, input InsertPopupInput) error {
257 assert.Equal(t, "https://example.com/noopener", input.TargetURI)
258 return nil
259 })
260
261 created := c.handlePopupCreate(ctx, parentPaneID, parentWV, port.PopupRequest{
262 TargetURI: "https://example.com/noopener",
263 FrameName: "auth-popup",
264 IsUserGesture: true,
265 NoJavaScriptAccess: true,
266 })
267
268 require.Same(t, popupWV, created)
269}
270
271func TestHandlePopupCreate_DeniesWhenRelatedCreateIsUnsupported(t *testing.T) {
272 ctx := context.Background()
273 parentPaneID := entity.PaneID("parent-pane")
274 parentWV := mocks.NewMockWebView(t)
275 parentWV.EXPECT().ID().Return(port.WebViewID(101)).Once()
276
277 factory := mocks.NewMockWebViewFactory(t)
278 factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(nil, domainerrors.ErrRelatedWebViewUnsupported).Once()
279
280 insertCalls := 0
281 c := &Coordinator{
282 webViews: make(map[entity.PaneID]port.WebView),
283 popups: newPopupManager(),
284 }
285 c.SetPopupConfig(factory, nil, nil)
286 c.SetOnInsertPopup(func(_ context.Context, input InsertPopupInput) error {
287 insertCalls++
288 return nil
289 })
290
291 created := c.handlePopupCreate(ctx, parentPaneID, parentWV, port.PopupRequest{
292 TargetURI: "https://example.com/edit",
293 FrameName: "_blank",
294 IsUserGesture: true,
295 })
296
297 require.Nil(t, created)
298 assert.Equal(t, 0, insertCalls)
299}
300
301func TestHandlePopupCreate_DeniesWhenRelatedCreateFailsUnexpectedly(t *testing.T) {
302 ctx := context.Background()
303 parentPaneID := entity.PaneID("parent-pane")
304 parentWV := mocks.NewMockWebView(t)
305 parentWV.EXPECT().ID().Return(port.WebViewID(101)).Once()
306
307 factory := mocks.NewMockWebViewFactory(t)
308 factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(nil, errors.New("boom")).Once()
309
310 insertCalls := 0
311 c := &Coordinator{
312 webViews: make(map[entity.PaneID]port.WebView),
313 popups: newPopupManager(),
314 }
315 c.SetPopupConfig(factory, nil, nil)
316 c.SetOnInsertPopup(func(_ context.Context, input InsertPopupInput) error {
317 insertCalls++
318 return nil
319 })
320
321 created := c.handlePopupCreate(ctx, parentPaneID, parentWV, port.PopupRequest{
322 TargetURI: "https://example.com/edit",
323 FrameName: "_blank",
324 IsUserGesture: true,
325 })
326
327 require.Nil(t, created)
328 assert.Equal(t, 0, insertCalls)
329}
330
331func TestHandleLinkMiddleClick_UsesRelatedWebView(t *testing.T) {
332 ctx := context.Background()
333 parentPaneID := entity.PaneID("parent-pane")
334 parentWV := mocks.NewMockWebView(t)
335 parentWV.EXPECT().ID().Return(port.WebViewID(101)).Twice()
336
337 newWV := &popupNavigationWebViewStub{MockWebView: mocks.NewMockWebView(t)}
338 newWV.EXPECT().ID().Return(port.WebViewID(301)).Maybe()
339 newWV.EXPECT().Generation().Return(uint64(1)).Maybe()
340 newWV.EXPECT().SetCallbacks(mock.Anything).Maybe()
341 newWV.EXPECT().LoadURI(mock.Anything, "https://example.com/middle-click").Return(nil).Maybe()
342
343 factory := mocks.NewMockWebViewFactory(t)
344 factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(newWV, nil).Maybe()
345
346 insertCalls := 0
347 c := &Coordinator{
348 webViews: map[entity.PaneID]port.WebView{parentPaneID: parentWV},
349 popups: newPopupManager(),
350 }
351 c.SetPopupConfig(factory, nil, nil)
352 c.SetOnInsertPopup(func(_ context.Context, input InsertPopupInput) error {
353 insertCalls++
354 assert.Equal(t, parentPaneID, input.ParentPaneID)
355 assert.Same(t, newWV, input.WebView)
356 assert.Equal(t, entity.PopupBehaviorSplit, input.Behavior)
357 return nil
358 })
359
360 handled := c.handleLinkMiddleClick(ctx, parentPaneID, "https://example.com/middle-click")
361 assert.True(t, handled)
362 assert.Equal(t, 1, insertCalls)
363 decision, hasDecision := newWV.BrowsingContextHostDecision()
364 require.True(t, hasDecision)
365 assert.Equal(t, dto.HostDecisionCreatePane, decision.Kind)
366}
367
368func TestHandlePopupCreate_OpensNativePopupForAuthIntent(t *testing.T) {
369 ctx := context.Background()
370 parentPaneID := entity.PaneID("parent-pane")
371 parentWV := mocks.NewMockWebView(t)
372 parentWV.EXPECT().ID().Return(port.WebViewID(101)).Once()
373 parentWV.EXPECT().URI().Return("https://x.com/i/flow/login").Once()
374
375 popupWV := &popupNavigationWebViewStub{MockWebView: mocks.NewMockWebView(t)}
376 popupWV.EXPECT().ID().Return(port.WebViewID(401)).Maybe()
377
378 factory := mocks.NewMockWebViewFactory(t)
379 factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(popupWV, nil).Once()
380
381 insertCalls := 0
382 nativeCalls := 0
383 c := &Coordinator{
384 webViews: make(map[entity.PaneID]port.WebView),
385 popups: newPopupManager(),
386 }
387 c.SetPopupConfig(factory, &entity.BrowsingContextConfig{OpenInNewPane: true, OAuthAutoClose: true}, nil)
388 c.SetOnInsertPopup(func(context.Context, InsertPopupInput) error {
389 insertCalls++
390 return nil
391 })
392 c.SetOnOpenNativePopup(func(_ context.Context, input NativePopupInput) error {
393 nativeCalls++
394 assert.Equal(t, parentPaneID, input.ParentPaneID)
395 assert.Equal(t, popupWV, input.PopupWebView)
396 assert.Equal(t, "https://accounts.google.com/o/oauth2/v2/auth", input.TargetURI)
397 assert.True(t, input.ObserveOAuthAutoClose)
398 return nil
399 })
400
401 created := c.handlePopupCreate(ctx, parentPaneID, parentWV, port.PopupRequest{
402 TargetURI: "https://accounts.google.com/o/oauth2/v2/auth",
403 FrameName: "oauth-popup",
404 IsUserGesture: true,
405 })
406
407 require.Equal(t, popupWV, created)
408 assert.Equal(t, 0, insertCalls)
409 assert.Equal(t, 1, nativeCalls)
410 assert.Equal(t, []string{"https://accounts.google.com/o/oauth2/v2/auth"}, popupWV.primed)
411}
412
413func TestHandlePopupCreate_NativePopupDoesNotForceOAuthObservationWhenDisabled(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)).Once()
418
419 popupWV := &popupNavigationWebViewStub{MockWebView: mocks.NewMockWebView(t)}
420 popupWV.EXPECT().ID().Return(port.WebViewID(402)).Maybe()
421
422 factory := mocks.NewMockWebViewFactory(t)
423 factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(popupWV, nil).Once()
424
425 insertCalls := 0
426 nativeCalls := 0
427 c := &Coordinator{
428 webViews: make(map[entity.PaneID]port.WebView),
429 popups: newPopupManager(),
430 }
431 c.SetPopupConfig(factory, &entity.BrowsingContextConfig{OpenInNewPane: true, OAuthAutoClose: false}, nil)
432 c.SetOnInsertPopup(func(context.Context, InsertPopupInput) error {
433 insertCalls++
434 return nil
435 })
436 c.SetOnOpenNativePopup(func(_ context.Context, input NativePopupInput) error {
437 nativeCalls++
438 assert.False(t, input.ObserveOAuthAutoClose)
439 return nil
440 })
441
442 created := c.handlePopupCreate(ctx, parentPaneID, parentWV, port.PopupRequest{
443 TargetURI: "https://accounts.google.com/o/oauth2/v2/auth",
444 FrameName: "oauth-popup",
445 IsUserGesture: true,
446 })
447
448 require.Equal(t, popupWV, created)
449 assert.Equal(t, 1, nativeCalls)
450 assert.Equal(t, 0, insertCalls)
451}
452
453func TestHandlePopupCreate_DoesNotReuseNamedPopupWhenNoJavaScriptAccess(t *testing.T) {
454 ctx := context.Background()
455 parentPaneID := entity.PaneID("parent-pane")
456 parentWV := mocks.NewMockWebView(t)
457 parentWV.EXPECT().ID().Return(port.WebViewID(101)).Twice()
458
459 firstWV := mocks.NewMockWebView(t)
460 firstWV.EXPECT().ID().Return(port.WebViewID(501)).Maybe()
461 firstWV.EXPECT().Generation().Return(uint64(1)).Maybe()
462 firstWV.EXPECT().SetCallbacks(mock.Anything).Maybe()
463 firstWV.EXPECT().IsLoading().Return(false).Maybe()
464 firstWV.EXPECT().URI().Return("").Maybe()
465 firstWV.EXPECT().LoadURI(mock.Anything, "https://example.com/first").Return(nil).Maybe()
466
467 secondWV := mocks.NewMockWebView(t)
468 secondWV.EXPECT().ID().Return(port.WebViewID(502)).Maybe()
469 secondWV.EXPECT().Generation().Return(uint64(1)).Maybe()
470 secondWV.EXPECT().SetCallbacks(mock.Anything).Maybe()
471 secondWV.EXPECT().IsLoading().Return(false).Maybe()
472 secondWV.EXPECT().URI().Return("").Maybe()
473 secondWV.EXPECT().LoadURI(mock.Anything, "https://example.com/second").Return(nil).Maybe()
474
475 factory := mocks.NewMockWebViewFactory(t)
476 factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(firstWV, nil).Once()
477 factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(secondWV, nil).Once()
478
479 insertCalls := 0
480 c := &Coordinator{
481 webViews: make(map[entity.PaneID]port.WebView),
482 popups: newPopupManager(),
483 }
484 c.SetPopupConfig(factory, nil, nil)
485 c.SetPopupWindowIDResolver(func(entity.PaneID) (string, bool) { return "window-1", true })
486 c.SetOnInsertPopup(func(context.Context, InsertPopupInput) error {
487 insertCalls++
488 return nil
489 })
490
491 first := c.handlePopupCreate(ctx, parentPaneID, parentWV, port.PopupRequest{
492 TargetURI: "https://example.com/first",
493 FrameName: "shared-pane",
494 IsUserGesture: true,
495 NoJavaScriptAccess: true,
496 })
497 second := c.handlePopupCreate(ctx, parentPaneID, parentWV, port.PopupRequest{
498 TargetURI: "https://example.com/second",
499 FrameName: "shared-pane",
500 IsUserGesture: true,
501 NoJavaScriptAccess: true,
502 })
503
504 require.Same(t, firstWV, first)
505 require.Same(t, secondWV, second)
506 assert.Equal(t, 2, insertCalls)
507}
508
509func TestHandlePopupCreate_ReusesNamedPopup(t *testing.T) {
510 ctx := context.Background()
511 parentPaneID := entity.PaneID("parent-pane")
512 parentWV := mocks.NewMockWebView(t)
513 parentWV.EXPECT().ID().Return(port.WebViewID(101)).Twice()
514
515 popupWV := mocks.NewMockWebView(t)
516 popupWV.EXPECT().ID().Return(port.WebViewID(202)).Maybe()
517 popupWV.EXPECT().Generation().Return(uint64(1)).Maybe()
518 popupWV.EXPECT().SetCallbacks(mock.Anything).Maybe()
519 popupWV.EXPECT().IsLoading().Return(false).Maybe()
520 currentURI := ""
521 popupWV.EXPECT().URI().RunAndReturn(func() string { return currentURI }).Maybe()
522 popupWV.EXPECT().IsDestroyed().Return(false).Maybe()
523 popupWV.EXPECT().LoadURI(mock.Anything, "https://example.com/first").RunAndReturn(func(context.Context, string) error {
524 currentURI = "https://example.com/first"
525 return nil
526 }).Once()
527 popupWV.EXPECT().LoadURI(mock.Anything, "https://example.com/second").RunAndReturn(func(context.Context, string) error {
528 currentURI = "https://example.com/second"
529 return nil
530 }).Once()
531
532 factory := mocks.NewMockWebViewFactory(t)
533 factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(popupWV, nil).Once()
534
535 insertCalls := 0
536 c := &Coordinator{
537 webViews: make(map[entity.PaneID]port.WebView),
538 popups: newPopupManager(),
539 }
540 c.SetPopupConfig(factory, nil, nil)
541 c.SetPopupWindowIDResolver(func(entity.PaneID) (string, bool) { return "window-1", true })
542 c.SetOnInsertPopup(func(context.Context, InsertPopupInput) error {
543 insertCalls++
544 return nil
545 })
546
547 first := c.handlePopupCreate(ctx, parentPaneID, parentWV, port.PopupRequest{
548 TargetURI: "https://example.com/first",
549 FrameName: "shared-pane",
550 IsUserGesture: true,
551 })
552 require.Same(t, popupWV, first)
553
554 second := c.handlePopupCreate(ctx, parentPaneID, parentWV, port.PopupRequest{
555 TargetURI: "https://example.com/second",
556 FrameName: "shared-pane",
557 IsUserGesture: true,
558 })
559 require.Same(t, popupWV, second)
560
561 assert.Equal(t, 1, insertCalls)
562 assert.Equal(t, "https://example.com/second", currentURI)
563}
564
565func TestHandlePopupCreate_FallsBackToNewPaneWhenNamedReuseBecomesUnavailable(t *testing.T) {
566 ctx := context.Background()
567 parentPaneID := entity.PaneID("parent-pane")
568 stalePaneID := entity.PaneID("stale-pane")
569 parentWV := mocks.NewMockWebView(t)
570 parentWV.EXPECT().ID().Return(port.WebViewID(101)).Once()
571
572 staleWV := mocks.NewMockWebView(t)
573 staleWV.EXPECT().IsDestroyed().Return(false).Once()
574 staleWV.EXPECT().ID().Return(port.WebViewID(202)).Once()
575 staleWV.EXPECT().IsDestroyed().Return(true).Once()
576
577 newWV := &popupNavigationWebViewStub{MockWebView: mocks.NewMockWebView(t)}
578 newWV.EXPECT().ID().Return(port.WebViewID(303)).Maybe()
579 newWV.EXPECT().Generation().Return(uint64(1)).Maybe()
580 newWV.EXPECT().SetCallbacks(mock.Anything).Maybe()
581
582 factory := mocks.NewMockWebViewFactory(t)
583 factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(newWV, nil).Once()
584
585 insertCalls := 0
586 c := &Coordinator{
587 webViews: map[entity.PaneID]port.WebView{
588 stalePaneID: staleWV,
589 },
590 popups: newPopupManager(),
591 }
592 c.SetPopupConfig(factory, nil, nil)
593 c.SetPopupWindowIDResolver(func(entity.PaneID) (string, bool) { return "window-1", true })
594 c.popups.namedContexts.Register("window-1", "shared-pane", stalePaneID, port.WebViewID(202))
595 c.SetOnInsertPopup(func(context.Context, InsertPopupInput) error {
596 insertCalls++
597 return nil
598 })
599
600 created := c.handlePopupCreate(ctx, parentPaneID, parentWV, port.PopupRequest{
601 TargetURI: "https://example.com/replacement",
602 FrameName: "shared-pane",
603 IsUserGesture: true,
604 })
605
606 require.Same(t, newWV, created)
607 assert.Equal(t, 1, insertCalls)
608 decision, hasDecision := newWV.BrowsingContextHostDecision()
609 require.True(t, hasDecision)
610 assert.Equal(t, dto.HostDecisionCreatePane, decision.Kind)
611 assert.Empty(t, decision.ReuseContextName)
612 assert.Equal(t, "shared-pane", decision.BrowsingContextName)
613}
614
615// ---------------------------------------------------------------------------
616// trackOAuthPopup / capturePopupOAuthState
617// ---------------------------------------------------------------------------
618
619func TestTrackOAuthPopup_StoresState(t *testing.T) {
620 t.Parallel()
621
622 c := &Coordinator{
623 webViews: make(map[entity.PaneID]port.WebView),
624 popups: newPopupManager(),
625 }
626
627 popupID := port.WebViewID(1)
628 parentPaneID := entity.PaneID("parent-pane")
629
630 c.trackOAuthPopup(popupID, parentPaneID, "https://parent.example.com/login")
631
632 c.popups.mu.RLock()
633 state, ok := c.popups.popupOAuth[popupID]
634 c.popups.mu.RUnlock()
635
636 require.True(t, ok, "oauth state should be tracked after trackOAuthPopup")
637 require.NotNil(t, state)
638 assert.Equal(t, parentPaneID, state.ParentPaneID)
639 assert.Equal(t, "https://parent.example.com/login", state.ParentURIAtOpen)
640 assert.False(t, state.Seen, "Seen should be false before capturing callback URI")
641}
642
643func TestCapturePopupOAuthState_SuccessCallback(t *testing.T) {
644 t.Parallel()
645
646 c := &Coordinator{
647 webViews: make(map[entity.PaneID]port.WebView),
648 popups: newPopupManager(),
649 }
650
651 popupID := port.WebViewID(2)
652 c.trackOAuthPopup(popupID, entity.PaneID("parent"), "https://parent.example.com/login")
653 c.capturePopupOAuthState(popupID, "https://app.example.com/callback?code=abc123")
654
655 c.popups.mu.RLock()
656 state := c.popups.popupOAuth[popupID]
657 c.popups.mu.RUnlock()
658
659 require.NotNil(t, state)
660 assert.True(t, state.Seen)
661 assert.True(t, state.Success)
662 assert.False(t, state.Error)
663 assert.Equal(t, "https://app.example.com/callback?code=abc123", state.CallbackURI)
664}
665
666func TestCapturePopupOAuthState_ErrorCallback(t *testing.T) {
667 t.Parallel()
668
669 c := &Coordinator{
670 webViews: make(map[entity.PaneID]port.WebView),
671 popups: newPopupManager(),
672 }
673
674 popupID := port.WebViewID(3)
675 c.trackOAuthPopup(popupID, entity.PaneID("parent"), "https://parent.example.com/login")
676 c.capturePopupOAuthState(popupID, "https://app.example.com/callback?error=access_denied")
677
678 c.popups.mu.RLock()
679 state := c.popups.popupOAuth[popupID]
680 c.popups.mu.RUnlock()
681
682 require.NotNil(t, state)
683 assert.True(t, state.Seen)
684 assert.False(t, state.Success)
685 assert.True(t, state.Error)
686}
687
688func TestCapturePopupOAuthState_UnknownPopupIsNoop(t *testing.T) {
689 t.Parallel()
690
691 c := &Coordinator{
692 webViews: make(map[entity.PaneID]port.WebView),
693 popups: newPopupManager(),
694 }
695
696 // Should not panic when no tracking entry exists.
697 c.capturePopupOAuthState(port.WebViewID(999), "https://example.com/callback?code=x")
698
699 c.popups.mu.RLock()
700 _, ok := c.popups.popupOAuth[port.WebViewID(999)]
701 c.popups.mu.RUnlock()
702
703 assert.False(t, ok)
704}
705
706// ---------------------------------------------------------------------------
707// SetPopupConfig
708// ---------------------------------------------------------------------------
709
710func TestSetPopupConfig_SetsFields(t *testing.T) {
711 t.Parallel()
712
713 c := &Coordinator{}
714
715 factory := &stubFactory{}
716 cfg := &entity.BrowsingContextConfig{Behavior: entity.PopupBehaviorSplit}
717 genID := func() string { return "test-id" }
718
719 c.SetPopupConfig(factory, cfg, genID)
720
721 assert.Equal(t, factory, c.popups.factory)
722 assert.True(t, c.popups.hasPopupConfig)
723 assert.Equal(t, *cfg, c.popups.popupConfig)
724 cfg.Behavior = entity.PopupBehaviorWindowed
725 assert.Equal(t, entity.PopupBehaviorSplit, c.popups.popupConfig.Behavior)
726 assert.NotNil(t, c.popups.generatePaneID)
727 assert.Equal(t, "test-id", c.popups.generatePaneID())
728}
729
730func TestSetPopupConfig_NilConfigAllowed(t *testing.T) {
731 t.Parallel()
732
733 c := &Coordinator{}
734 c.SetPopupConfig(nil, nil, nil)
735
736 assert.Nil(t, c.popups.factory)
737 assert.False(t, c.popups.hasPopupConfig)
738 assert.Equal(t, entity.BrowsingContextConfig{}, c.popups.popupConfig)
739 assert.Nil(t, c.popups.generatePaneID)
740}
741
742func TestUpdatePopupConfig_CopiesLatestValue(t *testing.T) {
743 t.Parallel()
744
745 c := &Coordinator{}
746 initial := &entity.BrowsingContextConfig{OpenInNewPane: true, OAuthAutoClose: false}
747 c.SetPopupConfig(nil, initial, nil)
748
749 c.UpdatePopupConfig(entity.BrowsingContextConfig{OpenInNewPane: false, OAuthAutoClose: true})
750
751 assert.True(t, c.popups.hasPopupConfig)
752 assert.False(t, c.popups.popupConfig.OpenInNewPane)
753 assert.True(t, c.popups.popupConfig.OAuthAutoClose)
754 assert.True(t, initial.OpenInNewPane)
755 assert.False(t, initial.OAuthAutoClose)
756}
757
758type popupNavigationWebViewStub struct {
759 *mocks.MockWebView
760 primed []string
761 browsingContextDecision dto.HostDecision
762 hasBrowsingContextDecision bool
763}
764
765func (s *popupNavigationWebViewStub) PrimePopupNavigation(uri string) {
766 s.primed = append(s.primed, uri)
767}
768
769func (s *popupNavigationWebViewStub) SetBrowsingContextHostDecision(decision dto.HostDecision) {
770 s.browsingContextDecision = decision
771 s.hasBrowsingContextDecision = true
772}
773
774func (s *popupNavigationWebViewStub) BrowsingContextHostDecision() (dto.HostDecision, bool) {
775 return s.browsingContextDecision, s.hasBrowsingContextDecision
776}
777
778func (*popupNavigationWebViewStub) SetOnReadyToShow(func()) {}
779func (*popupNavigationWebViewStub) SetOnClose(func()) {}
780func (*popupNavigationWebViewStub) Show() {}
781
782// stubFactory satisfies port.WebViewFactory without importing the mocks package.
783type stubFactory struct{}
784
785func (s *stubFactory) Create(_ context.Context) (port.WebView, error) {
786 return nil, nil
787}
788
789func (s *stubFactory) CreateRelated(_ context.Context, _ port.WebViewID) (port.WebView, error) {
790 return nil, nil
791}
792
793// ---------------------------------------------------------------------------
794// Concurrent access to pendingPopups
795// ---------------------------------------------------------------------------
796
797func TestPendingPopups_ConcurrentAccess(t *testing.T) {
798 t.Parallel()
799
800 c := &Coordinator{
801 popups: newPopupManager(),
802 }
803
804 const workers = 20
805 var wg sync.WaitGroup
806 wg.Add(workers * 2)
807
808 // Writers
809 for i := 0; i < workers; i++ {
810 go func(id int) {
811 defer wg.Done()
812 popupID := port.WebViewID(id)
813 c.popups.mu.Lock()
814 c.popups.pendingPopups[popupID] = &PendingPopup{
815 FrameName: "_blank",
816 PopupType: PopupTypeTab,
817 }
818 c.popups.mu.Unlock()
819 }(i)
820 }
821
822 // Readers
823 for i := 0; i < workers; i++ {
824 go func(id int) {
825 defer wg.Done()
826 popupID := port.WebViewID(id)
827 c.popups.mu.RLock()
828 _ = c.popups.pendingPopups[popupID]
829 c.popups.mu.RUnlock()
830 }(i)
831 }
832
833 wg.Wait()
834
835 c.popups.mu.RLock()
836 count := len(c.popups.pendingPopups)
837 c.popups.mu.RUnlock()
838
839 assert.Equal(t, workers, count, "each writer uses a unique key, so all inserts should be present")
840}
841
842func TestPendingPopups_ConcurrentDeleteAndRead(t *testing.T) {
843 t.Parallel()
844
845 c := &Coordinator{
846 popups: newPopupManager(),
847 }
848
849 // Pre-populate
850 for i := 0; i < 10; i++ {
851 c.popups.pendingPopups[port.WebViewID(i)] = &PendingPopup{PopupType: PopupTypePopup}
852 }
853
854 var wg sync.WaitGroup
855 wg.Add(20)
856
857 // Deleters
858 for i := 0; i < 10; i++ {
859 go func(id int) {
860 defer wg.Done()
861 c.popups.mu.Lock()
862 delete(c.popups.pendingPopups, port.WebViewID(id))
863 c.popups.mu.Unlock()
864 }(i)
865 }
866
867 // Readers
868 for i := 0; i < 10; i++ {
869 go func(id int) {
870 defer wg.Done()
871 c.popups.mu.RLock()
872 _ = c.popups.pendingPopups[port.WebViewID(id)]
873 c.popups.mu.RUnlock()
874 }(i)
875 }
876
877 wg.Wait()
878
879 c.popups.mu.RLock()
880 defer c.popups.mu.RUnlock()
881 assert.Empty(t, c.popups.pendingPopups, "all preloaded popups should have been deleted")
882}