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}