1package ui
2
3import (
4 "context"
5 "errors"
6 "reflect"
7 "testing"
8 "unsafe"
9
10 "github.com/bnema/dumber/internal/application/port"
11 "github.com/bnema/dumber/internal/application/usecase"
12 "github.com/bnema/dumber/internal/domain/entity"
13 "github.com/bnema/dumber/internal/infrastructure/config"
14 "github.com/bnema/dumber/internal/shared/syncdispatch"
15 "github.com/bnema/dumber/internal/ui/component"
16 contentcoord "github.com/bnema/dumber/internal/ui/coordinator/content"
17 "github.com/bnema/dumber/internal/ui/focus"
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", &component.Toaster{})
38 setShellField(t, removed, "borderMgr", &focus.BorderManager{})
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 "borderMgr",
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_RemoveBrowserWindowCleansOwnedTabState(t *testing.T) {
278 ownedTab := entity.NewTab(entity.TabID("tab-owned"), entity.WorkspaceID("workspace-owned"), entity.NewPane(entity.PaneID("pane-owned")))
279 otherTab := entity.NewTab(entity.TabID("tab-other"), entity.WorkspaceID("workspace-other"), entity.NewPane(entity.PaneID("pane-other")))
280 removedMainWindow := &window.MainWindow{}
281 remainingMainWindow := &window.MainWindow{}
282 removedTabs := entity.NewTabList()
283 removedTabs.Add(ownedTab)
284 remainingTabs := entity.NewTabList()
285 remainingTabs.Add(otherTab)
286 removed := &browserWindow{id: "window-1", mainWindow: removedMainWindow, tabs: removedTabs}
287 remaining := &browserWindow{id: "window-2", mainWindow: remainingMainWindow, tabs: remainingTabs}
288 ownedSessionKey := floatingSessionKey{tabID: ownedTab.ID, sessionID: "profile:owned"}
289 otherSessionKey := floatingSessionKey{tabID: otherTab.ID, sessionID: "profile:other"}
290 app := &App{
291 mainWindow: removedMainWindow,
292 browserWindows: map[string]*browserWindow{removed.id: removed, remaining.id: remaining},
293 tabs: entity.NewTabList(),
294 workspaceViews: map[entity.TabID]*component.WorkspaceView{ownedTab.ID: &component.WorkspaceView{}, otherTab.ID: &component.WorkspaceView{}},
295 windowForTab: map[entity.TabID]*browserWindow{ownedTab.ID: removed, otherTab.ID: remaining},
296 floatingSessions: map[floatingSessionKey]*floatingWorkspaceSession{
297 ownedSessionKey: {},
298 otherSessionKey: {},
299 },
300 }
301 app.tabs.Add(ownedTab)
302 app.tabs.Add(otherTab)
303
304 app.removeBrowserWindow(removed.id)
305
306 if app.workspaceViews[ownedTab.ID] != nil {
307 t.Fatalf("owned workspace view was not removed")
308 }
309 if app.windowForTab[ownedTab.ID] != nil {
310 t.Fatalf("owned window mapping was not removed")
311 }
312 if app.tabs.Find(ownedTab.ID) != nil {
313 t.Fatalf("owned tab was not removed")
314 }
315 if app.workspaceViews[otherTab.ID] == nil {
316 t.Fatalf("other workspace view should remain")
317 }
318 if app.windowForTab[otherTab.ID] != remaining {
319 t.Fatalf("other tab should remain mapped to remaining window")
320 }
321 if app.tabs.Find(otherTab.ID) == nil {
322 t.Fatalf("other tab should remain")
323 }
324 if _, ok := app.floatingSessions[ownedSessionKey]; ok {
325 t.Fatalf("owned floating session was not released")
326 }
327 if _, ok := app.floatingSessions[otherSessionKey]; !ok {
328 t.Fatalf("other floating session should remain")
329 }
330 assertWindowOwnershipInvariant(t, app)
331}
332
333func TestBrowserWindow_RegisterAndRemoveTrackCollection(t *testing.T) {
334 app := &App{browserWindows: make(map[string]*browserWindow)}
335 first := &browserWindow{id: "window-1"}
336 second := &browserWindow{id: "window-2"}
337
338 app.registerBrowserWindow(first)
339 app.registerBrowserWindow(second)
340
341 if got := len(app.browserWindows); got != 2 {
342 t.Fatalf("browserWindows length = %d, want 2", got)
343 }
344 if _, ok := app.browserWindows[first.id]; !ok {
345 t.Fatalf("first browser window was not registered")
346 }
347 if _, ok := app.browserWindows[second.id]; !ok {
348 t.Fatalf("second browser window was not registered")
349 }
350 if first.tabs == nil || second.tabs == nil {
351 t.Fatalf("registered browser windows must have non-nil tab lists")
352 }
353
354 app.removeBrowserWindow(first.id)
355
356 if _, ok := app.browserWindows[first.id]; ok {
357 t.Fatalf("first browser window was not removed")
358 }
359 if _, ok := app.browserWindows[second.id]; !ok {
360 t.Fatalf("second browser window should remain registered")
361 }
362}
363
364func TestBrowserWindow_RemovePromotesRemainingMainWindow(t *testing.T) {
365 mainWindow := &window.MainWindow{}
366 otherWindow := &window.MainWindow{}
367 first := &browserWindow{id: "window-1", mainWindow: mainWindow}
368 second := &browserWindow{id: "window-2", mainWindow: otherWindow}
369 app := &App{
370 mainWindow: mainWindow,
371 browserWindows: map[string]*browserWindow{first.id: first, second.id: second},
372 }
373
374 app.removeBrowserWindow(first.id)
375
376 if app.mainWindow != otherWindow {
377 t.Fatalf("mainWindow = %p, want promoted window %p", app.mainWindow, otherWindow)
378 }
379 if len(app.browserWindows) != 1 {
380 t.Fatalf("browserWindows length = %d, want 1", len(app.browserWindows))
381 }
382 if _, ok := app.browserWindows[second.id]; !ok {
383 t.Fatalf("remaining browser window was not kept")
384 }
385}
386
387func TestBrowserWindow_RemoveLastClearsMainWindow(t *testing.T) {
388 mainWindow := &window.MainWindow{}
389 first := &browserWindow{id: "window-1", mainWindow: mainWindow}
390 app := &App{
391 mainWindow: mainWindow,
392 browserWindows: map[string]*browserWindow{first.id: first},
393 }
394
395 app.removeBrowserWindow(first.id)
396
397 if app.mainWindow != nil {
398 t.Fatalf("mainWindow = %p, want nil", app.mainWindow)
399 }
400 if len(app.browserWindows) != 0 {
401 t.Fatalf("browserWindows length = %d, want 0", len(app.browserWindows))
402 }
403}
404
405func TestBrowserWindow_RemovePromotedWindowClearsResizeModeBorderTarget(t *testing.T) {
406 mainWindow := &window.MainWindow{}
407 otherWindow := &window.MainWindow{}
408 removed := &browserWindow{id: "window-1", mainWindow: mainWindow}
409 remaining := &browserWindow{id: "window-2", mainWindow: otherWindow}
410 resizeTarget := &testLayoutWidget{}
411 app := &App{
412 mainWindow: mainWindow,
413 browserWindows: map[string]*browserWindow{removed.id: removed, remaining.id: remaining},
414 resizeModeBorderTarget: resizeTarget,
415 }
416
417 app.removeBrowserWindow(removed.id)
418
419 if app.resizeModeBorderTarget != nil {
420 t.Fatalf("resizeModeBorderTarget = %p, want nil", app.resizeModeBorderTarget)
421 }
422}
423
424func TestOpenFreshWindow_DispatchesAndTracksBrowserWindow(t *testing.T) {
425 app := &App{browserWindows: make(map[string]*browserWindow)}
426
427 dispatched := false
428 createdURL := ""
429 app.dispatchOnMainThread = func(label string, fn func()) syncdispatch.SyncDispatchResult {
430 dispatched = true
431 if label != "ui.open_fresh_window" {
432 t.Fatalf("dispatch label = %q, want ui.open_fresh_window", label)
433 }
434 fn()
435 return syncdispatch.SyncDispatchResult{Label: label, Status: syncdispatch.SyncDispatchCompleted}
436 }
437 app.browserWindowFactory = func(ctx context.Context, url string) (*browserWindow, error) {
438 createdURL = url
439 return &browserWindow{id: "window-1", initialURL: url}, nil
440 }
441
442 if err := app.OpenFreshWindow(context.Background(), "https://example.com"); err != nil {
443 t.Fatalf("OpenFreshWindow returned error: %v", err)
444 }
445 if !dispatched {
446 t.Fatalf("OpenFreshWindow did not use the main-thread dispatcher")
447 }
448 if createdURL != "https://example.com" {
449 t.Fatalf("browser window factory URL = %q, want %q", createdURL, "https://example.com")
450 }
451 if got := len(app.browserWindows); got != 1 {
452 t.Fatalf("browserWindows length = %d, want 1", got)
453 }
454 if got := app.browserWindows["window-1"]; got == nil {
455 t.Fatalf("browser window was not tracked")
456 }
457 if app.browserWindows["window-1"].initialURL != "https://example.com" {
458 t.Fatalf("browser window initialURL = %q, want %q", app.browserWindows["window-1"].initialURL, "https://example.com")
459 }
460}
461
462func TestOpenInitialBrowserWindowShellRegistersWithoutCreatingTab(t *testing.T) {
463 app := &App{
464 tabs: entity.NewTabList(),
465 tabsUC: usecase.NewManageTabsUseCase(func() string { return "unexpected-tab" }, nil),
466 browserWindows: make(map[string]*browserWindow),
467 }
468 app.browserWindowFactory = func(_ context.Context, initialURL string) (*browserWindow, error) {
469 return &browserWindow{id: "window-1", initialURL: initialURL, tabs: entity.NewTabList()}, nil
470 }
471
472 if err := app.openInitialBrowserWindowShell(context.Background(), "https://example.com"); err != nil {
473 t.Fatalf("openInitialBrowserWindowShell returned error: %v", err)
474 }
475 if got := len(app.browserWindows); got != 1 {
476 t.Fatalf("browserWindows length = %d, want 1", got)
477 }
478 if app.lastFocusedWindowID != "window-1" {
479 t.Fatalf("lastFocusedWindowID = %q, want window-1", app.lastFocusedWindowID)
480 }
481 if got := app.tabs.Count(); got != 0 {
482 t.Fatalf("tabs count = %d, want 0; initial shell should wait for createInitialTab", got)
483 }
484 windowTabs := app.browserWindows["window-1"].tabs
485 require.NotNil(t, windowTabs)
486 if got := windowTabs.Count(); got != 0 {
487 t.Fatalf("window tabs count = %d, want 0; initial shell should wait for createInitialTab", got)
488 }
489 require.Equal(t, "https://example.com", app.browserWindows["window-1"].initialURL)
490}
491
492func TestOpenFreshWindow_FirstWindowWithRequestedURLCreatesTabInAppAndWindow(t *testing.T) {
493 app := &App{
494 tabs: entity.NewTabList(),
495 tabsUC: usecase.NewManageTabsUseCase(counterIDGen(), nil),
496 browserWindows: make(map[string]*browserWindow),
497 workspaceViews: map[entity.TabID]*component.WorkspaceView{},
498 windowForTab: map[entity.TabID]*browserWindow{},
499 }
500 app.dispatchOnMainThread = func(label string, fn func()) syncdispatch.SyncDispatchResult {
501 fn()
502 return syncdispatch.SyncDispatchResult{Label: label, Status: syncdispatch.SyncDispatchCompleted}
503 }
504 app.browserWindowFactory = func(context.Context, string) (*browserWindow, error) {
505 return &browserWindow{id: "window-1", tabs: entity.NewTabList()}, nil
506 }
507 app.workspaceViewCreateOverride = func(_ context.Context, tab *entity.Tab) bool {
508 app.workspaceViews[tab.ID] = &component.WorkspaceView{}
509 return true
510 }
511
512 if err := app.OpenFreshWindow(context.Background(), "https://example.com"); err != nil {
513 t.Fatalf("OpenFreshWindow returned error: %v", err)
514 }
515
516 windowTabs := app.browserWindows["window-1"].tabs
517 require.NotNil(t, windowTabs)
518 require.Equal(t, 1, app.tabs.Count())
519 require.Equal(t, 1, windowTabs.Count())
520 tab := windowTabs.ActiveTab()
521 require.NotNil(t, tab)
522 require.NotNil(t, tab.Workspace)
523 activePane := tab.Workspace.FindPane(tab.Workspace.ActivePaneID)
524 require.NotNil(t, activePane)
525 require.Equal(t, "https://example.com", activePane.Pane.URI)
526 require.NotNil(t, app.tabs.Find(tab.ID))
527 require.Same(t, app.browserWindows["window-1"], app.browserWindowForTab(tab.ID))
528}
529
530func TestOpenFreshWindow_PropagatesFactoryError(t *testing.T) {
531 app := &App{browserWindows: make(map[string]*browserWindow)}
532 app.dispatchOnMainThread = func(label string, fn func()) syncdispatch.SyncDispatchResult {
533 fn()
534 return syncdispatch.SyncDispatchResult{Label: label, Status: syncdispatch.SyncDispatchCompleted}
535 }
536 wantErr := errors.New("factory error")
537 app.browserWindowFactory = func(context.Context, string) (*browserWindow, error) {
538 return nil, wantErr
539 }
540
541 err := app.OpenFreshWindow(context.Background(), "https://example.com")
542
543 if !errors.Is(err, wantErr) {
544 t.Fatalf("expected error %v, got %v", wantErr, err)
545 }
546 if got := len(app.browserWindows); got != 0 {
547 t.Fatalf("browserWindows length = %d, want 0", got)
548 }
549}
550
551func setShellField(t *testing.T, bw *browserWindow, name string, value any) {
552 t.Helper()
553 rv := reflect.ValueOf(bw).Elem()
554 field := rv.FieldByName(name)
555 if !field.IsValid() {
556 t.Fatalf("browserWindow missing field %s", name)
557 }
558 reflect.NewAt(field.Type(), unsafe.Pointer(field.UnsafeAddr())).Elem().Set(reflect.ValueOf(value))
559}
560
561func fieldIsZero(t *testing.T, bw *browserWindow, name string) bool {
562 t.Helper()
563 rv := reflect.ValueOf(bw).Elem()
564 field := rv.FieldByName(name)
565 if !field.IsValid() {
566 t.Fatalf("browserWindow missing field %s", name)
567 }
568 return field.IsZero()
569}
570
571type testLayoutWidget struct{}
572
573func (*testLayoutWidget) Show() {}
574func (*testLayoutWidget) Hide() {}
575func (*testLayoutWidget) SetVisible(bool) {}
576func (*testLayoutWidget) IsVisible() bool { return false }
577func (*testLayoutWidget) SetOpacity(float64) {}
578func (*testLayoutWidget) GrabFocus() bool { return false }
579func (*testLayoutWidget) HasFocus() bool { return false }
580func (*testLayoutWidget) SetCanFocus(bool) {}
581func (*testLayoutWidget) SetFocusable(bool) {}
582func (*testLayoutWidget) SetFocusOnClick(bool) {}
583func (*testLayoutWidget) SetCanTarget(bool) {}
584func (*testLayoutWidget) SetHexpand(bool) {}
585func (*testLayoutWidget) SetVexpand(bool) {}
586func (*testLayoutWidget) GetHexpand() bool { return false }
587func (*testLayoutWidget) GetVexpand() bool { return false }
588func (*testLayoutWidget) SetHalign(gtk.Align) {}
589func (*testLayoutWidget) SetValign(gtk.Align) {}
590func (*testLayoutWidget) SetSizeRequest(int, int) {}
591func (*testLayoutWidget) GetAllocatedWidth() int { return 0 }
592func (*testLayoutWidget) GetAllocatedHeight() int { return 0 }
593func (*testLayoutWidget) ComputePoint(layout.Widget) (float64, float64, bool) {
594 return 0, 0, false
595}
596func (*testLayoutWidget) AddCssClass(string) {}
597func (*testLayoutWidget) RemoveCssClass(string) {}
598func (*testLayoutWidget) HasCssClass(string) bool { return false }
599func (*testLayoutWidget) Unparent() {}
600func (*testLayoutWidget) GetParent() layout.Widget { return nil }
601func (*testLayoutWidget) GtkWidget() *gtk.Widget { return nil }
602func (*testLayoutWidget) AddController(*gtk.EventController) {}
603
604type testPermissionDialogPresenter struct{}
605
606func (*testPermissionDialogPresenter) ShowPermissionDialog(context.Context, string, []entity.PermissionType, entity.PermissionMetadata, func(port.PermissionDialogResult)) {
607}
608
609// --- Window tab list tests ---
610
611func TestGetWindowTabLists_RespectsRegistrationOrder(t *testing.T) {
612 // Register windows in non-lexicographic order: window-2 first, then window-1.
613 // The result should preserve creation order, not sorted order.
614 app := &App{
615 browserWindows: make(map[string]*browserWindow),
616 }
617
618 first := &browserWindow{id: "window-2"}
619 second := &browserWindow{id: "window-1"}
620 app.registerBrowserWindow(first)
621 app.registerBrowserWindow(second)
622
623 // Give each window a tab so they show up in results.
624 pane1 := entity.NewPane(entity.PaneID("p-w2"))
625 tab1 := entity.NewTab(entity.TabID("t-w2"), entity.WorkspaceID("ws-w2"), pane1)
626 pane2 := entity.NewPane(entity.PaneID("p-w1"))
627 tab2 := entity.NewTab(entity.TabID("t-w1"), entity.WorkspaceID("ws-w1"), pane2)
628
629 app.tabs = entity.NewTabList()
630 app.tabs.Add(tab1)
631 app.tabs.Add(tab2)
632 first.tabs.Add(tab1)
633 second.tabs.Add(tab2)
634 app.windowForTab = map[entity.TabID]*browserWindow{
635 tab1.ID: first,
636 tab2.ID: second,
637 }
638
639 result := app.GetWindowTabLists()
640
641 require.Len(t, result, 2)
642 assert.Equal(t, entity.WindowID("window-2"), result[0].WindowID, "first registered window should be first")
643 assert.Equal(t, entity.WindowID("window-1"), result[1].WindowID, "second registered window should be second")
644}
645
646func TestGetWindowTabLists_NoBrowserWindowsReturnsGlobalSnapshot(t *testing.T) {
647 pane := entity.NewPane(entity.PaneID("p1"))
648 tab := entity.NewTab(entity.TabID("t1"), entity.WorkspaceID("ws1"), pane)
649 tabs := entity.NewTabList()
650 tabs.Add(tab)
651
652 app := &App{tabs: tabs}
653
654 result := app.GetWindowTabLists()
655
656 require.Len(t, result, 1)
657 assert.Equal(t, entity.WindowID(""), result[0].WindowID)
658 // Snapshot returns a copy so pointer identity is not expected; verify
659 // structural equality instead.
660 assert.Equal(t, tabs.ActiveTabID, result[0].Tabs.ActiveTabID)
661 assert.Len(t, result[0].Tabs.Tabs, len(tabs.Tabs))
662}
663
664func TestGetWindowTabLists_AppliesActiveTabIndex(t *testing.T) {
665 // A window with its own TabList where the second tab is active.
666 app := &App{
667 browserWindows: make(map[string]*browserWindow),
668 }
669
670 pane1 := entity.NewPane(entity.PaneID("p1"))
671 tab1 := entity.NewTab(entity.TabID("t1"), entity.WorkspaceID("ws1"), pane1)
672 pane2 := entity.NewPane(entity.PaneID("p2"))
673 tab2 := entity.NewTab(entity.TabID("t2"), entity.WorkspaceID("ws2"), pane2)
674
675 app.tabs = entity.NewTabList()
676 app.tabs.Add(tab1)
677 app.tabs.Add(tab2)
678
679 bwTabs := entity.NewTabList()
680 bwTabs.Add(tab1)
681 bwTabs.Add(tab2)
682 bwTabs.SetActive(entity.TabID("t2"))
683 bw := &browserWindow{id: "w1", tabs: bwTabs}
684 app.registerBrowserWindow(bw)
685 app.windowForTab = map[entity.TabID]*browserWindow{
686 tab1.ID: bw,
687 tab2.ID: bw,
688 }
689
690 result := app.GetWindowTabLists()
691
692 require.Len(t, result, 1)
693 assert.Equal(t, entity.TabID("t2"), result[0].Tabs.ActiveTabID, "should reflect bw.tabs.ActiveTabID")
694}
695
696func TestGetWindowTabLists_PreservesPreviousActiveTabID(t *testing.T) {
697 app := &App{
698 browserWindows: make(map[string]*browserWindow),
699 }
700
701 pane1 := entity.NewPane(entity.PaneID("p1"))
702 tab1 := entity.NewTab(entity.TabID("t1"), entity.WorkspaceID("ws1"), pane1)
703 pane2 := entity.NewPane(entity.PaneID("p2"))
704 tab2 := entity.NewTab(entity.TabID("t2"), entity.WorkspaceID("ws2"), pane2)
705
706 app.tabs = entity.NewTabList()
707 app.tabs.Add(tab1)
708 app.tabs.Add(tab2)
709
710 bwTabs := entity.NewTabList()
711 bwTabs.Add(tab1)
712 bwTabs.Add(tab2)
713 bwTabs.SetActive(entity.TabID("t2")) // t2 becomes active, t1 becomes previous
714
715 bw := &browserWindow{id: "w1", tabs: bwTabs}
716 app.registerBrowserWindow(bw)
717 app.windowForTab = map[entity.TabID]*browserWindow{
718 tab1.ID: bw,
719 tab2.ID: bw,
720 }
721
722 result := app.GetWindowTabLists()
723
724 require.Len(t, result, 1)
725 assert.Equal(t, entity.TabID("t1"), result[0].Tabs.PreviousActiveTabID, "should preserve bw.tabs.PreviousActiveTabID")
726}
727
728func TestGetActiveWindowIndex_MatchesRegistrationOrder(t *testing.T) {
729 // Register windows in non-lexicographic order; lastFocusedWindowID should
730 // map to the correct index in registration order.
731 app := &App{
732 browserWindows: make(map[string]*browserWindow),
733 }
734
735 app.registerBrowserWindow(&browserWindow{id: "z-window"})
736 app.registerBrowserWindow(&browserWindow{id: "a-window"})
737 app.lastFocusedWindowID = "a-window"
738
739 idx := app.GetActiveWindowIndex()
740 assert.Equal(t, 1, idx, "a-window is the second registered window, index should be 1")
741}
742
743func TestGetActiveWindowIndex_MatchesGetWindowTabLists(t *testing.T) {
744 // The order of windows in GetWindowTabLists must match the indices returned
745 // by GetActiveWindowIndex.
746 app := &App{
747 browserWindows: make(map[string]*browserWindow),
748 }
749
750 w1 := &browserWindow{id: "w1"}
751 w2 := &browserWindow{id: "w2"}
752 w3 := &browserWindow{id: "w3"}
753 app.registerBrowserWindow(w1)
754 app.registerBrowserWindow(w2)
755 app.registerBrowserWindow(w3)
756
757 // Give each window a tab.
758 p1 := entity.NewPane(entity.PaneID("p1"))
759 t1 := entity.NewTab(entity.TabID("t1"), entity.WorkspaceID("ws1"), p1)
760 p2 := entity.NewPane(entity.PaneID("p2"))
761 t2 := entity.NewTab(entity.TabID("t2"), entity.WorkspaceID("ws2"), p2)
762 p3 := entity.NewPane(entity.PaneID("p3"))
763 t3 := entity.NewTab(entity.TabID("t3"), entity.WorkspaceID("ws3"), p3)
764 app.tabs = entity.NewTabList()
765 app.tabs.Add(t1)
766 app.tabs.Add(t2)
767 app.tabs.Add(t3)
768 w1.tabs.Add(t1)
769 w2.tabs.Add(t2)
770 w3.tabs.Add(t3)
771 app.windowForTab = map[entity.TabID]*browserWindow{
772 t1.ID: w1,
773 t2.ID: w2,
774 t3.ID: w3,
775 }
776
777 app.lastFocusedWindowID = "w2"
778
779 lists := app.GetWindowTabLists()
780 idx := app.GetActiveWindowIndex()
781
782 require.Len(t, lists, 3)
783 assert.Equal(t, entity.WindowID("w2"), lists[idx].WindowID, "active window ID must match the lists index")
784}
785
786func TestWindowOrder_FallbackWhenRegistrationOrderEmpty(t *testing.T) {
787 // When browserWindowOrder is empty (e.g., old tests / direct map manipulation),
788 // windowOrder must fall back to a deterministic (sorted) order.
789 app := &App{
790 browserWindows: map[string]*browserWindow{
791 "z-window": {id: "z-window"},
792 "a-window": {id: "a-window"},
793 },
794 }
795
796 order := app.windowOrder()
797
798 require.Len(t, order, 2)
799 // Sorted order: "a-window" < "z-window"
800 assert.Equal(t, "a-window", order[0])
801 assert.Equal(t, "z-window", order[1])
802}
803
804func TestRemoveBrowserWindow_RemovesFromOrder(t *testing.T) {
805 app := &App{
806 browserWindows: make(map[string]*browserWindow),
807 }
808
809 w1 := &browserWindow{id: "w1"}
810 w2 := &browserWindow{id: "w2"}
811 w3 := &browserWindow{id: "w3"}
812 app.registerBrowserWindow(w1)
813 app.registerBrowserWindow(w2)
814 app.registerBrowserWindow(w3)
815
816 app.removeBrowserWindow("w2")
817
818 require.Len(t, app.browserWindowOrder, 2)
819 assert.Equal(t, "w1", app.browserWindowOrder[0])
820 assert.Equal(t, "w3", app.browserWindowOrder[1])
821}
822
823func TestRegisterBrowserWindow_DuplicateIgnores(t *testing.T) {
824 app := &App{
825 browserWindows: make(map[string]*browserWindow),
826 }
827
828 bw := &browserWindow{id: "w1"}
829 app.registerBrowserWindow(bw)
830 app.registerBrowserWindow(bw) // duplicate
831
832 require.Len(t, app.browserWindowOrder, 1)
833}
834
835func TestApp_GetWindowTabListsUsesCreationOrderAndActiveIndex(t *testing.T) {
836 // Create two windows with per-window bw.tabs as the real source of truth.
837 // Ensure GetWindowTabLists returns them in registration order, with correct
838 // WindowID, and that the returned Tabs pointers are the per-window bw.tabs
839 // (not copies or derived from global App.tabs).
840 firstTabs := entity.NewTabList()
841 firstTab := entity.NewTab(entity.TabID("tab-1"), entity.WorkspaceID("ws-1"), entity.NewPane(entity.PaneID("pane-1")))
842 firstTabs.Add(firstTab)
843 firstTabs.SetActive(entity.TabID("tab-1"))
844
845 secondTabs := entity.NewTabList()
846 secondTab := entity.NewTab(entity.TabID("tab-2"), entity.WorkspaceID("ws-2"), entity.NewPane(entity.PaneID("pane-2")))
847 secondTabs.Add(secondTab)
848
849 first := &browserWindow{id: "window-1", tabs: firstTabs}
850 second := &browserWindow{id: "window-2", tabs: secondTabs}
851
852 app := &App{
853 browserWindows: map[string]*browserWindow{first.id: first, second.id: second},
854 browserWindowOrder: []string{"window-1", "window-2"},
855 lastFocusedWindowID: "window-2",
856 }
857
858 result := app.GetWindowTabLists()
859
860 require.Len(t, result, 2)
861 assert.Equal(t, entity.WindowID("window-1"), result[0].WindowID)
862 assert.Equal(t, entity.WindowID("window-2"), result[1].WindowID)
863
864 // The returned Tabs are snapshot copies of per-window bw.tabs.
865 // Verify structural equality (not pointer identity, since
866 // GetWindowTabLists returns a snapshot for safe concurrent read).
867 assert.Equal(t, firstTabs.ActiveTabID, result[0].Tabs.ActiveTabID)
868 assert.Len(t, result[0].Tabs.Tabs, len(firstTabs.Tabs))
869 assert.Equal(t, secondTabs.ActiveTabID, result[1].Tabs.ActiveTabID)
870 assert.Len(t, result[1].Tabs.Tabs, len(secondTabs.Tabs))
871
872 // Active state reflects per-window bw.tabs.
873 assert.Equal(t, entity.TabID("tab-1"), result[0].Tabs.ActiveTabID)
874 assert.Equal(t, entity.TabID("tab-2"), result[1].Tabs.ActiveTabID)
875
876 // GetActiveWindowIndex respects lastFocusedWindowID mapped to registration order.
877 idx := app.GetActiveWindowIndex()
878 assert.Equal(t, 1, idx, "window-2 is second registered, index should be 1")
879}
880
881func TestApp_AssignRestoredWindowTabListsUsesFreshRuntimeWindowIDs(t *testing.T) {
882 // Create two restored TabLists with distinct tabs.
883 firstTabs := entity.NewTabList()
884 firstTab := entity.NewTab(entity.TabID("tab-1"), entity.WorkspaceID("ws-1"), entity.NewPane(entity.PaneID("pane-1")))
885 firstTabs.Add(firstTab)
886 firstTabs.SetActive(entity.TabID("tab-1"))
887
888 secondTabs := entity.NewTabList()
889 secondTab := entity.NewTab(entity.TabID("tab-2"), entity.WorkspaceID("ws-2"), entity.NewPane(entity.PaneID("pane-2")))
890 secondTabs.Add(secondTab)
891
892 // Create two runtime browser windows with fresh IDs distinct from saved IDs.
893 firstBW := &browserWindow{id: "runtime-w1"}
894 secondBW := &browserWindow{id: "runtime-w2"}
895
896 app := &App{
897 browserWindows: map[string]*browserWindow{"runtime-w1": firstBW, "runtime-w2": secondBW},
898 }
899
900 // Assign restored state using saved window IDs (simulating session restore).
901 app.assignRestoredWindowTabList(firstBW, entity.WindowTabListState{
902 WindowID: "saved-w1",
903 Tabs: firstTabs,
904 })
905 app.assignRestoredWindowTabList(secondBW, entity.WindowTabListState{
906 WindowID: "saved-w2",
907 Tabs: secondTabs,
908 })
909
910 // Tabs must be owned by runtime windows, not saved.
911 require.Same(t, firstTabs, firstBW.tabs, "first window tabs must be assigned")
912 require.Same(t, secondTabs, secondBW.tabs, "second window tabs must be assigned")
913
914 // windowForTab must map tabs to runtime windows.
915 require.Equal(t, firstBW, app.browserWindowForTab(firstTab.ID), "tab-1 must be owned by runtime-w1")
916 require.Equal(t, secondBW, app.browserWindowForTab(secondTab.ID), "tab-2 must be owned by runtime-w2")
917
918 // Saved window IDs must never leak into browserWindows.
919 require.Nil(t, app.browserWindows["saved-w1"], "saved-w1 must not appear in browserWindows")
920 require.Nil(t, app.browserWindows["saved-w2"], "saved-w2 must not appear in browserWindows")
921 assertWindowOwnershipInvariant(t, app)
922}
923
924func TestApp_BrowserWindowsOwnIndependentTabLists(t *testing.T) {
925 // Construct first browser window with its own TabList and distinct tabs.
926 firstTabs := entity.NewTabList()
927 firstTab := entity.NewTab(entity.TabID("tab-first"), entity.WorkspaceID("ws-first"), entity.NewPane(entity.PaneID("pane-first")))
928 firstTabs.Add(firstTab)
929
930 first := &browserWindow{
931 id: "window-first",
932 tabs: firstTabs,
933 }
934
935 // Construct second browser window with its own TabList and distinct tabs.
936 secondTabs := entity.NewTabList()
937 secondTab := entity.NewTab(entity.TabID("tab-second"), entity.WorkspaceID("ws-second"), entity.NewPane(entity.PaneID("pane-second")))
938 secondTabs.Add(secondTab)
939
940 second := &browserWindow{
941 id: "window-second",
942 tabs: secondTabs,
943 }
944
945 app := &App{
946 browserWindows: map[string]*browserWindow{
947 first.id: first,
948 second.id: second,
949 },
950 }
951
952 // tabListForBrowserWindow returns the correct TabList for each window.
953 got := app.tabListForBrowserWindow(first)
954 if got != firstTabs {
955 t.Fatalf("tabListForBrowserWindow(first) = %p, want %p", got, firstTabs)
956 }
957 got = app.tabListForBrowserWindow(second)
958 if got != secondTabs {
959 t.Fatalf("tabListForBrowserWindow(second) = %p, want %p", got, secondTabs)
960 }
961
962 // Each window's tabs do not contain the other window's tabs.
963 if firstTabs.Find(secondTab.ID) != nil {
964 t.Fatalf("first window tabs unexpectedly contain second tab")
965 }
966 if secondTabs.Find(firstTab.ID) != nil {
967 t.Fatalf("second window tabs unexpectedly contain first tab")
968 }
969
970 // activeTabForBrowserWindow returns the correct active tab for each window.
971 gotTab := app.activeTabForBrowserWindow(first)
972 if gotTab != firstTab {
973 t.Fatalf("activeTabForBrowserWindow(first) = %p, want %p", gotTab, firstTab)
974 }
975 gotTab = app.activeTabForBrowserWindow(second)
976 if gotTab != secondTab {
977 t.Fatalf("activeTabForBrowserWindow(second) = %p, want %p", gotTab, secondTab)
978 }
979
980 app.windowForTab = map[entity.TabID]*browserWindow{firstTab.ID: first, secondTab.ID: second}
981 assertWindowOwnershipInvariant(t, app)
982}
983
984func TestRestoreSession_RemovesStaleBrowserWindows(t *testing.T) {
985 // After restore, only runtime windows should remain in browserWindows
986 // and browserWindowOrder. Stale pre-existing windows must be removed.
987
988 // Runtime windows with tabs (what restoreSession produces).
989 runtime1Tabs := entity.NewTabList()
990 r1Tab := entity.NewTab(entity.TabID("r1-t1"), entity.WorkspaceID("ws-r1"), entity.NewPane(entity.PaneID("p-r1")))
991 runtime1Tabs.Add(r1Tab)
992 runtime1Tabs.SetActive(entity.TabID("r1-t1"))
993
994 runtime2Tabs := entity.NewTabList()
995 r2Tab := entity.NewTab(entity.TabID("r2-t1"), entity.WorkspaceID("ws-r2"), entity.NewPane(entity.PaneID("p-r2")))
996 runtime2Tabs.Add(r2Tab)
997 runtime2Tabs.SetActive(entity.TabID("r2-t1"))
998
999 runtime1 := &browserWindow{id: "runtime-w1", tabs: runtime1Tabs}
1000 runtime2 := &browserWindow{id: "runtime-w2", tabs: runtime2Tabs}
1001
1002 // Stale pre-existing browser window (no longer in runtime set).
1003 staleTabs := entity.NewTabList()
1004 staleTab := entity.NewTab(entity.TabID("stale-t1"), entity.WorkspaceID("ws-stale"), entity.NewPane(entity.PaneID("p-stale")))
1005 staleTabs.Add(staleTab)
1006 stale := &browserWindow{id: "stale-window", tabs: staleTabs}
1007
1008 app := &App{
1009 browserWindows: map[string]*browserWindow{
1010 "stale-window": stale,
1011 "runtime-w1": runtime1,
1012 "runtime-w2": runtime2,
1013 },
1014 tabs: entity.NewTabList(),
1015 windowForTab: map[entity.TabID]*browserWindow{
1016 staleTab.ID: stale,
1017 r1Tab.ID: runtime1,
1018 r2Tab.ID: runtime2,
1019 },
1020 }
1021
1022 // Simulate post-restore cleanup: remove stale windows not in runtime set.
1023 runtimeIDs := []string{"runtime-w1", "runtime-w2"}
1024 app.pruneStaleBrowserWindows([]*browserWindow{runtime1, runtime2})
1025
1026 // After cleanup, browserWindows contains only runtime windows.
1027 require.Len(t, app.browserWindows, 2)
1028 require.NotNil(t, app.browserWindows["runtime-w1"])
1029 require.NotNil(t, app.browserWindows["runtime-w2"])
1030 require.Nil(t, app.browserWindows["stale-window"])
1031
1032 // browserWindowOrder matches runtime order.
1033 require.Equal(t, runtimeIDs, app.browserWindowOrder)
1034
1035 // GetWindowTabLists returns only runtime windows.
1036 result := app.GetWindowTabLists()
1037 require.Len(t, result, 2)
1038 assert.Equal(t, entity.WindowID("runtime-w1"), result[0].WindowID)
1039 assert.Equal(t, entity.WindowID("runtime-w2"), result[1].WindowID)
1040
1041 // No stale tabs leak into the result.
1042 // Verify structural equality (snapshot copies, not live pointers).
1043 assert.Equal(t, runtime1Tabs.ActiveTabID, result[0].Tabs.ActiveTabID)
1044 assert.Len(t, result[0].Tabs.Tabs, len(runtime1Tabs.Tabs))
1045 assert.Equal(t, runtime2Tabs.ActiveTabID, result[1].Tabs.ActiveTabID)
1046 assert.Len(t, result[1].Tabs.Tabs, len(runtime2Tabs.Tabs))
1047 assertWindowOwnershipInvariant(t, app)
1048}
1049
1050func TestRestoreSession_BrowserWindowOrderStartsWithFirstReusedWindow(t *testing.T) {
1051 // When the first restored window reuses a browser window that was not
1052 // already in browserWindowOrder, the restored order must start with
1053 // the reused window ID followed by newly-created window IDs.
1054
1055 reuseTabs := entity.NewTabList()
1056 reuseTab := entity.NewTab(entity.TabID("reuse-t1"), entity.WorkspaceID("ws-reuse"), entity.NewPane(entity.PaneID("p-reuse")))
1057 reuseTabs.Add(reuseTab)
1058 reuseBW := &browserWindow{id: "reused-window", tabs: reuseTabs}
1059
1060 createdTabs := entity.NewTabList()
1061 createdTab := entity.NewTab(entity.TabID("created-t1"), entity.WorkspaceID("ws-created"), entity.NewPane(entity.PaneID("p-created")))
1062 createdTabs.Add(createdTab)
1063 createdBW := &browserWindow{id: "created-window", tabs: createdTabs}
1064
1065 // Pre-existing browserWindowOrder does NOT contain the reused window.
1066 app := &App{
1067 browserWindows: map[string]*browserWindow{
1068 "reused-window": reuseBW,
1069 "created-window": createdBW,
1070 },
1071 browserWindowOrder: []string{"old-stale-window"}, // reused window not present
1072 tabs: entity.NewTabList(),
1073 windowForTab: map[entity.TabID]*browserWindow{},
1074 }
1075
1076 // Exercise the helper used by restoreSession to prune stale windows and
1077 // replace browserWindowOrder with the runtime restore order.
1078 runtimeWindows := []*browserWindow{reuseBW, createdBW}
1079 app.pruneStaleBrowserWindows(runtimeWindows)
1080
1081 require.Len(t, app.browserWindowOrder, 2)
1082 assert.Equal(t, "reused-window", app.browserWindowOrder[0],
1083 "first reused window must be first in order")
1084 assert.Equal(t, "created-window", app.browserWindowOrder[1],
1085 "newly-created window must follow the reused window")
1086
1087 // GetWindowTabLists respects the order.
1088 result := app.GetWindowTabLists()
1089 require.Len(t, result, 2)
1090 assert.Equal(t, entity.WindowID("reused-window"), result[0].WindowID)
1091 assert.Equal(t, entity.WindowID("created-window"), result[1].WindowID)
1092}
1093
1094func TestRestoreSession_ActiveWindowIndexSyncsState(t *testing.T) {
1095 // After restore, GetActiveWindowIndex must return the restored active index.
1096 // The focused window state must be consistent: lastFocusedWindowID matches
1097 // the window at that index, and the active window's tab target aligns.
1098
1099 runtime1Tabs := entity.NewTabList()
1100 r1Tab := entity.NewTab(entity.TabID("a1-t1"), entity.WorkspaceID("ws-a1"), entity.NewPane(entity.PaneID("p-a1")))
1101 runtime1Tabs.Add(r1Tab)
1102 runtime1Tabs.SetActive(entity.TabID("a1-t1"))
1103
1104 runtime2Tabs := entity.NewTabList()
1105 r2Tab := entity.NewTab(entity.TabID("a2-t1"), entity.WorkspaceID("ws-a2"), entity.NewPane(entity.PaneID("p-a2")))
1106 runtime2Tabs.Add(r2Tab)
1107 runtime2Tabs.SetActive(entity.TabID("a2-t1"))
1108
1109 runtime1 := &browserWindow{id: "active-w1", tabs: runtime1Tabs}
1110 runtime2 := &browserWindow{id: "active-w2", tabs: runtime2Tabs}
1111
1112 app := &App{
1113 browserWindows: map[string]*browserWindow{
1114 "active-w1": runtime1,
1115 "active-w2": runtime2,
1116 },
1117 browserWindowOrder: []string{"active-w1", "active-w2"},
1118 lastFocusedWindowID: "active-w2",
1119 tabs: entity.NewTabList(),
1120 windowForTab: map[entity.TabID]*browserWindow{},
1121 }
1122
1123 // Active index should be 1 (second window in order).
1124 idx := app.GetActiveWindowIndex()
1125 assert.Equal(t, 1, idx)
1126
1127 // The focused window is the one at that index.
1128 focused := app.lastFocusedBrowserWindow()
1129 require.NotNil(t, focused)
1130 assert.Equal(t, "active-w2", focused.id)
1131
1132 // Verify that GetActiveWindowIndex and GetWindowTabLists are consistent:
1133 // the window at the active index matches the focused window.
1134 result := app.GetWindowTabLists()
1135 require.Len(t, result, 2)
1136 assert.Equal(t, entity.WindowID("active-w2"), result[idx].WindowID,
1137 "window at active index must match focused window ID")
1138}