main 5041353

popup_lifecycle_test.go RawHistoryFolder

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		assert.True(t, input.AlreadyReadyToShow, "deferred popups have already fired ready-to-show")
362		return nil
363	})
364
365	require.Same(t, popup, c.handlePopupCreate(ctx, "floating", parent, port.PopupRequest{
366		Engine: dto.BrowserEngineWebKit, TargetURI: "https://example.com/open", FrameName: "_blank",
367		TargetDisposition: dto.WindowDispositionNewPopup, PopupFeatures: dto.PopupFeatures{State: dto.PopupFeaturesUnknown},
368	}))
369	popup.ready()
370	decision, ok := popup.BrowsingContextHostDecision()
371	require.True(t, ok)
372	assert.Equal(t, dto.HostDecisionCreateNativePopup, decision.Kind)
373}
374
375func TestPopupDeferredNamedReuseDestroysNewlyStagedWebViewExactlyOnce(t *testing.T) {
376	ctx := context.Background()
377	parentPaneID := entity.PaneID("floating")
378	existingPaneID := entity.PaneID("existing")
379	parent := mocks.NewMockWebView(t)
380	parent.EXPECT().ID().Return(port.WebViewID(101)).Once()
381	staged := &deferredPopupWebViewStub{MockWebView: mocks.NewMockWebView(t), features: dto.PopupFeatures{State: dto.PopupFeaturesNone}}
382	staged.EXPECT().ID().Return(port.WebViewID(205)).Maybe()
383	staged.EXPECT().Destroy().Once()
384	existing := mocks.NewMockWebView(t)
385	existing.EXPECT().ID().Return(port.WebViewID(301)).Times(3)
386	existing.EXPECT().IsDestroyed().Return(false).Twice()
387	existing.EXPECT().LoadURI(mock.Anything, "https://example.com/reused").Return(nil).Once()
388	factory := mocks.NewMockWebViewFactory(t)
389	factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(staged, nil).Once()
390	staging := &popupStagingHostStub{}
391	c := &Coordinator{webViews: make(map[entity.PaneID]port.WebView), popups: newPopupManager()}
392	c.SetPopupConfig(factory, &entity.BrowsingContextConfig{OpenInNewPane: false}, nil)
393	c.SetPopupSourceHostResolver(func(entity.PaneID) dto.SourceHostKind { return dto.SourceHostFloating })
394	c.SetPopupWindowIDResolver(func(paneID entity.PaneID) (string, bool) {
395		windowByPane := map[entity.PaneID]string{parentPaneID: "owner", existingPaneID: "host"}
396		windowID, ok := windowByPane[paneID]
397		return windowID, ok
398	})
399	c.SetOnStagePopup(func(context.Context, StagePopupInput) (PopupStagingHost, error) { return staging, nil })
400
401	require.Same(t, staged, c.handlePopupCreate(ctx, parentPaneID, parent, port.PopupRequest{
402		Engine: dto.BrowserEngineWebKit, TargetURI: "https://example.com/reused", FrameName: "shared",
403		PopupFeatures: dto.PopupFeatures{State: dto.PopupFeaturesUnknown},
404	}))
405	c.RegisterPopupWebView(existingPaneID, existing)
406	c.popups.namedContexts.Register("owner", "host", "shared", existingPaneID, port.WebViewID(301))
407	staged.ready()
408	staged.ready()
409
410	assert.True(t, staging.detached)
411	assert.Equal(t, 1, staging.destroyCalls)
412}
413
414func TestPopupDeferredNamedReuseRaceLogsHostUnavailableAndCleansStagingExactlyOnce(t *testing.T) {
415	var output bytes.Buffer
416	logger := zerolog.New(&output).Level(zerolog.WarnLevel)
417	ctx := logging.WithContext(context.Background(), logger)
418	parentPaneID := entity.PaneID("floating")
419	existingPaneID := entity.PaneID("existing")
420	parent := mocks.NewMockWebView(t)
421	parent.EXPECT().ID().Return(port.WebViewID(101)).Once()
422	staged := &deferredPopupWebViewStub{MockWebView: mocks.NewMockWebView(t), features: dto.PopupFeatures{State: dto.PopupFeaturesNone}}
423	staged.EXPECT().ID().Return(port.WebViewID(208)).Maybe()
424	staged.EXPECT().Destroy().Once()
425	existing := mocks.NewMockWebView(t)
426	destroyedChecks := 0
427	existing.EXPECT().IsDestroyed().RunAndReturn(func() bool {
428		destroyedChecks++
429		return destroyedChecks > 1
430	}).Twice()
431	existing.EXPECT().ID().Return(port.WebViewID(301)).Once()
432	factory := mocks.NewMockWebViewFactory(t)
433	factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(staged, nil).Once()
434	staging := &popupStagingHostStub{}
435	c := &Coordinator{webViews: make(map[entity.PaneID]port.WebView), popups: newPopupManager()}
436	c.SetPopupConfig(factory, &entity.BrowsingContextConfig{OpenInNewPane: false}, nil)
437	c.SetPopupSourceHostResolver(func(entity.PaneID) dto.SourceHostKind { return dto.SourceHostFloating })
438	c.SetPopupWindowIDResolver(func(paneID entity.PaneID) (string, bool) {
439		windowByPane := map[entity.PaneID]string{parentPaneID: "owner", existingPaneID: "host"}
440		windowID, ok := windowByPane[paneID]
441		return windowID, ok
442	})
443	c.SetOnStagePopup(func(context.Context, StagePopupInput) (PopupStagingHost, error) { return staging, nil })
444
445	require.Same(t, staged, c.handlePopupCreate(ctx, parentPaneID, parent, port.PopupRequest{
446		Engine: dto.BrowserEngineWebKit, TargetURI: "https://example.com/reused", FrameName: "shared",
447		PopupFeatures: dto.PopupFeatures{State: dto.PopupFeaturesUnknown},
448	}))
449	c.RegisterPopupWebView(existingPaneID, existing)
450	c.popups.namedContexts.Register("owner", "host", "shared", existingPaneID, port.WebViewID(301))
451	staged.ready()
452	staged.ready()
453
454	assert.Equal(t, 2, destroyedChecks)
455	assert.Equal(t, 1, staging.destroyCalls)
456	record := decodeBrowsingContextLog(t, output.Bytes())
457	assert.Equal(t, "host-unavailable", record["reason_code"])
458}
459
460func TestPopupDeferredNativeAbortWithoutFallbackLogsStructuredFailure(t *testing.T) {
461	var output bytes.Buffer
462	logger := zerolog.New(&output).Level(zerolog.WarnLevel)
463	ctx := logging.WithContext(context.Background(), logger)
464	parent := mocks.NewMockWebView(t)
465	parent.EXPECT().ID().Return(port.WebViewID(101)).Once()
466	popup := &deferredPopupWebViewStub{MockWebView: mocks.NewMockWebView(t), features: dto.PopupFeatures{State: dto.PopupFeaturesSpecified, Width: 640, WidthSet: true}}
467	popup.EXPECT().ID().Return(port.WebViewID(206)).Maybe()
468	popup.EXPECT().Destroy().Once()
469	factory := mocks.NewMockWebViewFactory(t)
470	factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(popup, nil).Once()
471	c := &Coordinator{popups: newPopupManager()}
472	c.SetPopupConfig(factory, &entity.BrowsingContextConfig{OpenInNewPane: false}, nil)
473	c.SetPopupSourceHostResolver(func(entity.PaneID) dto.SourceHostKind { return dto.SourceHostFloating })
474	c.SetOnStagePopup(func(context.Context, StagePopupInput) (PopupStagingHost, error) { return &popupStagingHostStub{}, nil })
475	c.SetOnOpenNativePopup(func(abortCtx context.Context, input NativePopupInput) error {
476		assert.False(t, input.AllowBrowserWindowFallback)
477		if !input.OnNativeHostAbort(abortCtx, input.PopupWebView) {
478			input.PopupWebView.Destroy()
479		}
480		return nil
481	})
482
483	require.Same(t, popup, c.handlePopupCreate(ctx, "floating", parent, port.PopupRequest{
484		Engine: dto.BrowserEngineWebKit, TargetURI: "https://example.com/visual", TargetDisposition: dto.WindowDispositionNewPopup,
485		PopupFeatures: dto.PopupFeatures{State: dto.PopupFeaturesUnknown},
486	}))
487	popup.ready()
488
489	record := decodeBrowsingContextLog(t, output.Bytes())
490	assert.Equal(t, "webkit", record["engine"])
491	assert.Equal(t, "floating", record["source_host"])
492	assert.Equal(t, "create-native-popup", record["decision"])
493	assert.Equal(t, "new-popup", record["target_disposition"])
494	assert.Equal(t, "native-arm-failed", record["reason_code"])
495}
496
497func TestPopupDeferredBrowserHostFailureCleansUpExactlyOnce(t *testing.T) {
498	parent := mocks.NewMockWebView(t)
499	parent.EXPECT().ID().Return(port.WebViewID(101)).Once()
500	popup := &deferredPopupWebViewStub{MockWebView: mocks.NewMockWebView(t), features: dto.PopupFeatures{State: dto.PopupFeaturesNone}}
501	popup.EXPECT().ID().Return(port.WebViewID(204)).Maybe()
502	popup.EXPECT().Generation().Return(uint64(1)).Maybe()
503	popup.EXPECT().SetCallbacks(mock.Anything).Once()
504	popup.EXPECT().Destroy().Once()
505	factory := mocks.NewMockWebViewFactory(t)
506	factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(popup, nil).Once()
507	staging := &popupStagingHostStub{}
508	c := &Coordinator{popups: newPopupManager()}
509	c.SetPopupConfig(factory, &entity.BrowsingContextConfig{OpenInNewPane: false}, nil)
510	c.SetPopupSourceHostResolver(func(entity.PaneID) dto.SourceHostKind { return dto.SourceHostFloating })
511	c.SetOnStagePopup(func(context.Context, StagePopupInput) (PopupStagingHost, error) { return staging, nil })
512	c.SetOnOpenBrowserWindow(func(context.Context, BrowserWindowInput) (BrowserWindowResult, error) {
513		return BrowserWindowResult{}, errors.New("host failed")
514	})
515
516	require.Same(t, popup, c.handlePopupCreate(context.Background(), "floating", parent, port.PopupRequest{
517		Engine: dto.BrowserEngineWebKit, TargetURI: "https://example.com/open", FrameName: "shared",
518		TargetDisposition: dto.WindowDispositionNewPopup, PopupFeatures: dto.PopupFeatures{State: dto.PopupFeaturesUnknown},
519	}))
520	popup.ready()
521	popup.ready()
522	assert.True(t, staging.detached)
523	assert.Equal(t, 1, staging.destroyCalls)
524}
525
526func TestPopupDeferredStagingDetachFailureCleansUpExactlyOnce(t *testing.T) {
527	parent := mocks.NewMockWebView(t)
528	parent.EXPECT().ID().Return(port.WebViewID(101)).Once()
529	popup := &deferredPopupWebViewStub{MockWebView: mocks.NewMockWebView(t), features: dto.PopupFeatures{State: dto.PopupFeaturesNone}}
530	popup.EXPECT().ID().Return(port.WebViewID(209)).Maybe()
531	popup.EXPECT().Destroy().Once()
532	factory := mocks.NewMockWebViewFactory(t)
533	factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(popup, nil).Once()
534	staging := &popupStagingHostStub{detachErr: errors.New("detach failed")}
535	c := &Coordinator{popups: newPopupManager()}
536	c.SetPopupConfig(factory, &entity.BrowsingContextConfig{OpenInNewPane: false}, nil)
537	c.SetPopupSourceHostResolver(func(entity.PaneID) dto.SourceHostKind { return dto.SourceHostFloating })
538	c.SetOnStagePopup(func(context.Context, StagePopupInput) (PopupStagingHost, error) { return staging, nil })
539
540	require.Same(t, popup, c.handlePopupCreate(context.Background(), "floating", parent, port.PopupRequest{
541		Engine: dto.BrowserEngineWebKit, TargetURI: "https://example.com/open",
542		PopupFeatures: dto.PopupFeatures{State: dto.PopupFeaturesUnknown},
543	}))
544	popup.ready()
545	popup.ready()
546
547	assert.True(t, staging.detached)
548	assert.Equal(t, 1, staging.destroyCalls)
549}
550
551func TestPopupDeferredStagingFailureDestroysCallerOwnedWebViewOnce(t *testing.T) {
552	parent := mocks.NewMockWebView(t)
553	parent.EXPECT().ID().Return(port.WebViewID(101)).Once()
554	popup := &deferredPopupWebViewStub{MockWebView: mocks.NewMockWebView(t)}
555	popup.EXPECT().Destroy().Once()
556	factory := mocks.NewMockWebViewFactory(t)
557	factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(popup, nil).Once()
558	c := &Coordinator{popups: newPopupManager()}
559	c.SetPopupConfig(factory, &entity.BrowsingContextConfig{OpenInNewPane: false}, nil)
560	c.SetPopupSourceHostResolver(func(entity.PaneID) dto.SourceHostKind { return dto.SourceHostFloating })
561	c.SetOnStagePopup(func(context.Context, StagePopupInput) (PopupStagingHost, error) {
562		return nil, errors.New("stage failed")
563	})
564
565	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}}))
566}
567
568func TestPopupDeferredExplicitTeardownCleansUpWithoutLifecycleSignals(t *testing.T) {
569	for _, tc := range []struct {
570		name    string
571		prepare func(*Coordinator)
572		trigger func(*Coordinator)
573	}{
574		{name: "parent release", trigger: func(c *Coordinator) { c.ReleaseWebView(context.Background(), "floating") }},
575		{name: "owner window teardown", prepare: func(c *Coordinator) {
576			c.SetPopupWindowIDResolver(func(entity.PaneID) (string, bool) { return "owner", true })
577		}, trigger: func(c *Coordinator) { c.ClearPopupNamedContextsForWindow("owner") }},
578		{name: "coordinator shutdown", trigger: func(c *Coordinator) { c.ShutdownPopups() }},
579	} {
580		t.Run(tc.name, func(t *testing.T) {
581			parent := mocks.NewMockWebView(t)
582			parent.EXPECT().ID().Return(port.WebViewID(101)).Once()
583			popup := &deferredPopupWebViewStub{MockWebView: mocks.NewMockWebView(t), features: dto.PopupFeatures{State: dto.PopupFeaturesNone}}
584			popup.EXPECT().ID().Return(port.WebViewID(207)).Maybe()
585			popup.EXPECT().Destroy().Once()
586			factory := mocks.NewMockWebViewFactory(t)
587			factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(popup, nil).Once()
588			staging := &popupStagingHostStub{}
589			c := &Coordinator{popups: newPopupManager()}
590			c.SetPopupConfig(factory, &entity.BrowsingContextConfig{OpenInNewPane: false}, nil)
591			c.SetPopupSourceHostResolver(func(entity.PaneID) dto.SourceHostKind { return dto.SourceHostFloating })
592			c.SetOnStagePopup(func(context.Context, StagePopupInput) (PopupStagingHost, error) { return staging, nil })
593			if tc.prepare != nil {
594				tc.prepare(c)
595			}
596
597			require.Same(t, popup, c.handlePopupCreate(context.Background(), "floating", parent, port.PopupRequest{
598				Engine: dto.BrowserEngineWebKit, TargetURI: "https://example.com/open",
599				PopupFeatures: dto.PopupFeatures{State: dto.PopupFeaturesUnknown},
600			}))
601			require.Len(t, c.popups.deferredPopups, 1)
602			tc.trigger(c)
603			tc.trigger(c)
604
605			assert.Empty(t, c.popups.deferredPopups)
606			assert.Empty(t, c.popups.pendingPopups)
607			assert.Equal(t, 1, staging.destroyCalls)
608			assert.Nil(t, popup.onReady)
609			assert.Nil(t, popup.onClose)
610		})
611	}
612}
613
614func TestPopupDeferredUnknownAndCloseCleanupExactlyOnce(t *testing.T) {
615	for _, tc := range []struct {
616		name  string
617		close bool
618	}{{name: "unknown at ready"}, {name: "close before ready", close: true}} {
619		t.Run(tc.name, func(t *testing.T) {
620			parent := mocks.NewMockWebView(t)
621			parent.EXPECT().ID().Return(port.WebViewID(101)).Once()
622			popup := &deferredPopupWebViewStub{MockWebView: mocks.NewMockWebView(t), features: dto.PopupFeatures{State: dto.PopupFeaturesUnknown}}
623			popup.EXPECT().ID().Return(port.WebViewID(203)).Maybe()
624			popup.EXPECT().Destroy().Once()
625			factory := mocks.NewMockWebViewFactory(t)
626			factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(popup, nil).Once()
627			staging := &popupStagingHostStub{}
628			c := &Coordinator{popups: newPopupManager()}
629			c.SetPopupConfig(factory, &entity.BrowsingContextConfig{OpenInNewPane: false}, nil)
630			c.SetPopupSourceHostResolver(func(entity.PaneID) dto.SourceHostKind { return dto.SourceHostFloating })
631			c.SetOnStagePopup(func(context.Context, StagePopupInput) (PopupStagingHost, error) { return staging, nil })
632			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}}))
633			if tc.close {
634				popup.close()
635				popup.close()
636			} else {
637				popup.ready()
638				popup.ready()
639			}
640			assert.Equal(t, 1, staging.destroyCalls)
641		})
642	}
643}
644
645// deferredPopupWebViewStub exposes the optional late-feature and lifecycle ports.
646type deferredPopupWebViewStub struct {
647	*mocks.MockWebView
648	features                   dto.PopupFeatures
649	onReady                    func()
650	onClose                    func()
651	browsingContextDecision    dto.HostDecision
652	hasBrowsingContextDecision bool
653}
654
655func (s *deferredPopupWebViewStub) ResolvePopupFeatures() dto.PopupFeatures { return s.features }
656func (s *deferredPopupWebViewStub) PrimePopupNavigation(string)             {}
657func (s *deferredPopupWebViewStub) SetOnReadyToShow(fn func())              { s.onReady = fn }
658func (s *deferredPopupWebViewStub) SetOnClose(fn func())                    { s.onClose = fn }
659func (s *deferredPopupWebViewStub) Show()                                   {}
660func (s *deferredPopupWebViewStub) SetBrowsingContextHostDecision(decision dto.HostDecision) {
661	s.browsingContextDecision = decision
662	s.hasBrowsingContextDecision = true
663}
664func (s *deferredPopupWebViewStub) BrowsingContextHostDecision() (dto.HostDecision, bool) {
665	return s.browsingContextDecision, s.hasBrowsingContextDecision
666}
667func (s *deferredPopupWebViewStub) ready() {
668	if s.onReady != nil {
669		s.onReady()
670	}
671}
672func (s *deferredPopupWebViewStub) close() {
673	if s.onClose != nil {
674		s.onClose()
675	}
676}
677
678type popupStagingHostStub struct {
679	attached, detached bool
680	destroyCalls       int
681	detachErr          error
682}
683
684func (s *popupStagingHostStub) Detach() error { s.detached = true; return s.detachErr }
685func (s *popupStagingHostStub) Destroy()      { s.destroyCalls++ }
686
687// ---------------------------------------------------------------------------
688// Popup config concurrency / aborted popup cleanup
689// ---------------------------------------------------------------------------
690
691func TestPopupConfig_ConcurrentUpdateAndRead(t *testing.T) {
692	t.Parallel()
693
694	pm := newPopupManager()
695	var wg sync.WaitGroup
696	for i := range 20 {
697		wg.Add(3)
698		go func() {
699			defer wg.Done()
700			pm.updatePopupConfig(entity.BrowsingContextConfig{OpenInNewPane: i%2 == 0})
701		}()
702		go func() {
703			defer wg.Done()
704			pm.setPopupConfig(&entity.BrowsingContextConfig{OAuthAutoClose: i%2 == 0})
705		}()
706		go func() {
707			defer wg.Done()
708			if cfg := pm.currentPopupConfig(); cfg != nil {
709				_ = cfg.OpenInNewPane
710			}
711		}()
712	}
713	wg.Wait()
714}
715
716func TestCurrentPopupConfig_ReturnsSnapshot(t *testing.T) {
717	t.Parallel()
718
719	pm := newPopupManager()
720	pm.updatePopupConfig(entity.BrowsingContextConfig{OpenInNewPane: true})
721	snapshot := pm.currentPopupConfig()
722	require.NotNil(t, snapshot)
723	snapshot.OpenInNewPane = false
724
725	require.True(t, pm.currentPopupConfig().OpenInNewPane)
726}
727
728func TestPopupClose_AbortedPendingPopupCleansPendingOAuthAndPane(t *testing.T) {
729	ctx := context.Background()
730	parent := mocks.NewMockWebView(t)
731	parent.EXPECT().ID().Return(port.WebViewID(101)).Once()
732	popup := &closableLifecyclePopupStub{popupNavigationWebViewStub: &popupNavigationWebViewStub{MockWebView: mocks.NewMockWebView(t)}}
733	popup.EXPECT().ID().Return(port.WebViewID(260)).Maybe()
734	popup.EXPECT().URI().Return("").Maybe()
735	popup.EXPECT().IsLoading().Return(false).Maybe()
736	popup.EXPECT().Generation().Return(uint64(1)).Maybe()
737	popup.EXPECT().SetCallbacks(mock.Anything).Once()
738	popup.EXPECT().Destroy().Maybe()
739	factory := mocks.NewMockWebViewFactory(t)
740	factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(popup, nil).Once()
741
742	c := &Coordinator{webViews: make(map[entity.PaneID]port.WebView), popups: newPopupManager()}
743	c.SetPopupConfig(factory, &entity.BrowsingContextConfig{OpenInNewPane: true, OAuthAutoClose: true}, func() string { return "popup-pane" })
744	var closedPanes []entity.PaneID
745	c.SetOnInsertPopup(func(context.Context, InsertPopupInput) error { return nil })
746	c.SetOnClosePane(func(_ context.Context, paneID entity.PaneID) error {
747		closedPanes = append(closedPanes, paneID)
748		return nil
749	})
750
751	created := c.handlePopupCreate(ctx, "parent-pane", parent, port.PopupRequest{
752		TargetURI: "https://example.com/popup", FrameName: "popup", IsUserGesture: true,
753	})
754	require.Same(t, popup, created)
755	require.Len(t, c.popups.pendingPopups, 1)
756	// OAuth tracking is normally armed for auth-intent popups; seed it so the
757	// cleanup contract is covered regardless of the policy's current routing.
758	c.popups.trackOAuthPopup(260, "parent-pane", "https://app.example.com/login")
759	require.Len(t, c.popups.popupOAuth, 1)
760	require.NotNil(t, c.getWebViewLocked("popup-pane"))
761	require.NotNil(t, popup.onClose)
762
763	// CEF aborts the popup before ready-to-show: the engine fires the lifecycle
764	// close callback exactly as it does for a destroyed shell.
765	popup.close()
766
767	assert.Empty(t, c.popups.pendingPopups)
768	assert.Empty(t, c.popups.popupOAuth)
769	assert.Equal(t, []entity.PaneID{"popup-pane"}, closedPanes)
770	assert.Nil(t, c.getWebViewLocked("popup-pane"))
771}
772
773// closableLifecyclePopupStub records the lifecycle close callback the content
774// layer installs so tests can simulate the engine tearing the shell down.
775type closableLifecyclePopupStub struct {
776	*popupNavigationWebViewStub
777	onClose func()
778}
779
780func (s *closableLifecyclePopupStub) SetOnClose(fn func()) { s.onClose = fn }
781func (s *closableLifecyclePopupStub) close() {
782	if s.onClose != nil {
783		s.onClose()
784	}
785}

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