1package ui
2
3import (
4 "context"
5 "errors"
6 "reflect"
7 "testing"
8 "unsafe"
9
10 "github.com/bnema/dumber/internal/application/port"
11 portmocks "github.com/bnema/dumber/internal/application/port/mocks"
12 "github.com/bnema/dumber/internal/application/usecase"
13 "github.com/bnema/dumber/internal/domain/entity"
14 "github.com/bnema/dumber/internal/infrastructure/config"
15 "github.com/bnema/dumber/internal/shared/syncdispatch"
16 "github.com/bnema/dumber/internal/ui/component"
17 contentcoord "github.com/bnema/dumber/internal/ui/coordinator/content"
18 "github.com/bnema/dumber/internal/ui/input"
19 "github.com/bnema/dumber/internal/ui/layout"
20 "github.com/bnema/dumber/internal/ui/window"
21 "github.com/bnema/puregotk/v4/gtk"
22 "github.com/stretchr/testify/assert"
23 "github.com/stretchr/testify/require"
24)
25
26func TestBrowserWindow_RemoveBrowserWindowClearsShellState(t *testing.T) {
27 mainWindow := &window.MainWindow{}
28 removed := &browserWindow{id: "window-1", mainWindow: mainWindow}
29 remaining := &browserWindow{id: "window-2", mainWindow: &window.MainWindow{}}
30 app := &App{
31 mainWindow: mainWindow,
32 browserWindows: map[string]*browserWindow{removed.id: removed, remaining.id: remaining},
33 lastFocusedWindowID: remaining.id,
34 }
35
36 setShellField(t, removed, "appToaster", &component.Toaster{})
37 setShellField(t, removed, "modeToaster", &modeToaster{})
38 setShellField(t, removed, "modeFrame", &modeFrame{})
39 setShellField(t, removed, "sessionManager", &component.SessionManager{})
40 setShellField(t, removed, "tabPicker", &component.TabPicker{})
41 setShellField(t, removed, "tabPickerWidget", (*testLayoutWidget)(nil))
42 setShellField(t, removed, "tabPickerPaneID", entity.PaneID("pane-1"))
43 setShellField(t, removed, "insertAccentUC", newTestAccentUseCase(t, false))
44 setShellField(t, removed, "accentPicker", &component.AccentPicker{})
45 setShellField(t, removed, "keyboardHandler", &input.KeyboardHandler{})
46 setShellField(t, removed, "globalShortcutHandler", &input.GlobalShortcutHandler{})
47 setShellField(t, removed, "permissionDialog", (*testPermissionDialogPresenter)(nil))
48 setShellField(t, removed, "webrtcIndicator", &component.WebRTCPermissionIndicator{})
49 setShellField(t, removed, "historySidebar", &component.HistorySidebar{})
50
51 app.removeBrowserWindow(removed.id)
52
53 for _, name := range []string{
54 "appToaster",
55 "modeToaster",
56 "modeFrame",
57 "sessionManager",
58 "tabPicker",
59 "tabPickerWidget",
60 "tabPickerPaneID",
61 "insertAccentUC",
62 "accentPicker",
63 "keyboardHandler",
64 "globalShortcutHandler",
65 "permissionDialog",
66 "webrtcIndicator",
67 "historySidebar",
68 } {
69 if !fieldIsZero(t, removed, name) {
70 t.Fatalf("browserWindow.%s was not cleared", name)
71 }
72 }
73}
74
75func TestApp_CleanupCreatedBrowserWindowsDetachesUnregisteredWindowBeforeDestroy(t *testing.T) {
76 transientMainWindow := &window.MainWindow{}
77 fallbackMainWindow := &window.MainWindow{}
78 transient := &browserWindow{id: "window-transient", mainWindow: transientMainWindow}
79 fallback := &browserWindow{id: "window-fallback", mainWindow: fallbackMainWindow}
80 app := &App{
81 mainWindow: transientMainWindow,
82 browserWindows: map[string]*browserWindow{fallback.id: fallback},
83 browserWindowOrder: []string{fallback.id},
84 lastFocusedWindowID: transient.id,
85 }
86
87 setShellField(t, transient, "keyboardHandler", &input.KeyboardHandler{})
88 setShellField(t, transient, "globalShortcutHandler", &input.GlobalShortcutHandler{})
89 setShellField(t, transient, "accentPicker", &component.AccentPicker{})
90
91 app.cleanupCreatedBrowserWindows([]*browserWindow{transient})
92
93 for _, name := range []string{"keyboardHandler", "globalShortcutHandler", "accentPicker"} {
94 if !fieldIsZero(t, transient, name) {
95 t.Fatalf("transient browserWindow.%s was not cleared", name)
96 }
97 }
98 if app.mainWindow != fallbackMainWindow {
99 t.Fatalf("mainWindow = %p, want fallback main window %p", app.mainWindow, fallbackMainWindow)
100 }
101 if app.lastFocusedWindowID != fallback.id {
102 t.Fatalf("lastFocusedWindowID = %q, want %q", app.lastFocusedWindowID, fallback.id)
103 }
104 if got := app.browserWindows[fallback.id]; got != fallback {
105 t.Fatalf("fallback browser window changed during transient cleanup: got %p want %p", got, fallback)
106 }
107}
108
109type recordingWebViewPool struct {
110 released []port.WebView
111}
112
113func (p *recordingWebViewPool) Acquire(context.Context) (port.WebView, error) {
114 return nil, errors.New("not implemented")
115}
116func (p *recordingWebViewPool) Release(wv port.WebView) { p.released = append(p.released, wv) }
117func (p *recordingWebViewPool) Prewarm(int) {}
118func (p *recordingWebViewPool) PrewarmAsync(context.Context, int) {}
119func (p *recordingWebViewPool) Size() int { return 0 }
120func (p *recordingWebViewPool) Close() {}
121
122func TestTabCoordinator_CloseReleasesClosedTabWorkspaceWebViews(t *testing.T) {
123 ctx := context.Background()
124 closedTab := entity.NewTab(entity.TabID("tab-x"), entity.WorkspaceID("workspace-x"), entity.NewPane(entity.PaneID("pane-x")))
125 survivingTab := entity.NewTab(entity.TabID("tab-survivor"), entity.WorkspaceID("workspace-survivor"), entity.NewPane(entity.PaneID("pane-survivor")))
126 tabs := entity.NewTabList()
127 tabs.Add(closedTab)
128 tabs.Add(survivingTab)
129 tabs.SetActive(closedTab.ID)
130
131 pool := &recordingWebViewPool{}
132 contentCoord := contentcoord.NewCoordinator(ctx, pool, nil, nil, nil, nil, nil, nil)
133 closedWV := &recordingWebView{id: 101}
134 survivingWV := &recordingWebView{id: 102}
135 contentCoord.RegisterPopupWebView(closedTab.Workspace.ActivePaneID, closedWV)
136 contentCoord.RegisterPopupWebView(survivingTab.Workspace.ActivePaneID, survivingWV)
137
138 bw := &browserWindow{id: "window-1", tabs: tabs}
139 app := &App{
140 deps: &Dependencies{},
141 runtimeConfig: runtimeConfigStateForTest(config.DefaultConfig()),
142 tabsUC: usecase.NewManageTabsUseCase(counterIDGen(), nil),
143 contentCoord: contentCoord,
144 browserWindows: map[string]*browserWindow{bw.id: bw},
145 tabs: entity.NewTabList(),
146 workspaceViews: map[entity.TabID]*component.WorkspaceView{closedTab.ID: {}, survivingTab.ID: {}},
147 windowForTab: map[entity.TabID]*browserWindow{closedTab.ID: bw, survivingTab.ID: bw},
148 floatingSessions: map[floatingSessionKey]*floatingWorkspaceSession{},
149 }
150 app.tabs.Add(closedTab)
151 app.tabs.Add(survivingTab)
152 app.initTabCoordinator(ctx)
153
154 require.NoError(t, app.tabCoord.Close(ctx, app.ensureTabTargetForBrowserWindow(bw)))
155
156 require.Len(t, pool.released, 1)
157 assert.Same(t, closedWV, pool.released[0])
158 assert.Nil(t, contentCoord.GetWebView(closedTab.Workspace.ActivePaneID))
159 assert.Same(t, survivingWV, contentCoord.GetWebView(survivingTab.Workspace.ActivePaneID))
160 assert.Nil(t, app.workspaceViews[closedTab.ID])
161 assert.Nil(t, app.windowForTab[closedTab.ID])
162 assert.Nil(t, app.tabs.Find(closedTab.ID))
163 assert.Equal(t, survivingTab.ID, tabs.ActiveTabID)
164}
165
166func TestTabCoordinator_SwitchDoesNotReleaseWorkspaceWebViews(t *testing.T) {
167 ctx := context.Background()
168 firstTab := entity.NewTab(entity.TabID("tab-first"), entity.WorkspaceID("workspace-first"), entity.NewPane(entity.PaneID("pane-first")))
169 secondTab := entity.NewTab(entity.TabID("tab-second"), entity.WorkspaceID("workspace-second"), entity.NewPane(entity.PaneID("pane-second")))
170 tabs := entity.NewTabList()
171 tabs.Add(firstTab)
172 tabs.Add(secondTab)
173 tabs.SetActive(firstTab.ID)
174
175 pool := &recordingWebViewPool{}
176 contentCoord := contentcoord.NewCoordinator(ctx, pool, nil, nil, nil, nil, nil, nil)
177 firstWV := &recordingWebView{id: 111}
178 secondWV := &recordingWebView{id: 112}
179 contentCoord.RegisterPopupWebView(firstTab.Workspace.ActivePaneID, firstWV)
180 contentCoord.RegisterPopupWebView(secondTab.Workspace.ActivePaneID, secondWV)
181
182 bw := &browserWindow{id: "window-1", tabs: tabs}
183 app := &App{
184 deps: &Dependencies{},
185 runtimeConfig: runtimeConfigStateForTest(config.DefaultConfig()),
186 tabsUC: usecase.NewManageTabsUseCase(counterIDGen(), nil),
187 contentCoord: contentCoord,
188 browserWindows: map[string]*browserWindow{bw.id: bw},
189 tabs: entity.NewTabList(),
190 workspaceViews: map[entity.TabID]*component.WorkspaceView{firstTab.ID: {}, secondTab.ID: {}},
191 windowForTab: map[entity.TabID]*browserWindow{firstTab.ID: bw, secondTab.ID: bw},
192 floatingSessions: map[floatingSessionKey]*floatingWorkspaceSession{},
193 }
194 app.tabs.Add(firstTab)
195 app.tabs.Add(secondTab)
196 app.initTabCoordinator(ctx)
197
198 require.NoError(t, app.tabCoord.Switch(ctx, app.ensureTabTargetForBrowserWindow(bw), secondTab.ID))
199
200 assert.Empty(t, pool.released)
201 assert.Same(t, firstWV, contentCoord.GetWebView(firstTab.Workspace.ActivePaneID))
202 assert.Same(t, secondWV, contentCoord.GetWebView(secondTab.Workspace.ActivePaneID))
203}
204
205func TestTabCoordinator_CloseLastTabReleasesWorkspaceBeforeWindowRemoval(t *testing.T) {
206 ctx := context.Background()
207 closedTab := entity.NewTab(entity.TabID("tab-only"), entity.WorkspaceID("workspace-only"), entity.NewPane(entity.PaneID("pane-only")))
208 tabs := entity.NewTabList()
209 tabs.Add(closedTab)
210 tabs.SetActive(closedTab.ID)
211
212 pool := &recordingWebViewPool{}
213 contentCoord := contentcoord.NewCoordinator(ctx, pool, nil, nil, nil, nil, nil, nil)
214 closedWV := &recordingWebView{id: 121}
215 contentCoord.RegisterPopupWebView(closedTab.Workspace.ActivePaneID, closedWV)
216
217 bw := &browserWindow{id: "window-1", tabs: tabs}
218 app := &App{
219 deps: &Dependencies{},
220 runtimeConfig: runtimeConfigStateForTest(config.DefaultConfig()),
221 tabsUC: usecase.NewManageTabsUseCase(counterIDGen(), nil),
222 contentCoord: contentCoord,
223 browserWindows: map[string]*browserWindow{bw.id: bw},
224 tabs: entity.NewTabList(),
225 workspaceViews: map[entity.TabID]*component.WorkspaceView{closedTab.ID: {}},
226 windowForTab: map[entity.TabID]*browserWindow{closedTab.ID: bw},
227 floatingSessions: map[floatingSessionKey]*floatingWorkspaceSession{},
228 }
229 app.tabs.Add(closedTab)
230 app.initTabCoordinator(ctx)
231
232 require.NoError(t, app.tabCoord.Close(ctx, app.ensureTabTargetForBrowserWindow(bw)))
233
234 require.Len(t, pool.released, 1)
235 assert.Same(t, closedWV, pool.released[0])
236 assert.Nil(t, contentCoord.GetWebView(closedTab.Workspace.ActivePaneID))
237 assert.Empty(t, app.browserWindows)
238 assert.Nil(t, app.workspaceViews[closedTab.ID])
239 assert.Nil(t, app.windowForTab[closedTab.ID])
240}
241
242func TestBrowserWindow_RemoveBrowserWindowReleasesOwnedTabWorkspaceWebViews(t *testing.T) {
243 ctx := context.Background()
244 ownedTab := entity.NewTab(entity.TabID("tab-owned"), entity.WorkspaceID("workspace-owned"), entity.NewPane(entity.PaneID("pane-owned")))
245 otherTab := entity.NewTab(entity.TabID("tab-other"), entity.WorkspaceID("workspace-other"), entity.NewPane(entity.PaneID("pane-other")))
246 removed := &browserWindow{id: "window-1", tabs: entity.NewTabList()}
247 remaining := &browserWindow{id: "window-2", tabs: entity.NewTabList()}
248 removed.tabs.Add(ownedTab)
249 remaining.tabs.Add(otherTab)
250
251 pool := &recordingWebViewPool{}
252 contentCoord := contentcoord.NewCoordinator(ctx, pool, nil, nil, nil, nil, nil, nil)
253 ownedWV := &recordingWebView{id: 201}
254 otherWV := &recordingWebView{id: 202}
255 contentCoord.RegisterPopupWebView(ownedTab.Workspace.ActivePaneID, ownedWV)
256 contentCoord.RegisterPopupWebView(otherTab.Workspace.ActivePaneID, otherWV)
257
258 app := &App{
259 contentCoord: contentCoord,
260 browserWindows: map[string]*browserWindow{removed.id: removed, remaining.id: remaining},
261 tabs: entity.NewTabList(),
262 workspaceViews: map[entity.TabID]*component.WorkspaceView{ownedTab.ID: {}, otherTab.ID: {}},
263 windowForTab: map[entity.TabID]*browserWindow{ownedTab.ID: removed, otherTab.ID: remaining},
264 floatingSessions: map[floatingSessionKey]*floatingWorkspaceSession{},
265 }
266 app.tabs.Add(ownedTab)
267 app.tabs.Add(otherTab)
268
269 app.removeBrowserWindow(removed.id)
270
271 require.Len(t, pool.released, 1)
272 assert.Same(t, ownedWV, pool.released[0])
273 assert.Nil(t, contentCoord.GetWebView(ownedTab.Workspace.ActivePaneID))
274 assert.Same(t, otherWV, contentCoord.GetWebView(otherTab.Workspace.ActivePaneID))
275}
276
277func TestBrowserWindow_RemoveBrowserWindowReleasesOnlyRelatedNativePopups(t *testing.T) {
278 closedWebView := portmocks.NewMockWebView(t)
279 closedWebView.EXPECT().IsDestroyed().Return(false).Once()
280 closedWebView.EXPECT().Destroy().Once()
281 unrelatedWebView := portmocks.NewMockWebView(t)
282 closedShell := &popupDestroySpy{}
283 unrelatedShell := &popupDestroySpy{}
284 removed := &browserWindow{id: "owner-window", tabs: entity.NewTabList()}
285 remaining := &browserWindow{id: "other-window", tabs: entity.NewTabList()}
286 app := &App{
287 browserWindows: map[string]*browserWindow{removed.id: removed, remaining.id: remaining},
288 nativePopupWindows: map[port.WebViewID]*nativePopupWindow{
289 1: {popupID: 1, parentWindowID: removed.id, webView: closedWebView, popupWindow: closedShell},
290 2: {popupID: 2, parentWindowID: remaining.id, webView: unrelatedWebView, popupWindow: unrelatedShell},
291 },
292 }
293
294 app.removeBrowserWindow(removed.id)
295
296 assert.NotContains(t, app.nativePopupWindows, port.WebViewID(1))
297 assert.Contains(t, app.nativePopupWindows, port.WebViewID(2))
298 assert.Equal(t, 1, closedShell.destroyed)
299 assert.Zero(t, unrelatedShell.destroyed)
300}
301
302func TestBrowserWindow_RemoveBrowserWindowCleansOwnedTabState(t *testing.T) {
303 ownedTab := entity.NewTab(entity.TabID("tab-owned"), entity.WorkspaceID("workspace-owned"), entity.NewPane(entity.PaneID("pane-owned")))
304 otherTab := entity.NewTab(entity.TabID("tab-other"), entity.WorkspaceID("workspace-other"), entity.NewPane(entity.PaneID("pane-other")))
305 removedMainWindow := &window.MainWindow{}
306 remainingMainWindow := &window.MainWindow{}
307 removedTabs := entity.NewTabList()
308 removedTabs.Add(ownedTab)
309 remainingTabs := entity.NewTabList()
310 remainingTabs.Add(otherTab)
311 removed := &browserWindow{id: "window-1", mainWindow: removedMainWindow, tabs: removedTabs}
312 remaining := &browserWindow{id: "window-2", mainWindow: remainingMainWindow, tabs: remainingTabs}
313 ownedSessionKey := floatingSessionKey{tabID: ownedTab.ID, sessionID: "profile:owned"}
314 otherSessionKey := floatingSessionKey{tabID: otherTab.ID, sessionID: "profile:other"}
315 app := &App{
316 mainWindow: removedMainWindow,
317 browserWindows: map[string]*browserWindow{removed.id: removed, remaining.id: remaining},
318 tabs: entity.NewTabList(),
319 workspaceViews: map[entity.TabID]*component.WorkspaceView{ownedTab.ID: &component.WorkspaceView{}, otherTab.ID: &component.WorkspaceView{}},
320 windowForTab: map[entity.TabID]*browserWindow{ownedTab.ID: removed, otherTab.ID: remaining},
321 floatingSessions: map[floatingSessionKey]*floatingWorkspaceSession{
322 ownedSessionKey: {},
323 otherSessionKey: {},
324 },
325 }
326 app.tabs.Add(ownedTab)
327 app.tabs.Add(otherTab)
328
329 app.removeBrowserWindow(removed.id)
330
331 if app.workspaceViews[ownedTab.ID] != nil {
332 t.Fatalf("owned workspace view was not removed")
333 }
334 if app.windowForTab[ownedTab.ID] != nil {
335 t.Fatalf("owned window mapping was not removed")
336 }
337 if app.tabs.Find(ownedTab.ID) != nil {
338 t.Fatalf("owned tab was not removed")
339 }
340 if app.workspaceViews[otherTab.ID] == nil {
341 t.Fatalf("other workspace view should remain")
342 }
343 if app.windowForTab[otherTab.ID] != remaining {
344 t.Fatalf("other tab should remain mapped to remaining window")
345 }
346 if app.tabs.Find(otherTab.ID) == nil {
347 t.Fatalf("other tab should remain")
348 }
349 if _, ok := app.floatingSessions[ownedSessionKey]; ok {
350 t.Fatalf("owned floating session was not released")
351 }
352 if _, ok := app.floatingSessions[otherSessionKey]; !ok {
353 t.Fatalf("other floating session should remain")
354 }
355 assertWindowOwnershipInvariant(t, app)
356}
357
358func TestBrowserWindow_RegisterAndRemoveTrackCollection(t *testing.T) {
359 app := &App{browserWindows: make(map[string]*browserWindow)}
360 first := &browserWindow{id: "window-1"}
361 second := &browserWindow{id: "window-2"}
362
363 app.registerBrowserWindow(first)
364 app.registerBrowserWindow(second)
365
366 if got := len(app.browserWindows); got != 2 {
367 t.Fatalf("browserWindows length = %d, want 2", got)
368 }
369 if _, ok := app.browserWindows[first.id]; !ok {
370 t.Fatalf("first browser window was not registered")
371 }
372 if _, ok := app.browserWindows[second.id]; !ok {
373 t.Fatalf("second browser window was not registered")
374 }
375 if first.tabs == nil || second.tabs == nil {
376 t.Fatalf("registered browser windows must have non-nil tab lists")
377 }
378
379 app.removeBrowserWindow(first.id)
380
381 if _, ok := app.browserWindows[first.id]; ok {
382 t.Fatalf("first browser window was not removed")
383 }
384 if _, ok := app.browserWindows[second.id]; !ok {
385 t.Fatalf("second browser window should remain registered")
386 }
387}
388
389func TestBrowserWindow_RemovePromotesRemainingMainWindow(t *testing.T) {
390 mainWindow := &window.MainWindow{}
391 otherWindow := &window.MainWindow{}
392 first := &browserWindow{id: "window-1", mainWindow: mainWindow}
393 second := &browserWindow{id: "window-2", mainWindow: otherWindow}
394 app := &App{
395 mainWindow: mainWindow,
396 browserWindows: map[string]*browserWindow{first.id: first, second.id: second},
397 }
398
399 app.removeBrowserWindow(first.id)
400
401 if app.mainWindow != otherWindow {
402 t.Fatalf("mainWindow = %p, want promoted window %p", app.mainWindow, otherWindow)
403 }
404 if len(app.browserWindows) != 1 {
405 t.Fatalf("browserWindows length = %d, want 1", len(app.browserWindows))
406 }
407 if _, ok := app.browserWindows[second.id]; !ok {
408 t.Fatalf("remaining browser window was not kept")
409 }
410}
411
412func TestBrowserWindow_RemoveLastClearsMainWindow(t *testing.T) {
413 mainWindow := &window.MainWindow{}
414 first := &browserWindow{id: "window-1", mainWindow: mainWindow}
415 app := &App{
416 mainWindow: mainWindow,
417 browserWindows: map[string]*browserWindow{first.id: first},
418 }
419
420 app.removeBrowserWindow(first.id)
421
422 if app.mainWindow != nil {
423 t.Fatalf("mainWindow = %p, want nil", app.mainWindow)
424 }
425 if len(app.browserWindows) != 0 {
426 t.Fatalf("browserWindows length = %d, want 0", len(app.browserWindows))
427 }
428}
429
430func TestBrowserWindow_RemovePromotedWindowKeepsRemainingWindow(t *testing.T) {
431 mainWindow := &window.MainWindow{}
432 otherWindow := &window.MainWindow{}
433 removed := &browserWindow{id: "window-1", mainWindow: mainWindow}
434 remaining := &browserWindow{id: "window-2", mainWindow: otherWindow}
435 app := &App{
436 mainWindow: mainWindow,
437 browserWindows: map[string]*browserWindow{removed.id: removed, remaining.id: remaining},
438 }
439
440 app.removeBrowserWindow(removed.id)
441
442 if app.mainWindow != otherWindow {
443 t.Fatalf("mainWindow = %p, want %p", app.mainWindow, otherWindow)
444 }
445}
446
447func TestOpenExternalURL_DispatchesAndTracksBrowserWindow(t *testing.T) {
448 app := &App{browserWindows: make(map[string]*browserWindow)}
449
450 dispatched := false
451 createdURL := ""
452 app.dispatchOnMainThread = func(label string, fn func()) syncdispatch.SyncDispatchResult {
453 dispatched = true
454 if label != "ui.open_external_url" {
455 t.Fatalf("dispatch label = %q, want ui.open_external_url", label)
456 }
457 fn()
458 return syncdispatch.SyncDispatchResult{Label: label, Status: syncdispatch.SyncDispatchCompleted}
459 }
460 app.browserWindowFactory = func(ctx context.Context, url string) (*browserWindow, error) {
461 createdURL = url
462 return &browserWindow{id: "window-1", initialURL: url}, nil
463 }
464
465 if err := app.OpenExternalURL(context.Background(), "https://example.com"); err != nil {
466 t.Fatalf("OpenExternalURL returned error: %v", err)
467 }
468 if !dispatched {
469 t.Fatalf("OpenExternalURL did not use the main-thread dispatcher")
470 }
471 if createdURL != "https://example.com" {
472 t.Fatalf("browser window factory URL = %q, want %q", createdURL, "https://example.com")
473 }
474 if got := len(app.browserWindows); got != 1 {
475 t.Fatalf("browserWindows length = %d, want 1", got)
476 }
477 if got := app.browserWindows["window-1"]; got == nil {
478 t.Fatalf("browser window was not tracked")
479 }
480 if app.browserWindows["window-1"].initialURL != "https://example.com" {
481 t.Fatalf("browser window initialURL = %q, want %q", app.browserWindows["window-1"].initialURL, "https://example.com")
482 }
483}
484
485func TestOpenInitialBrowserWindowShellRegistersWithoutCreatingTab(t *testing.T) {
486 app := &App{
487 tabs: entity.NewTabList(),
488 tabsUC: usecase.NewManageTabsUseCase(func() string { return "unexpected-tab" }, nil),
489 browserWindows: make(map[string]*browserWindow),
490 }
491 app.browserWindowFactory = func(_ context.Context, initialURL string) (*browserWindow, error) {
492 return &browserWindow{id: "window-1", initialURL: initialURL, tabs: entity.NewTabList()}, nil
493 }
494
495 if err := app.openInitialBrowserWindowShell(context.Background(), "https://example.com"); err != nil {
496 t.Fatalf("openInitialBrowserWindowShell returned error: %v", err)
497 }
498 if got := len(app.browserWindows); got != 1 {
499 t.Fatalf("browserWindows length = %d, want 1", got)
500 }
501 if app.lastFocusedWindowID != "window-1" {
502 t.Fatalf("lastFocusedWindowID = %q, want window-1", app.lastFocusedWindowID)
503 }
504 if got := app.tabs.Count(); got != 0 {
505 t.Fatalf("tabs count = %d, want 0; initial shell should wait for createInitialTab", got)
506 }
507 windowTabs := app.browserWindows["window-1"].tabs
508 require.NotNil(t, windowTabs)
509 if got := windowTabs.Count(); got != 0 {
510 t.Fatalf("window tabs count = %d, want 0; initial shell should wait for createInitialTab", got)
511 }
512 require.Equal(t, "https://example.com", app.browserWindows["window-1"].initialURL)
513}
514
515func TestOpenExternalURL_FirstWindowWithRequestedURLCreatesTabInAppAndWindow(t *testing.T) {
516 app := &App{
517 tabs: entity.NewTabList(),
518 tabsUC: usecase.NewManageTabsUseCase(counterIDGen(), nil),
519 browserWindows: make(map[string]*browserWindow),
520 workspaceViews: map[entity.TabID]*component.WorkspaceView{},
521 windowForTab: map[entity.TabID]*browserWindow{},
522 }
523 app.dispatchOnMainThread = func(label string, fn func()) syncdispatch.SyncDispatchResult {
524 fn()
525 return syncdispatch.SyncDispatchResult{Label: label, Status: syncdispatch.SyncDispatchCompleted}
526 }
527 app.browserWindowFactory = func(context.Context, string) (*browserWindow, error) {
528 return &browserWindow{id: "window-1", tabs: entity.NewTabList()}, nil
529 }
530 app.workspaceViewCreateOverride = func(_ context.Context, tab *entity.Tab) bool {
531 app.workspaceViews[tab.ID] = &component.WorkspaceView{}
532 return true
533 }
534
535 if err := app.OpenExternalURL(context.Background(), "https://example.com"); err != nil {
536 t.Fatalf("OpenExternalURL returned error: %v", err)
537 }
538
539 windowTabs := app.browserWindows["window-1"].tabs
540 require.NotNil(t, windowTabs)
541 require.Equal(t, 1, app.tabs.Count())
542 require.Equal(t, 1, windowTabs.Count())
543 tab := windowTabs.ActiveTab()
544 require.NotNil(t, tab)
545 require.NotNil(t, tab.Workspace)
546 activePane := tab.Workspace.FindPane(tab.Workspace.ActivePaneID)
547 require.NotNil(t, activePane)
548 require.Equal(t, "https://example.com", activePane.Pane.URI)
549 require.NotNil(t, app.tabs.Find(tab.ID))
550 require.Same(t, app.browserWindows["window-1"], app.browserWindowForTab(tab.ID))
551}
552
553func TestOpenExternalURL_PropagatesFactoryError(t *testing.T) {
554 app := &App{browserWindows: make(map[string]*browserWindow)}
555 app.dispatchOnMainThread = func(label string, fn func()) syncdispatch.SyncDispatchResult {
556 fn()
557 return syncdispatch.SyncDispatchResult{Label: label, Status: syncdispatch.SyncDispatchCompleted}
558 }
559 wantErr := errors.New("factory error")
560 app.browserWindowFactory = func(context.Context, string) (*browserWindow, error) {
561 return nil, wantErr
562 }
563
564 err := app.OpenExternalURL(context.Background(), "https://example.com")
565
566 if !errors.Is(err, wantErr) {
567 t.Fatalf("expected error %v, got %v", wantErr, err)
568 }
569 if got := len(app.browserWindows); got != 0 {
570 t.Fatalf("browserWindows length = %d, want 0", got)
571 }
572}
573
574func setShellField(t *testing.T, bw *browserWindow, name string, value any) {
575 t.Helper()
576 rv := reflect.ValueOf(bw).Elem()
577 field := rv.FieldByName(name)
578 if !field.IsValid() {
579 t.Fatalf("browserWindow missing field %s", name)
580 }
581 reflect.NewAt(field.Type(), unsafe.Pointer(field.UnsafeAddr())).Elem().Set(reflect.ValueOf(value))
582}
583
584func fieldIsZero(t *testing.T, bw *browserWindow, name string) bool {
585 t.Helper()
586 rv := reflect.ValueOf(bw).Elem()
587 field := rv.FieldByName(name)
588 if !field.IsValid() {
589 t.Fatalf("browserWindow missing field %s", name)
590 }
591 return field.IsZero()
592}
593
594type testLayoutWidget struct{}
595
596func (*testLayoutWidget) Show() {}
597func (*testLayoutWidget) Hide() {}
598func (*testLayoutWidget) SetVisible(bool) {}
599func (*testLayoutWidget) IsVisible() bool { return false }
600func (*testLayoutWidget) SetOpacity(float64) {}
601func (*testLayoutWidget) GrabFocus() bool { return false }
602func (*testLayoutWidget) HasFocus() bool { return false }
603func (*testLayoutWidget) SetCanFocus(bool) {}
604func (*testLayoutWidget) SetFocusable(bool) {}
605func (*testLayoutWidget) SetFocusOnClick(bool) {}
606func (*testLayoutWidget) SetCanTarget(bool) {}
607func (*testLayoutWidget) SetHexpand(bool) {}
608func (*testLayoutWidget) SetVexpand(bool) {}
609func (*testLayoutWidget) GetHexpand() bool { return false }
610func (*testLayoutWidget) GetVexpand() bool { return false }
611func (*testLayoutWidget) SetHalign(gtk.Align) {}
612func (*testLayoutWidget) SetValign(gtk.Align) {}
613func (*testLayoutWidget) SetSizeRequest(int, int) {}
614func (*testLayoutWidget) GetAllocatedWidth() int { return 0 }
615func (*testLayoutWidget) GetAllocatedHeight() int { return 0 }
616func (*testLayoutWidget) ComputePoint(layout.Widget) (float64, float64, bool) {
617 return 0, 0, false
618}
619func (*testLayoutWidget) AddCssClass(string) {}
620func (*testLayoutWidget) RemoveCssClass(string) {}
621func (*testLayoutWidget) HasCssClass(string) bool { return false }
622func (*testLayoutWidget) Unparent() {}
623func (*testLayoutWidget) GetParent() layout.Widget { return nil }
624func (*testLayoutWidget) GtkWidget() *gtk.Widget { return nil }
625func (*testLayoutWidget) AddController(*gtk.EventController) {}
626
627type testPermissionDialogPresenter struct{}
628
629func (*testPermissionDialogPresenter) ShowPermissionDialog(context.Context, string, []entity.PermissionType, entity.PermissionMetadata, func(port.PermissionDialogResult)) {
630}
631
632// --- Window tab list tests ---
633
634func TestGetWindowTabLists_RespectsRegistrationOrder(t *testing.T) {
635 // Register windows in non-lexicographic order: window-2 first, then window-1.
636 // The result should preserve creation order, not sorted order.
637 app := &App{
638 browserWindows: make(map[string]*browserWindow),
639 }
640
641 first := &browserWindow{id: "window-2"}
642 second := &browserWindow{id: "window-1"}
643 app.registerBrowserWindow(first)
644 app.registerBrowserWindow(second)
645
646 // Give each window a tab so they show up in results.
647 pane1 := entity.NewPane(entity.PaneID("p-w2"))
648 tab1 := entity.NewTab(entity.TabID("t-w2"), entity.WorkspaceID("ws-w2"), pane1)
649 pane2 := entity.NewPane(entity.PaneID("p-w1"))
650 tab2 := entity.NewTab(entity.TabID("t-w1"), entity.WorkspaceID("ws-w1"), pane2)
651
652 app.tabs = entity.NewTabList()
653 app.tabs.Add(tab1)
654 app.tabs.Add(tab2)
655 first.tabs.Add(tab1)
656 second.tabs.Add(tab2)
657 app.windowForTab = map[entity.TabID]*browserWindow{
658 tab1.ID: first,
659 tab2.ID: second,
660 }
661
662 result := app.GetWindowTabLists()
663
664 require.Len(t, result, 2)
665 assert.Equal(t, entity.WindowID("window-2"), result[0].WindowID, "first registered window should be first")
666 assert.Equal(t, entity.WindowID("window-1"), result[1].WindowID, "second registered window should be second")
667}
668
669func TestGetWindowTabLists_NoBrowserWindowsReturnsGlobalSnapshot(t *testing.T) {
670 pane := entity.NewPane(entity.PaneID("p1"))
671 tab := entity.NewTab(entity.TabID("t1"), entity.WorkspaceID("ws1"), pane)
672 tabs := entity.NewTabList()
673 tabs.Add(tab)
674
675 app := &App{tabs: tabs}
676
677 result := app.GetWindowTabLists()
678
679 require.Len(t, result, 1)
680 assert.Equal(t, entity.WindowID(""), result[0].WindowID)
681 // Snapshot returns a copy so pointer identity is not expected; verify
682 // structural equality instead.
683 assert.Equal(t, tabs.ActiveTabID, result[0].Tabs.ActiveTabID)
684 assert.Len(t, result[0].Tabs.Tabs, len(tabs.Tabs))
685}
686
687func TestGetWindowTabLists_AppliesActiveTabIndex(t *testing.T) {
688 // A window with its own TabList where the second tab is active.
689 app := &App{
690 browserWindows: make(map[string]*browserWindow),
691 }
692
693 pane1 := entity.NewPane(entity.PaneID("p1"))
694 tab1 := entity.NewTab(entity.TabID("t1"), entity.WorkspaceID("ws1"), pane1)
695 pane2 := entity.NewPane(entity.PaneID("p2"))
696 tab2 := entity.NewTab(entity.TabID("t2"), entity.WorkspaceID("ws2"), pane2)
697
698 app.tabs = entity.NewTabList()
699 app.tabs.Add(tab1)
700 app.tabs.Add(tab2)
701
702 bwTabs := entity.NewTabList()
703 bwTabs.Add(tab1)
704 bwTabs.Add(tab2)
705 bwTabs.SetActive(entity.TabID("t2"))
706 bw := &browserWindow{id: "w1", tabs: bwTabs}
707 app.registerBrowserWindow(bw)
708 app.windowForTab = map[entity.TabID]*browserWindow{
709 tab1.ID: bw,
710 tab2.ID: bw,
711 }
712
713 result := app.GetWindowTabLists()
714
715 require.Len(t, result, 1)
716 assert.Equal(t, entity.TabID("t2"), result[0].Tabs.ActiveTabID, "should reflect bw.tabs.ActiveTabID")
717}
718
719func TestGetWindowTabLists_PreservesPreviousActiveTabID(t *testing.T) {
720 app := &App{
721 browserWindows: make(map[string]*browserWindow),
722 }
723
724 pane1 := entity.NewPane(entity.PaneID("p1"))
725 tab1 := entity.NewTab(entity.TabID("t1"), entity.WorkspaceID("ws1"), pane1)
726 pane2 := entity.NewPane(entity.PaneID("p2"))
727 tab2 := entity.NewTab(entity.TabID("t2"), entity.WorkspaceID("ws2"), pane2)
728
729 app.tabs = entity.NewTabList()
730 app.tabs.Add(tab1)
731 app.tabs.Add(tab2)
732
733 bwTabs := entity.NewTabList()
734 bwTabs.Add(tab1)
735 bwTabs.Add(tab2)
736 bwTabs.SetActive(entity.TabID("t2")) // t2 becomes active, t1 becomes previous
737
738 bw := &browserWindow{id: "w1", tabs: bwTabs}
739 app.registerBrowserWindow(bw)
740 app.windowForTab = map[entity.TabID]*browserWindow{
741 tab1.ID: bw,
742 tab2.ID: bw,
743 }
744
745 result := app.GetWindowTabLists()
746
747 require.Len(t, result, 1)
748 assert.Equal(t, entity.TabID("t1"), result[0].Tabs.PreviousActiveTabID, "should preserve bw.tabs.PreviousActiveTabID")
749}
750
751func TestGetActiveWindowIndex_MatchesRegistrationOrder(t *testing.T) {
752 // Register windows in non-lexicographic order; lastFocusedWindowID should
753 // map to the correct index in registration order.
754 app := &App{
755 browserWindows: make(map[string]*browserWindow),
756 }
757
758 app.registerBrowserWindow(&browserWindow{id: "z-window"})
759 app.registerBrowserWindow(&browserWindow{id: "a-window"})
760 app.lastFocusedWindowID = "a-window"
761
762 idx := app.GetActiveWindowIndex()
763 assert.Equal(t, 1, idx, "a-window is the second registered window, index should be 1")
764}
765
766func TestGetActiveWindowIndex_MatchesGetWindowTabLists(t *testing.T) {
767 // The order of windows in GetWindowTabLists must match the indices returned
768 // by GetActiveWindowIndex.
769 app := &App{
770 browserWindows: make(map[string]*browserWindow),
771 }
772
773 w1 := &browserWindow{id: "w1"}
774 w2 := &browserWindow{id: "w2"}
775 w3 := &browserWindow{id: "w3"}
776 app.registerBrowserWindow(w1)
777 app.registerBrowserWindow(w2)
778 app.registerBrowserWindow(w3)
779
780 // Give each window a tab.
781 p1 := entity.NewPane(entity.PaneID("p1"))
782 t1 := entity.NewTab(entity.TabID("t1"), entity.WorkspaceID("ws1"), p1)
783 p2 := entity.NewPane(entity.PaneID("p2"))
784 t2 := entity.NewTab(entity.TabID("t2"), entity.WorkspaceID("ws2"), p2)
785 p3 := entity.NewPane(entity.PaneID("p3"))
786 t3 := entity.NewTab(entity.TabID("t3"), entity.WorkspaceID("ws3"), p3)
787 app.tabs = entity.NewTabList()
788 app.tabs.Add(t1)
789 app.tabs.Add(t2)
790 app.tabs.Add(t3)
791 w1.tabs.Add(t1)
792 w2.tabs.Add(t2)
793 w3.tabs.Add(t3)
794 app.windowForTab = map[entity.TabID]*browserWindow{
795 t1.ID: w1,
796 t2.ID: w2,
797 t3.ID: w3,
798 }
799
800 app.lastFocusedWindowID = "w2"
801
802 lists := app.GetWindowTabLists()
803 idx := app.GetActiveWindowIndex()
804
805 require.Len(t, lists, 3)
806 assert.Equal(t, entity.WindowID("w2"), lists[idx].WindowID, "active window ID must match the lists index")
807}
808
809func TestWindowOrder_FallbackWhenRegistrationOrderEmpty(t *testing.T) {
810 // When browserWindowOrder is empty (e.g., old tests / direct map manipulation),
811 // windowOrder must fall back to a deterministic (sorted) order.
812 app := &App{
813 browserWindows: map[string]*browserWindow{
814 "z-window": {id: "z-window"},
815 "a-window": {id: "a-window"},
816 },
817 }
818
819 order := app.windowOrder()
820
821 require.Len(t, order, 2)
822 // Sorted order: "a-window" < "z-window"
823 assert.Equal(t, "a-window", order[0])
824 assert.Equal(t, "z-window", order[1])
825}
826
827func TestRemoveBrowserWindow_RemovesFromOrder(t *testing.T) {
828 app := &App{
829 browserWindows: make(map[string]*browserWindow),
830 }
831
832 w1 := &browserWindow{id: "w1"}
833 w2 := &browserWindow{id: "w2"}
834 w3 := &browserWindow{id: "w3"}
835 app.registerBrowserWindow(w1)
836 app.registerBrowserWindow(w2)
837 app.registerBrowserWindow(w3)
838
839 app.removeBrowserWindow("w2")
840
841 require.Len(t, app.browserWindowOrder, 2)
842 assert.Equal(t, "w1", app.browserWindowOrder[0])
843 assert.Equal(t, "w3", app.browserWindowOrder[1])
844}
845
846func TestRegisterBrowserWindow_DuplicateIgnores(t *testing.T) {
847 app := &App{
848 browserWindows: make(map[string]*browserWindow),
849 }
850
851 bw := &browserWindow{id: "w1"}
852 app.registerBrowserWindow(bw)
853 app.registerBrowserWindow(bw) // duplicate
854
855 require.Len(t, app.browserWindowOrder, 1)
856}
857
858func TestApp_GetWindowTabListsUsesCreationOrderAndActiveIndex(t *testing.T) {
859 // Create two windows with per-window bw.tabs as the real source of truth.
860 // Ensure GetWindowTabLists returns them in registration order, with correct
861 // WindowID, and that the returned Tabs pointers are the per-window bw.tabs
862 // (not copies or derived from global App.tabs).
863 firstTabs := entity.NewTabList()
864 firstTab := entity.NewTab(entity.TabID("tab-1"), entity.WorkspaceID("ws-1"), entity.NewPane(entity.PaneID("pane-1")))
865 firstTabs.Add(firstTab)
866 firstTabs.SetActive(entity.TabID("tab-1"))
867
868 secondTabs := entity.NewTabList()
869 secondTab := entity.NewTab(entity.TabID("tab-2"), entity.WorkspaceID("ws-2"), entity.NewPane(entity.PaneID("pane-2")))
870 secondTabs.Add(secondTab)
871
872 first := &browserWindow{id: "window-1", tabs: firstTabs}
873 second := &browserWindow{id: "window-2", tabs: secondTabs}
874
875 app := &App{
876 browserWindows: map[string]*browserWindow{first.id: first, second.id: second},
877 browserWindowOrder: []string{"window-1", "window-2"},
878 lastFocusedWindowID: "window-2",
879 }
880
881 result := app.GetWindowTabLists()
882
883 require.Len(t, result, 2)
884 assert.Equal(t, entity.WindowID("window-1"), result[0].WindowID)
885 assert.Equal(t, entity.WindowID("window-2"), result[1].WindowID)
886
887 // The returned Tabs are snapshot copies of per-window bw.tabs.
888 // Verify structural equality (not pointer identity, since
889 // GetWindowTabLists returns a snapshot for safe concurrent read).
890 assert.Equal(t, firstTabs.ActiveTabID, result[0].Tabs.ActiveTabID)
891 assert.Len(t, result[0].Tabs.Tabs, len(firstTabs.Tabs))
892 assert.Equal(t, secondTabs.ActiveTabID, result[1].Tabs.ActiveTabID)
893 assert.Len(t, result[1].Tabs.Tabs, len(secondTabs.Tabs))
894
895 // Active state reflects per-window bw.tabs.
896 assert.Equal(t, entity.TabID("tab-1"), result[0].Tabs.ActiveTabID)
897 assert.Equal(t, entity.TabID("tab-2"), result[1].Tabs.ActiveTabID)
898
899 // GetActiveWindowIndex respects lastFocusedWindowID mapped to registration order.
900 idx := app.GetActiveWindowIndex()
901 assert.Equal(t, 1, idx, "window-2 is second registered, index should be 1")
902}
903
904func TestApp_AssignRestoredWindowTabListsUsesFreshRuntimeWindowIDs(t *testing.T) {
905 // Create two restored TabLists with distinct tabs.
906 firstTabs := entity.NewTabList()
907 firstTab := entity.NewTab(entity.TabID("tab-1"), entity.WorkspaceID("ws-1"), entity.NewPane(entity.PaneID("pane-1")))
908 firstTabs.Add(firstTab)
909 firstTabs.SetActive(entity.TabID("tab-1"))
910
911 secondTabs := entity.NewTabList()
912 secondTab := entity.NewTab(entity.TabID("tab-2"), entity.WorkspaceID("ws-2"), entity.NewPane(entity.PaneID("pane-2")))
913 secondTabs.Add(secondTab)
914
915 // Create two runtime browser windows with fresh IDs distinct from saved IDs.
916 firstBW := &browserWindow{id: "runtime-w1"}
917 secondBW := &browserWindow{id: "runtime-w2"}
918
919 app := &App{
920 browserWindows: map[string]*browserWindow{"runtime-w1": firstBW, "runtime-w2": secondBW},
921 }
922
923 // Assign restored state using saved window IDs (simulating session restore).
924 app.assignRestoredWindowTabList(firstBW, entity.WindowTabListState{
925 WindowID: "saved-w1",
926 Tabs: firstTabs,
927 })
928 app.assignRestoredWindowTabList(secondBW, entity.WindowTabListState{
929 WindowID: "saved-w2",
930 Tabs: secondTabs,
931 })
932
933 // Tabs must be owned by runtime windows, not saved.
934 require.Same(t, firstTabs, firstBW.tabs, "first window tabs must be assigned")
935 require.Same(t, secondTabs, secondBW.tabs, "second window tabs must be assigned")
936
937 // windowForTab must map tabs to runtime windows.
938 require.Equal(t, firstBW, app.browserWindowForTab(firstTab.ID), "tab-1 must be owned by runtime-w1")
939 require.Equal(t, secondBW, app.browserWindowForTab(secondTab.ID), "tab-2 must be owned by runtime-w2")
940
941 // Saved window IDs must never leak into browserWindows.
942 require.Nil(t, app.browserWindows["saved-w1"], "saved-w1 must not appear in browserWindows")
943 require.Nil(t, app.browserWindows["saved-w2"], "saved-w2 must not appear in browserWindows")
944 assertWindowOwnershipInvariant(t, app)
945}
946
947func TestApp_BrowserWindowsOwnIndependentTabLists(t *testing.T) {
948 // Construct first browser window with its own TabList and distinct tabs.
949 firstTabs := entity.NewTabList()
950 firstTab := entity.NewTab(entity.TabID("tab-first"), entity.WorkspaceID("ws-first"), entity.NewPane(entity.PaneID("pane-first")))
951 firstTabs.Add(firstTab)
952
953 first := &browserWindow{
954 id: "window-first",
955 tabs: firstTabs,
956 }
957
958 // Construct second browser window with its own TabList and distinct tabs.
959 secondTabs := entity.NewTabList()
960 secondTab := entity.NewTab(entity.TabID("tab-second"), entity.WorkspaceID("ws-second"), entity.NewPane(entity.PaneID("pane-second")))
961 secondTabs.Add(secondTab)
962
963 second := &browserWindow{
964 id: "window-second",
965 tabs: secondTabs,
966 }
967
968 app := &App{
969 browserWindows: map[string]*browserWindow{
970 first.id: first,
971 second.id: second,
972 },
973 }
974
975 // tabListForBrowserWindow returns the correct TabList for each window.
976 got := app.tabListForBrowserWindow(first)
977 if got != firstTabs {
978 t.Fatalf("tabListForBrowserWindow(first) = %p, want %p", got, firstTabs)
979 }
980 got = app.tabListForBrowserWindow(second)
981 if got != secondTabs {
982 t.Fatalf("tabListForBrowserWindow(second) = %p, want %p", got, secondTabs)
983 }
984
985 // Each window's tabs do not contain the other window's tabs.
986 if firstTabs.Find(secondTab.ID) != nil {
987 t.Fatalf("first window tabs unexpectedly contain second tab")
988 }
989 if secondTabs.Find(firstTab.ID) != nil {
990 t.Fatalf("second window tabs unexpectedly contain first tab")
991 }
992
993 // activeTabForBrowserWindow returns the correct active tab for each window.
994 gotTab := app.activeTabForBrowserWindow(first)
995 if gotTab != firstTab {
996 t.Fatalf("activeTabForBrowserWindow(first) = %p, want %p", gotTab, firstTab)
997 }
998 gotTab = app.activeTabForBrowserWindow(second)
999 if gotTab != secondTab {
1000 t.Fatalf("activeTabForBrowserWindow(second) = %p, want %p", gotTab, secondTab)
1001 }
1002
1003 app.windowForTab = map[entity.TabID]*browserWindow{firstTab.ID: first, secondTab.ID: second}
1004 assertWindowOwnershipInvariant(t, app)
1005}
1006
1007func TestRestoreSession_RemovesStaleBrowserWindows(t *testing.T) {
1008 // After restore, only runtime windows should remain in browserWindows
1009 // and browserWindowOrder. Stale pre-existing windows must be removed.
1010
1011 // Runtime windows with tabs (what restoreSession produces).
1012 runtime1Tabs := entity.NewTabList()
1013 r1Tab := entity.NewTab(entity.TabID("r1-t1"), entity.WorkspaceID("ws-r1"), entity.NewPane(entity.PaneID("p-r1")))
1014 runtime1Tabs.Add(r1Tab)
1015 runtime1Tabs.SetActive(entity.TabID("r1-t1"))
1016
1017 runtime2Tabs := entity.NewTabList()
1018 r2Tab := entity.NewTab(entity.TabID("r2-t1"), entity.WorkspaceID("ws-r2"), entity.NewPane(entity.PaneID("p-r2")))
1019 runtime2Tabs.Add(r2Tab)
1020 runtime2Tabs.SetActive(entity.TabID("r2-t1"))
1021
1022 runtime1 := &browserWindow{id: "runtime-w1", tabs: runtime1Tabs}
1023 runtime2 := &browserWindow{id: "runtime-w2", tabs: runtime2Tabs}
1024
1025 // Stale pre-existing browser window (no longer in runtime set).
1026 staleTabs := entity.NewTabList()
1027 staleTab := entity.NewTab(entity.TabID("stale-t1"), entity.WorkspaceID("ws-stale"), entity.NewPane(entity.PaneID("p-stale")))
1028 staleTabs.Add(staleTab)
1029 stale := &browserWindow{id: "stale-window", tabs: staleTabs}
1030
1031 app := &App{
1032 browserWindows: map[string]*browserWindow{
1033 "stale-window": stale,
1034 "runtime-w1": runtime1,
1035 "runtime-w2": runtime2,
1036 },
1037 tabs: entity.NewTabList(),
1038 windowForTab: map[entity.TabID]*browserWindow{
1039 staleTab.ID: stale,
1040 r1Tab.ID: runtime1,
1041 r2Tab.ID: runtime2,
1042 },
1043 }
1044
1045 // Simulate post-restore cleanup: remove stale windows not in runtime set.
1046 runtimeIDs := []string{"runtime-w1", "runtime-w2"}
1047 app.pruneStaleBrowserWindows([]*browserWindow{runtime1, runtime2})
1048
1049 // After cleanup, browserWindows contains only runtime windows.
1050 require.Len(t, app.browserWindows, 2)
1051 require.NotNil(t, app.browserWindows["runtime-w1"])
1052 require.NotNil(t, app.browserWindows["runtime-w2"])
1053 require.Nil(t, app.browserWindows["stale-window"])
1054
1055 // browserWindowOrder matches runtime order.
1056 require.Equal(t, runtimeIDs, app.browserWindowOrder)
1057
1058 // GetWindowTabLists returns only runtime windows.
1059 result := app.GetWindowTabLists()
1060 require.Len(t, result, 2)
1061 assert.Equal(t, entity.WindowID("runtime-w1"), result[0].WindowID)
1062 assert.Equal(t, entity.WindowID("runtime-w2"), result[1].WindowID)
1063
1064 // No stale tabs leak into the result.
1065 // Verify structural equality (snapshot copies, not live pointers).
1066 assert.Equal(t, runtime1Tabs.ActiveTabID, result[0].Tabs.ActiveTabID)
1067 assert.Len(t, result[0].Tabs.Tabs, len(runtime1Tabs.Tabs))
1068 assert.Equal(t, runtime2Tabs.ActiveTabID, result[1].Tabs.ActiveTabID)
1069 assert.Len(t, result[1].Tabs.Tabs, len(runtime2Tabs.Tabs))
1070 assertWindowOwnershipInvariant(t, app)
1071}
1072
1073func TestRestoreSession_BrowserWindowOrderStartsWithFirstReusedWindow(t *testing.T) {
1074 // When the first restored window reuses a browser window that was not
1075 // already in browserWindowOrder, the restored order must start with
1076 // the reused window ID followed by newly-created window IDs.
1077
1078 reuseTabs := entity.NewTabList()
1079 reuseTab := entity.NewTab(entity.TabID("reuse-t1"), entity.WorkspaceID("ws-reuse"), entity.NewPane(entity.PaneID("p-reuse")))
1080 reuseTabs.Add(reuseTab)
1081 reuseBW := &browserWindow{id: "reused-window", tabs: reuseTabs}
1082
1083 createdTabs := entity.NewTabList()
1084 createdTab := entity.NewTab(entity.TabID("created-t1"), entity.WorkspaceID("ws-created"), entity.NewPane(entity.PaneID("p-created")))
1085 createdTabs.Add(createdTab)
1086 createdBW := &browserWindow{id: "created-window", tabs: createdTabs}
1087
1088 // Pre-existing browserWindowOrder does NOT contain the reused window.
1089 app := &App{
1090 browserWindows: map[string]*browserWindow{
1091 "reused-window": reuseBW,
1092 "created-window": createdBW,
1093 },
1094 browserWindowOrder: []string{"old-stale-window"}, // reused window not present
1095 tabs: entity.NewTabList(),
1096 windowForTab: map[entity.TabID]*browserWindow{},
1097 }
1098
1099 // Exercise the helper used by restoreSession to prune stale windows and
1100 // replace browserWindowOrder with the runtime restore order.
1101 runtimeWindows := []*browserWindow{reuseBW, createdBW}
1102 app.pruneStaleBrowserWindows(runtimeWindows)
1103
1104 require.Len(t, app.browserWindowOrder, 2)
1105 assert.Equal(t, "reused-window", app.browserWindowOrder[0],
1106 "first reused window must be first in order")
1107 assert.Equal(t, "created-window", app.browserWindowOrder[1],
1108 "newly-created window must follow the reused window")
1109
1110 // GetWindowTabLists respects the order.
1111 result := app.GetWindowTabLists()
1112 require.Len(t, result, 2)
1113 assert.Equal(t, entity.WindowID("reused-window"), result[0].WindowID)
1114 assert.Equal(t, entity.WindowID("created-window"), result[1].WindowID)
1115}
1116
1117func TestRestoreSession_ActiveWindowIndexSyncsState(t *testing.T) {
1118 // After restore, GetActiveWindowIndex must return the restored active index.
1119 // The focused window state must be consistent: lastFocusedWindowID matches
1120 // the window at that index, and the active window's tab target aligns.
1121
1122 runtime1Tabs := entity.NewTabList()
1123 r1Tab := entity.NewTab(entity.TabID("a1-t1"), entity.WorkspaceID("ws-a1"), entity.NewPane(entity.PaneID("p-a1")))
1124 runtime1Tabs.Add(r1Tab)
1125 runtime1Tabs.SetActive(entity.TabID("a1-t1"))
1126
1127 runtime2Tabs := entity.NewTabList()
1128 r2Tab := entity.NewTab(entity.TabID("a2-t1"), entity.WorkspaceID("ws-a2"), entity.NewPane(entity.PaneID("p-a2")))
1129 runtime2Tabs.Add(r2Tab)
1130 runtime2Tabs.SetActive(entity.TabID("a2-t1"))
1131
1132 runtime1 := &browserWindow{id: "active-w1", tabs: runtime1Tabs}
1133 runtime2 := &browserWindow{id: "active-w2", tabs: runtime2Tabs}
1134
1135 app := &App{
1136 browserWindows: map[string]*browserWindow{
1137 "active-w1": runtime1,
1138 "active-w2": runtime2,
1139 },
1140 browserWindowOrder: []string{"active-w1", "active-w2"},
1141 lastFocusedWindowID: "active-w2",
1142 tabs: entity.NewTabList(),
1143 windowForTab: map[entity.TabID]*browserWindow{},
1144 }
1145
1146 // Active index should be 1 (second window in order).
1147 idx := app.GetActiveWindowIndex()
1148 assert.Equal(t, 1, idx)
1149
1150 // The focused window is the one at that index.
1151 focused := app.lastFocusedBrowserWindow()
1152 require.NotNil(t, focused)
1153 assert.Equal(t, "active-w2", focused.id)
1154
1155 // Verify that GetActiveWindowIndex and GetWindowTabLists are consistent:
1156 // the window at the active index matches the focused window.
1157 result := app.GetWindowTabLists()
1158 require.Len(t, result, 2)
1159 assert.Equal(t, entity.WindowID("active-w2"), result[idx].WindowID,
1160 "window at active index must match focused window ID")
1161}
1162
1163func TestApp_PermissionDialogForPane(t *testing.T) {
1164 tabID := entity.TabID("tab-1")
1165 tabPane := entity.PaneID("pane-in-tab")
1166 floatingPane := entity.PaneID("pane-floating")
1167 tab := entity.NewTab(tabID, entity.WorkspaceID("ws-1"), entity.NewPane(tabPane))
1168 tabs := entity.NewTabList()
1169 tabs.Add(tab)
1170 dialogA := &testPermissionDialogPresenter{}
1171 owner := &browserWindow{id: "window-1", tabs: tabs, permissionDialog: dialogA}
1172 noDialog := &browserWindow{id: "window-2", tabs: entity.NewTabList()}
1173 app := &App{
1174 browserWindows: map[string]*browserWindow{owner.id: owner, noDialog.id: noDialog},
1175 windowForTab: map[entity.TabID]*browserWindow{tabID: owner},
1176 floatingSessions: map[floatingSessionKey]*floatingWorkspaceSession{
1177 {tabID: tabID, sessionID: "profile"}: {paneID: floatingPane},
1178 },
1179 }
1180
1181 assert.Same(t, dialogA, app.permissionDialogForPane(tabPane), "tiled pane")
1182 assert.Same(t, dialogA, app.permissionDialogForPane(floatingPane), "floating pane uses its tab's window")
1183 assert.Nil(t, app.permissionDialogForPane("unknown"), "unowned pane clears the presenter")
1184 assert.Nil(t, app.permissionDialogForPane(""))
1185
1186 owner.permissionDialog = nil
1187 assert.Nil(t, app.permissionDialogForPane(tabPane), "window without dialog")
1188}
1189
1190func TestApp_ResetWebRTCIndicatorOnNavigationCoversFloatingPanes(t *testing.T) {
1191 tab := entity.NewTab(entity.TabID("tab-1"), entity.WorkspaceID("workspace-1"), entity.NewPane(entity.PaneID("pane-1")))
1192 tabs := entity.NewTabList()
1193 tabs.Add(tab)
1194 indicator := &component.WebRTCPermissionIndicator{}
1195 bw := &browserWindow{id: "window-1", tabs: tabs, webrtcIndicator: indicator}
1196 floatingPaneID := entity.PaneID("floating-pane")
1197 app := &App{
1198 browserWindows: map[string]*browserWindow{bw.id: bw},
1199 windowForTab: map[entity.TabID]*browserWindow{tab.ID: bw},
1200 floatingSessions: map[floatingSessionKey]*floatingWorkspaceSession{
1201 {tabID: tab.ID, sessionID: "profile:one"}: {paneID: floatingPaneID},
1202 },
1203 }
1204
1205 indicator.SetOrigin("https://old.example")
1206 app.resetWebRTCIndicatorOnNavigation(floatingPaneID, "https://old.example/page")
1207 assert.Equal(t, "https://old.example", indicator.Origin(), "same-origin navigation keeps the indicator")
1208
1209 app.resetWebRTCIndicatorOnNavigation(floatingPaneID, "https://new.example/")
1210 assert.Empty(t, indicator.Origin(), "cross-origin navigation in a floating pane resets the indicator")
1211}