v0.30.0 eb3068f

popup_test.go RawHistoryFolder

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}

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