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