v0.30.0 eb3068f

lifecycle_test.go RawHistoryFolder

1package content
2
3import (
4	"context"
5	"errors"
6	"testing"
7
8	"github.com/stretchr/testify/assert"
9	"github.com/stretchr/testify/mock"
10	"github.com/stretchr/testify/require"
11
12	"github.com/bnema/dumber/internal/application/port"
13	"github.com/bnema/dumber/internal/application/port/mocks"
14	"github.com/bnema/dumber/internal/domain/entity"
15	"github.com/bnema/dumber/internal/ui/component"
16	"github.com/bnema/dumber/internal/ui/layout"
17	layoutmocks "github.com/bnema/dumber/internal/ui/layout/mocks"
18)
19
20// newMinimalCoordinator returns a Coordinator with the maps required by
21// ReleaseWebView initialized so that calls to clearPendingAppearance,
22// titleMu / navOriginMu locks do not panic.
23func newMinimalCoordinator() *Coordinator {
24	return &Coordinator{
25		webViews:   make(map[entity.PaneID]port.WebView),
26		paneTitles: make(map[entity.PaneID]string),
27		navOrigins: make(map[entity.PaneID]string),
28	}
29}
30
31// ---------------------------------------------------------------------------
32// EnsureWebView
33// ---------------------------------------------------------------------------
34
35func TestLifecycle_EnsureWebView_ReusesExistingNonDestroyed(t *testing.T) {
36	t.Parallel()
37
38	wv := mocks.NewMockWebView(t)
39	wv.EXPECT().IsDestroyed().Return(false)
40	wv.EXPECT().ID().Return(port.WebViewID(101)).Once()
41
42	pool := mocks.NewMockWebViewPool(t)
43	// pool.Acquire must NOT be called
44
45	c := newMinimalCoordinator()
46	c.pool = pool
47	c.webViews[entity.PaneID("pane-1")] = wv
48
49	got, err := c.EnsureWebView(context.Background(), "pane-1")
50
51	require.NoError(t, err)
52	assert.Equal(t, wv, got)
53}
54
55func TestLifecycle_EnsureWebView_AcquiresFromPoolWhenNoneExists(t *testing.T) {
56	t.Parallel()
57
58	newWV := mocks.NewMockWebView(t)
59	newWV.EXPECT().SetCallbacks(mock.Anything).Maybe()
60	newWV.EXPECT().Generation().Return(uint64(0)).Maybe()
61	newWV.EXPECT().ID().Return(port.WebViewID(102)).Once()
62
63	pool := mocks.NewMockWebViewPool(t)
64	pool.EXPECT().Acquire(mock.Anything).Return(newWV, nil)
65
66	c := newMinimalCoordinator()
67	c.pool = pool
68
69	got, err := c.EnsureWebView(context.Background(), "pane-1")
70
71	require.NoError(t, err)
72	assert.Equal(t, newWV, got)
73	assert.Equal(t, newWV, c.webViews[entity.PaneID("pane-1")])
74}
75
76func TestLifecycle_EnsureWebView_AcquiresFromPoolWhenExistingIsDestroyed(t *testing.T) {
77	t.Parallel()
78
79	oldWV := mocks.NewMockWebView(t)
80	oldWV.EXPECT().IsDestroyed().Return(true)
81
82	newWV := mocks.NewMockWebView(t)
83	newWV.EXPECT().SetCallbacks(mock.Anything).Maybe()
84	newWV.EXPECT().Generation().Return(uint64(0)).Maybe()
85	newWV.EXPECT().ID().Return(port.WebViewID(103)).Once()
86
87	pool := mocks.NewMockWebViewPool(t)
88	pool.EXPECT().Acquire(mock.Anything).Return(newWV, nil)
89
90	c := newMinimalCoordinator()
91	c.pool = pool
92	c.webViews[entity.PaneID("pane-1")] = oldWV
93
94	got, err := c.EnsureWebView(context.Background(), "pane-1")
95
96	require.NoError(t, err)
97	assert.Equal(t, newWV, got)
98}
99
100func TestLifecycle_EnsureWebView_ErrorWhenPoolIsNil(t *testing.T) {
101	t.Parallel()
102
103	c := newMinimalCoordinator()
104	// pool remains nil
105
106	_, err := c.EnsureWebView(context.Background(), "pane-1")
107
108	require.Error(t, err)
109	assert.Contains(t, err.Error(), "webview pool not configured")
110}
111
112func TestLifecycle_EnsureWebView_ErrorWhenAcquireFails(t *testing.T) {
113	t.Parallel()
114
115	acquireErr := errors.New("pool exhausted")
116
117	pool := mocks.NewMockWebViewPool(t)
118	pool.EXPECT().Acquire(mock.Anything).Return(nil, acquireErr)
119
120	c := newMinimalCoordinator()
121	c.pool = pool
122
123	_, err := c.EnsureWebView(context.Background(), "pane-1")
124
125	require.Error(t, err)
126	assert.Equal(t, acquireErr, err)
127}
128
129func TestLifecycle_AttachToWorkspace_ReusesRegisteredWebViewByPaneID(t *testing.T) {
130	t.Parallel()
131
132	paneID := entity.PaneID("pane-1")
133	wv := mocks.NewMockWebView(t)
134	wv.EXPECT().IsDestroyed().Return(false).Once()
135	wv.EXPECT().ID().Return(port.WebViewID(104)).Once()
136	wv.EXPECT().URI().Return("").Maybe()
137
138	pool := mocks.NewMockWebViewPool(t)
139	widgetFactory := layoutmocks.NewMockWidgetFactory(t)
140	ctx, wsView := newAttachWorkspaceView(t, widgetFactory)
141	workspace := &entity.Workspace{
142		ID:           "workspace-1",
143		Root:         &entity.PaneNode{ID: "node-1", Pane: entity.NewPane(paneID)},
144		ActivePaneID: paneID,
145	}
146
147	c := newMinimalCoordinator()
148	c.pool = pool
149	c.widgetFactory = widgetFactory
150	c.webViews[paneID] = wv
151
152	c.AttachToWorkspace(ctx, workspace, wsView)
153
154	assert.Same(t, wv, c.webViews[paneID])
155}
156
157func newAttachWorkspaceView(t *testing.T, factory *layoutmocks.MockWidgetFactory) (context.Context, *component.WorkspaceView) {
158	t.Helper()
159	ctx := context.Background()
160	container := layoutmocks.NewMockBoxWidget(t)
161	overlay := layoutmocks.NewMockOverlayWidget(t)
162
163	factory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(container).Once()
164	container.EXPECT().SetHexpand(true).Once()
165	container.EXPECT().SetVexpand(true).Once()
166	container.EXPECT().SetVisible(true).Once()
167	factory.EXPECT().NewOverlay().Return(overlay).Once()
168	overlay.EXPECT().SetHexpand(true).Once()
169	overlay.EXPECT().SetVexpand(true).Once()
170	overlay.EXPECT().SetChild(container).Once()
171	overlay.EXPECT().SetVisible(true).Once()
172
173	return ctx, component.NewWorkspaceView(ctx, factory)
174}
175
176// ---------------------------------------------------------------------------
177// ReleaseWebView
178// ---------------------------------------------------------------------------
179
180func TestLifecycle_ReleaseWebView_ReleasesToPool(t *testing.T) {
181	t.Parallel()
182
183	wv := mocks.NewMockWebView(t)
184	wv.EXPECT().ID().Return(port.WebViewID(11)).Maybe()
185	// idleInhibitor is nil so these won't be called
186	wv.EXPECT().IsFullscreen().Return(false).Maybe()
187	wv.EXPECT().IsPlayingAudio().Return(false).Maybe()
188
189	pool := mocks.NewMockWebViewPool(t)
190	pool.EXPECT().Release(wv)
191
192	c := newMinimalCoordinator()
193	c.pool = pool
194	c.webViews[entity.PaneID("pane-1")] = wv
195
196	c.ReleaseWebView(context.Background(), "pane-1")
197
198	_, exists := c.webViews[entity.PaneID("pane-1")]
199	assert.False(t, exists, "webview should be removed from map after release")
200}
201
202func TestLifecycle_ReleaseWebView_ClearsNamedBrowsingContextRegistration(t *testing.T) {
203	t.Parallel()
204
205	paneID := entity.PaneID("pane-1")
206	windowID := "window-1"
207	wv := mocks.NewMockWebView(t)
208	wv.EXPECT().ID().Return(port.WebViewID(15)).Maybe()
209	wv.EXPECT().IsDestroyed().Return(false).Maybe()
210	wv.EXPECT().IsFullscreen().Return(false).Maybe()
211	wv.EXPECT().IsPlayingAudio().Return(false).Maybe()
212
213	pool := mocks.NewMockWebViewPool(t)
214	pool.EXPECT().Release(wv)
215
216	c := newMinimalCoordinator()
217	c.pool = pool
218	c.popups = newPopupManager()
219	c.popups.setWindowIDResolver(func(id entity.PaneID) (string, bool) {
220		if id == paneID {
221			return windowID, true
222		}
223		return "", false
224	})
225	c.webViews[paneID] = wv
226	c.popups.namedContexts.Register(windowID, "named-popup", paneID, wv.ID())
227
228	_, _, okBefore := c.popups.namedContexts.Lookup(windowID, "named-popup", c.getWebViewLocked, c.popups.windowIDForPane)
229	require.True(t, okBefore)
230
231	c.ReleaseWebView(context.Background(), paneID)
232
233	_, _, okAfter := c.popups.namedContexts.Lookup(windowID, "named-popup", c.getWebViewLocked, c.popups.windowIDForPane)
234	assert.False(t, okAfter)
235}
236
237func TestLifecycle_ReleaseWebView_NoopForUnknownPane(t *testing.T) {
238	t.Parallel()
239
240	pool := mocks.NewMockWebViewPool(t)
241	// pool.Release must NOT be called
242
243	c := newMinimalCoordinator()
244	c.pool = pool
245
246	// Should not panic
247	c.ReleaseWebView(context.Background(), "unknown-pane")
248}
249
250func TestLifecycle_ReleaseWebView_UninhibitsIdleIfFullscreen(t *testing.T) {
251	t.Parallel()
252
253	wv := mocks.NewMockWebView(t)
254	wv.EXPECT().ID().Return(port.WebViewID(12)).Maybe()
255	wv.EXPECT().IsFullscreen().Return(true)
256	wv.EXPECT().IsPlayingAudio().Return(false)
257
258	inhibitor := mocks.NewMockIdleInhibitor(t)
259	inhibitor.EXPECT().Uninhibit(mock.Anything).Return(nil)
260
261	pool := mocks.NewMockWebViewPool(t)
262	pool.EXPECT().Release(wv)
263
264	c := newMinimalCoordinator()
265	c.pool = pool
266	c.idleInhibitor = inhibitor
267	c.webViews[entity.PaneID("pane-1")] = wv
268
269	c.ReleaseWebView(context.Background(), "pane-1")
270}
271
272func TestLifecycle_ReleaseWebView_UninhibitsIdleIfPlayingAudio(t *testing.T) {
273	t.Parallel()
274
275	wv := mocks.NewMockWebView(t)
276	wv.EXPECT().ID().Return(port.WebViewID(13)).Maybe()
277	wv.EXPECT().IsFullscreen().Return(false)
278	wv.EXPECT().IsPlayingAudio().Return(true)
279
280	inhibitor := mocks.NewMockIdleInhibitor(t)
281	inhibitor.EXPECT().Uninhibit(mock.Anything).Return(nil)
282
283	pool := mocks.NewMockWebViewPool(t)
284	pool.EXPECT().Release(wv)
285
286	c := newMinimalCoordinator()
287	c.pool = pool
288	c.idleInhibitor = inhibitor
289	c.webViews[entity.PaneID("pane-1")] = wv
290
291	c.ReleaseWebView(context.Background(), "pane-1")
292}
293
294func TestLifecycle_ReleaseWebView_DestroysWebViewWhenPoolIsNil(t *testing.T) {
295	t.Parallel()
296
297	wv := mocks.NewMockWebView(t)
298	wv.EXPECT().ID().Return(port.WebViewID(14)).Maybe()
299	// idleInhibitor is nil so these won't be called
300	wv.EXPECT().IsFullscreen().Return(false).Maybe()
301	wv.EXPECT().IsPlayingAudio().Return(false).Maybe()
302	wv.EXPECT().Destroy()
303
304	c := newMinimalCoordinator()
305	// pool remains nil
306	c.webViews[entity.PaneID("pane-1")] = wv
307
308	c.ReleaseWebView(context.Background(), "pane-1")
309}
310
311// ---------------------------------------------------------------------------
312// GetWebView / RegisterPopupWebView
313// ---------------------------------------------------------------------------
314
315func TestLifecycle_GetWebView_ReturnsRegisteredWebView(t *testing.T) {
316	t.Parallel()
317
318	wv := mocks.NewMockWebView(t)
319
320	c := newMinimalCoordinator()
321	c.webViews[entity.PaneID("pane-1")] = wv
322
323	got := c.GetWebView("pane-1")
324
325	assert.Equal(t, wv, got)
326}
327
328func TestLifecycle_RegisterPopupWebView_StoresWebView(t *testing.T) {
329	t.Parallel()
330
331	wv := mocks.NewMockWebView(t)
332
333	c := newMinimalCoordinator()
334
335	c.RegisterPopupWebView("popup-1", wv)
336
337	assert.Equal(t, wv, c.GetWebView("popup-1"))
338}
339
340func TestLifecycle_RegisterPopupWebView_IgnoresNil(t *testing.T) {
341	t.Parallel()
342
343	c := newMinimalCoordinator()
344
345	// Should not panic
346	c.RegisterPopupWebView("popup-1", nil)
347
348	assert.Nil(t, c.GetWebView("popup-1"))
349}
350
351type nativeWebViewStub struct {
352	port.WebView
353	ptr uintptr
354}
355
356func (s *nativeWebViewStub) NativeWidget() uintptr {
357	return s.ptr
358}
359
360func TestLifecycle_WrapWidget_ReturnsNilForNilWebView(t *testing.T) {
361	t.Parallel()
362
363	factory := layoutmocks.NewMockWidgetFactory(t)
364	c := newMinimalCoordinator()
365	c.widgetFactory = factory
366
367	assert.Nil(t, c.WrapWidget(context.Background(), nil))
368}
369
370func TestLifecycle_WrapWidget_ReturnsNilForMissingNativeProvider(t *testing.T) {
371	t.Parallel()
372
373	wv := mocks.NewMockWebView(t)
374	factory := layoutmocks.NewMockWidgetFactory(t)
375	c := newMinimalCoordinator()
376	c.widgetFactory = factory
377
378	assert.Nil(t, c.WrapWidget(context.Background(), wv))
379}
380
381func TestLifecycle_WrapWidget_ReturnsNilForZeroNativePointer(t *testing.T) {
382	t.Parallel()
383
384	base := mocks.NewMockWebView(t)
385	wv := &nativeWebViewStub{WebView: base}
386	factory := layoutmocks.NewMockWidgetFactory(t)
387	c := newMinimalCoordinator()
388	c.widgetFactory = factory
389
390	assert.Nil(t, c.WrapWidget(context.Background(), wv))
391}
392
393func TestLifecycle_WrapWidget_ReturnsNilForNilWrappedWidget(t *testing.T) {
394	t.Parallel()
395
396	base := mocks.NewMockWebView(t)
397	wv := &nativeWebViewStub{WebView: base, ptr: 123}
398	factory := layoutmocks.NewMockWidgetFactory(t)
399	factory.EXPECT().WrapNativeWidget(uintptr(123)).Return(nil).Once()
400	c := newMinimalCoordinator()
401	c.widgetFactory = factory
402
403	assert.Nil(t, c.WrapWidget(context.Background(), wv))
404}
405
406func TestLifecycle_WrapWidget_RequestsGtkWidgetForGestureSetup(t *testing.T) {
407	t.Parallel()
408
409	base := mocks.NewMockWebView(t)
410	wv := &nativeWebViewStub{WebView: base, ptr: 456}
411	factory := layoutmocks.NewMockWidgetFactory(t)
412	widget := layoutmocks.NewMockWidget(t)
413	factory.EXPECT().WrapNativeWidget(uintptr(456)).Return(widget).Once()
414	widget.EXPECT().GtkWidget().Return(nil).Once()
415	c := newMinimalCoordinator()
416	c.widgetFactory = factory
417
418	assert.Same(t, widget, c.WrapWidget(context.Background(), wv))
419}
420
421type syncViewportContextKey struct{}
422
423type syncViewportCapableStub struct {
424	port.WebView
425	called bool
426	ctx    context.Context
427	reason string
428}
429
430func (s *syncViewportCapableStub) SyncViewport(ctx context.Context, reason string) {
431	s.called = true
432	s.ctx = ctx
433	s.reason = reason
434}
435
436func TestLifecycle_SyncWebViewViewport_DelegatesWhenCapabilityPresent(t *testing.T) {
437	t.Parallel()
438
439	base := mocks.NewMockWebView(t)
440	wv := &syncViewportCapableStub{WebView: base}
441	c := newMinimalCoordinator()
442	c.webViews[entity.PaneID("pane-1")] = wv
443
444	ctx := context.WithValue(context.Background(), syncViewportContextKey{}, "marker")
445	c.SyncWebViewViewport(ctx, "pane-1", "unit-test")
446
447	require.True(t, wv.called)
448	require.Equal(t, "unit-test", wv.reason)
449	require.Same(t, ctx, wv.ctx)
450}
451
452func TestLifecycle_SyncWebViewViewport_NoopWithoutCapability(t *testing.T) {
453	t.Parallel()
454
455	wv := mocks.NewMockWebView(t)
456	c := newMinimalCoordinator()
457	c.webViews[entity.PaneID("pane-1")] = wv
458
459	c.SyncWebViewViewport(context.Background(), "pane-1", "unit-test")
460}

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