v0.28.0 3cb3770

popup_test.go RawHistoryFolder

1package content
2
3import (
4	"context"
5	"sync"
6	"testing"
7
8	"github.com/stretchr/testify/assert"
9	"github.com/stretchr/testify/require"
10
11	"github.com/bnema/dumber/internal/application/port"
12	"github.com/bnema/dumber/internal/domain/entity"
13)
14
15// ---------------------------------------------------------------------------
16// DetectPopupType
17// ---------------------------------------------------------------------------
18
19func TestDetectPopupType_BlankIsTab(t *testing.T) {
20	t.Parallel()
21
22	assert.Equal(t, PopupTypeTab, DetectPopupType("_blank"))
23}
24
25func TestDetectPopupType_EmptyIsPopup(t *testing.T) {
26	t.Parallel()
27
28	assert.Equal(t, PopupTypePopup, DetectPopupType(""))
29}
30
31func TestDetectPopupType_NamedFrameIsPopup(t *testing.T) {
32	t.Parallel()
33
34	assert.Equal(t, PopupTypePopup, DetectPopupType("myFrame"))
35}
36
37// ---------------------------------------------------------------------------
38// PopupType.String()
39// ---------------------------------------------------------------------------
40
41func TestPopupTypeString_Tab(t *testing.T) {
42	t.Parallel()
43
44	assert.Equal(t, "tab", PopupTypeTab.String())
45}
46
47func TestPopupTypeString_Popup(t *testing.T) {
48	t.Parallel()
49
50	assert.Equal(t, "popup", PopupTypePopup.String())
51}
52
53func TestPopupTypeString_Unknown(t *testing.T) {
54	t.Parallel()
55
56	assert.Equal(t, "unknown", PopupType(99).String())
57}
58
59// ---------------------------------------------------------------------------
60// GetBehavior
61// ---------------------------------------------------------------------------
62
63func TestGetBehavior_NilConfigReturnsSplit(t *testing.T) {
64	t.Parallel()
65
66	assert.Equal(t, entity.PopupBehaviorSplit, GetBehavior(PopupTypeTab, nil))
67	assert.Equal(t, entity.PopupBehaviorSplit, GetBehavior(PopupTypePopup, nil))
68}
69
70func TestGetBehavior_TabPopup_DefaultConfig(t *testing.T) {
71	t.Parallel()
72
73	// BlankTargetBehavior is empty → falls through to default "stacked"
74	cfg := &entity.PopupBehaviorConfig{}
75	assert.Equal(t, entity.PopupBehaviorStacked, GetBehavior(PopupTypeTab, cfg))
76}
77
78func TestGetBehavior_TabPopup_SplitConfig(t *testing.T) {
79	t.Parallel()
80
81	cfg := &entity.PopupBehaviorConfig{BlankTargetBehavior: "split"}
82	assert.Equal(t, entity.PopupBehaviorSplit, GetBehavior(PopupTypeTab, cfg))
83}
84
85func TestGetBehavior_TabPopup_StackedConfig(t *testing.T) {
86	t.Parallel()
87
88	cfg := &entity.PopupBehaviorConfig{BlankTargetBehavior: "stacked"}
89	assert.Equal(t, entity.PopupBehaviorStacked, GetBehavior(PopupTypeTab, cfg))
90}
91
92func TestGetBehavior_TabPopup_TabbedConfig(t *testing.T) {
93	t.Parallel()
94
95	cfg := &entity.PopupBehaviorConfig{BlankTargetBehavior: "tabbed"}
96	assert.Equal(t, entity.PopupBehaviorTabbed, GetBehavior(PopupTypeTab, cfg))
97}
98
99func TestGetBehavior_JSPopup_UsesBehaviorField(t *testing.T) {
100	t.Parallel()
101
102	cfg := &entity.PopupBehaviorConfig{Behavior: entity.PopupBehaviorWindowed}
103	assert.Equal(t, entity.PopupBehaviorWindowed, GetBehavior(PopupTypePopup, cfg))
104}
105
106func TestGetBehavior_JSPopup_DefaultConfig(t *testing.T) {
107	t.Parallel()
108
109	// Behavior zero-value is empty string; GetBehavior returns it as-is.
110	cfg := &entity.PopupBehaviorConfig{}
111	assert.Equal(t, entity.PopupBehavior(""), GetBehavior(PopupTypePopup, cfg))
112}
113
114func TestGetBehavior_TabPopup_BlocksWhenOpenInNewPaneFalse(t *testing.T) {
115	t.Parallel()
116
117	// GetBehavior itself does not honor OpenInNewPane — that is enforced by
118	// handlePopupCreate. GetBehavior still returns the configured value.
119	cfg := &entity.PopupBehaviorConfig{
120		OpenInNewPane:       false,
121		BlankTargetBehavior: "split",
122	}
123	assert.Equal(t, entity.PopupBehaviorSplit, GetBehavior(PopupTypeTab, cfg))
124}
125
126// ---------------------------------------------------------------------------
127// trackOAuthPopup / capturePopupOAuthState
128// ---------------------------------------------------------------------------
129
130func TestTrackOAuthPopup_StoresState(t *testing.T) {
131	t.Parallel()
132
133	c := &Coordinator{
134		webViews:   make(map[entity.PaneID]port.WebView),
135		popupOAuth: make(map[port.WebViewID]*popupOAuthState),
136	}
137
138	popupID := port.WebViewID(1)
139	parentPaneID := entity.PaneID("parent-pane")
140
141	c.trackOAuthPopup(popupID, parentPaneID)
142
143	c.popupMu.RLock()
144	state, ok := c.popupOAuth[popupID]
145	c.popupMu.RUnlock()
146
147	require.True(t, ok, "oauth state should be tracked after trackOAuthPopup")
148	require.NotNil(t, state)
149	assert.Equal(t, parentPaneID, state.ParentPaneID)
150	assert.False(t, state.Seen, "Seen should be false before capturing callback URI")
151}
152
153func TestCapturePopupOAuthState_SuccessCallback(t *testing.T) {
154	t.Parallel()
155
156	c := &Coordinator{
157		webViews:   make(map[entity.PaneID]port.WebView),
158		popupOAuth: make(map[port.WebViewID]*popupOAuthState),
159	}
160
161	popupID := port.WebViewID(2)
162	c.trackOAuthPopup(popupID, entity.PaneID("parent"))
163	c.capturePopupOAuthState(popupID, "https://app.example.com/callback?code=abc123")
164
165	c.popupMu.RLock()
166	state := c.popupOAuth[popupID]
167	c.popupMu.RUnlock()
168
169	require.NotNil(t, state)
170	assert.True(t, state.Seen)
171	assert.True(t, state.Success)
172	assert.False(t, state.Error)
173	assert.Equal(t, "https://app.example.com/callback?code=abc123", state.CallbackURI)
174}
175
176func TestCapturePopupOAuthState_ErrorCallback(t *testing.T) {
177	t.Parallel()
178
179	c := &Coordinator{
180		webViews:   make(map[entity.PaneID]port.WebView),
181		popupOAuth: make(map[port.WebViewID]*popupOAuthState),
182	}
183
184	popupID := port.WebViewID(3)
185	c.trackOAuthPopup(popupID, entity.PaneID("parent"))
186	c.capturePopupOAuthState(popupID, "https://app.example.com/callback?error=access_denied")
187
188	c.popupMu.RLock()
189	state := c.popupOAuth[popupID]
190	c.popupMu.RUnlock()
191
192	require.NotNil(t, state)
193	assert.True(t, state.Seen)
194	assert.False(t, state.Success)
195	assert.True(t, state.Error)
196}
197
198func TestCapturePopupOAuthState_UnknownPopupIsNoop(t *testing.T) {
199	t.Parallel()
200
201	c := &Coordinator{
202		webViews:   make(map[entity.PaneID]port.WebView),
203		popupOAuth: make(map[port.WebViewID]*popupOAuthState),
204	}
205
206	// Should not panic when no tracking entry exists.
207	c.capturePopupOAuthState(port.WebViewID(999), "https://example.com/callback?code=x")
208
209	c.popupMu.RLock()
210	_, ok := c.popupOAuth[port.WebViewID(999)]
211	c.popupMu.RUnlock()
212
213	assert.False(t, ok)
214}
215
216// ---------------------------------------------------------------------------
217// SetPopupConfig
218// ---------------------------------------------------------------------------
219
220func TestSetPopupConfig_SetsFields(t *testing.T) {
221	t.Parallel()
222
223	c := &Coordinator{}
224
225	factory := &stubFactory{}
226	cfg := &entity.PopupBehaviorConfig{Behavior: entity.PopupBehaviorSplit}
227	genID := func() string { return "test-id" }
228
229	c.SetPopupConfig(factory, cfg, genID)
230
231	assert.Equal(t, factory, c.factory)
232	assert.Equal(t, cfg, c.popupConfig)
233	assert.NotNil(t, c.generateID)
234	assert.Equal(t, "test-id", c.generateID())
235}
236
237func TestSetPopupConfig_NilConfigAllowed(t *testing.T) {
238	t.Parallel()
239
240	c := &Coordinator{}
241	c.SetPopupConfig(nil, nil, nil)
242
243	assert.Nil(t, c.factory)
244	assert.Nil(t, c.popupConfig)
245	assert.Nil(t, c.generateID)
246}
247
248// stubFactory satisfies port.WebViewFactory without importing the mocks package.
249type stubFactory struct{}
250
251func (s *stubFactory) Create(_ context.Context) (port.WebView, error) {
252	return nil, nil
253}
254
255func (s *stubFactory) CreateRelated(_ context.Context, _ port.WebViewID) (port.WebView, error) {
256	return nil, nil
257}
258
259// ---------------------------------------------------------------------------
260// Concurrent access to pendingPopups
261// ---------------------------------------------------------------------------
262
263func TestPendingPopups_ConcurrentAccess(t *testing.T) {
264	t.Parallel()
265
266	c := &Coordinator{
267		pendingPopups: make(map[port.WebViewID]*PendingPopup),
268	}
269
270	const workers = 20
271	var wg sync.WaitGroup
272	wg.Add(workers * 2)
273
274	// Writers
275	for i := 0; i < workers; i++ {
276		go func(id int) {
277			defer wg.Done()
278			popupID := port.WebViewID(id)
279			c.popupMu.Lock()
280			c.pendingPopups[popupID] = &PendingPopup{
281				FrameName: "_blank",
282				PopupType: PopupTypeTab,
283			}
284			c.popupMu.Unlock()
285		}(i)
286	}
287
288	// Readers
289	for i := 0; i < workers; i++ {
290		go func(id int) {
291			defer wg.Done()
292			popupID := port.WebViewID(id)
293			c.popupMu.RLock()
294			_ = c.pendingPopups[popupID]
295			c.popupMu.RUnlock()
296		}(i)
297	}
298
299	wg.Wait()
300
301	c.popupMu.RLock()
302	count := len(c.pendingPopups)
303	c.popupMu.RUnlock()
304
305	assert.Equal(t, workers, count, "each writer uses a unique key, so all inserts should be present")
306}
307
308func TestPendingPopups_ConcurrentDeleteAndRead(t *testing.T) {
309	t.Parallel()
310
311	c := &Coordinator{
312		pendingPopups: make(map[port.WebViewID]*PendingPopup),
313	}
314
315	// Pre-populate
316	for i := 0; i < 10; i++ {
317		c.pendingPopups[port.WebViewID(i)] = &PendingPopup{PopupType: PopupTypePopup}
318	}
319
320	var wg sync.WaitGroup
321	wg.Add(20)
322
323	// Deleters
324	for i := 0; i < 10; i++ {
325		go func(id int) {
326			defer wg.Done()
327			c.popupMu.Lock()
328			delete(c.pendingPopups, port.WebViewID(id))
329			c.popupMu.Unlock()
330		}(i)
331	}
332
333	// Readers
334	for i := 0; i < 10; i++ {
335		go func(id int) {
336			defer wg.Done()
337			c.popupMu.RLock()
338			_ = c.pendingPopups[port.WebViewID(id)]
339			c.popupMu.RUnlock()
340		}(i)
341	}
342
343	wg.Wait()
344
345	c.popupMu.RLock()
346	defer c.popupMu.RUnlock()
347	assert.Empty(t, c.pendingPopups, "all preloaded popups should have been deleted")
348}

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