v0.31.0 bf0756b

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

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