1package ui
2
3import (
4 "bytes"
5 "context"
6 "encoding/binary"
7 "errors"
8 "io"
9 "math"
10 "net"
11 "os"
12 "path/filepath"
13 "reflect"
14 "strconv"
15 "strings"
16 "testing"
17 "time"
18 "unsafe"
19
20 "github.com/bnema/dumber/internal/application/port"
21 portmocks "github.com/bnema/dumber/internal/application/port/mocks"
22 "github.com/bnema/dumber/internal/application/usecase"
23 "github.com/bnema/dumber/internal/domain/entity"
24 repomocks "github.com/bnema/dumber/internal/domain/repository/mocks"
25 "github.com/bnema/dumber/internal/infrastructure/config"
26 "github.com/bnema/dumber/internal/shared/syncdispatch"
27 "github.com/bnema/dumber/internal/ui/component"
28 "github.com/bnema/dumber/internal/ui/coordinator"
29 contentcoord "github.com/bnema/dumber/internal/ui/coordinator/content"
30 "github.com/bnema/dumber/internal/ui/dispatcher"
31 "github.com/bnema/dumber/internal/ui/input"
32 "github.com/bnema/dumber/internal/ui/layout"
33 layoutmocks "github.com/bnema/dumber/internal/ui/layout/mocks"
34 "github.com/bnema/dumber/internal/ui/window"
35 "github.com/bnema/puregotk/v4/gdk"
36 "github.com/bnema/puregotk/v4/gio"
37 "github.com/bnema/puregotk/v4/gtk"
38 "github.com/stretchr/testify/mock"
39 "github.com/stretchr/testify/require"
40)
41
42type testBrowserLaunchRelay struct {
43 listenCalls int
44 closer *testCloser
45}
46
47func (r *testBrowserLaunchRelay) DeliverOpenFreshWindow(context.Context, string) (bool, error) {
48 return false, nil
49}
50
51func (r *testBrowserLaunchRelay) Listen(_ context.Context, opener port.BrowserWindowOpener) (io.Closer, error) {
52 r.listenCalls++
53 _ = opener
54 return r.closer, nil
55}
56
57type testCloser struct {
58 closed bool
59}
60
61func (c *testCloser) Close() error {
62 c.closed = true
63 return nil
64}
65
66func testPathIsSocket(path string) bool {
67 info, err := os.Stat(path)
68 return err == nil && info.Mode()&os.ModeSocket != 0
69}
70
71func mockSessionStateRepoWithSnapshot(t *testing.T, sessionID entity.SessionID, state *entity.SessionState) *repomocks.MockSessionStateRepository {
72 t.Helper()
73 repo := repomocks.NewMockSessionStateRepository(t)
74 repo.EXPECT().
75 GetSnapshot(mock.Anything, sessionID).
76 Return(state, nil).
77 Once()
78 return repo
79}
80
81func mockZoomRepo(t *testing.T) *repomocks.MockZoomRepository {
82 t.Helper()
83 repo := repomocks.NewMockZoomRepository(t)
84 repo.EXPECT().
85 Get(mock.Anything, mock.Anything).
86 Return((*entity.ZoomLevel)(nil), nil).
87 Maybe()
88 repo.EXPECT().
89 Set(mock.Anything, mock.Anything).
90 Return(nil).
91 Maybe()
92 return repo
93}
94
95func testGDKBackendAllows(backend string) bool {
96 configured := os.Getenv("GDK_BACKEND")
97 if configured == "" {
98 return true
99 }
100 for _, candidate := range strings.Split(configured, ",") {
101 if strings.TrimSpace(candidate) == backend {
102 return true
103 }
104 }
105 return false
106}
107
108func testHasUsableWaylandDisplay() bool {
109 if !testGDKBackendAllows("wayland") {
110 return false
111 }
112 waylandDisplay := os.Getenv("WAYLAND_DISPLAY")
113 if waylandDisplay == "" {
114 return false
115 }
116 candidates := []string{waylandDisplay}
117 if runtimeDir := os.Getenv("XDG_RUNTIME_DIR"); runtimeDir != "" && !filepath.IsAbs(waylandDisplay) {
118 candidates = append([]string{filepath.Join(runtimeDir, waylandDisplay)}, candidates...)
119 }
120 for _, candidate := range candidates {
121 if testPathIsSocket(candidate) {
122 return true
123 }
124 }
125 return false
126}
127
128func testHasUsableX11Display() bool {
129 if !testGDKBackendAllows("x11") {
130 return false
131 }
132 display := os.Getenv("DISPLAY")
133 if display == "" {
134 return false
135 }
136 if strings.HasPrefix(display, ":") {
137 displayNum := strings.TrimPrefix(display, ":")
138 displayNum = strings.SplitN(displayNum, ".", 2)[0]
139 if displayNum == "" {
140 return false
141 }
142 x11SocketCandidates := []string{filepath.Join(os.TempDir(), ".X11-unix", "X"+displayNum)}
143 if fallback := "/tmp/.X11-unix/X" + displayNum; fallback != x11SocketCandidates[0] {
144 x11SocketCandidates = append(x11SocketCandidates, fallback)
145 }
146 for _, candidate := range x11SocketCandidates {
147 if testPathIsSocket(candidate) {
148 return true
149 }
150 }
151 return false
152 }
153 return true // TCP display
154}
155
156func testHasUsableGTKDisplay() bool {
157 return testHasUsableWaylandDisplay() || testHasUsableX11Display()
158}
159
160// testHasX11Auth checks whether X11 authorization is available for the current
161// DISPLAY before any GTK/Adwaita initialization. It is a pure function that
162// inspects env vars and the .Xauthority file only; no GTK calls.
163func testHasX11Auth() bool {
164 display := os.Getenv("DISPLAY")
165 if display == "" || !strings.HasPrefix(display, ":") {
166 // No DISPLAY or TCP-style display; can't check locally.
167 // Let GTK try and fall through to gdk.DisplayGetDefault().
168 return true
169 }
170
171 displayNum := strings.TrimPrefix(display, ":")
172 displayNum = strings.SplitN(displayNum, ".", 2)[0]
173 if displayNum == "" {
174 return false
175 }
176
177 // Find the X authority file.
178 authFile := os.Getenv("XAUTHORITY")
179 if authFile == "" {
180 home, err := os.UserHomeDir()
181 if err != nil {
182 return false
183 }
184 authFile = filepath.Join(home, ".Xauthority")
185 }
186
187 if _, err := os.Stat(authFile); err != nil {
188 return false // No authority file; X11 auth will fail.
189 }
190
191 // Parse the .Xauthority file to look for a matching display entry.
192 // Format: sequence of {family(2B), addrLen(2B), addr, numLen(2B), num, nameLen(2B), name, dataLen(2B), data}
193 // All multi-byte values are big-endian (network byte order).
194 data, err := os.ReadFile(authFile)
195 if err != nil {
196 return false
197 }
198
199 return testXauthorityHasDisplay(data, displayNum)
200}
201
202func testXauthorityHasDisplay(data []byte, displayNum string) bool {
203 for len(data) > 0 {
204 if len(data) < 2 {
205 return false
206 }
207 data = data[2:] // family, unused
208
209 _, rest, ok := testReadXauthorityField(data) // address
210 if !ok {
211 return false
212 }
213 num, rest, ok := testReadXauthorityField(rest)
214 if !ok {
215 return false
216 }
217 name, rest, ok := testReadXauthorityField(rest)
218 if !ok {
219 return false
220 }
221 cookie, rest, ok := testReadXauthorityField(rest)
222 if !ok {
223 return false
224 }
225 if string(num) == displayNum && len(name) > 0 && len(cookie) > 0 {
226 return true
227 }
228 data = rest
229 }
230 return false
231}
232
233func testReadXauthorityField(data []byte) ([]byte, []byte, bool) {
234 if len(data) < 2 {
235 return nil, nil, false
236 }
237 fieldLen := int(binary.BigEndian.Uint16(data[:2]))
238 data = data[2:]
239 if len(data) < fieldLen {
240 return nil, nil, false
241 }
242 return data[:fieldLen], data[fieldLen:], true
243}
244
245// testNeedsX11Auth reports whether GTK may need the X11 backend and therefore
246// requires X11 authorization before GTK initialization. Wayland-only backend
247// configuration never needs X11 auth; X11 is required when it is the only usable
248// allowed backend.
249func testNeedsX11Auth() bool {
250 if !testGDKBackendAllows("x11") {
251 return false
252 }
253 return !testGDKBackendAllows("wayland") || !testHasUsableWaylandDisplay()
254}
255
256func testCreateUnixSocket(t *testing.T, path string) {
257 t.Helper()
258 if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
259 t.Fatal(err)
260 }
261 listener, err := net.Listen("unix", path)
262 if err != nil {
263 t.Fatal(err)
264 }
265 t.Cleanup(func() {
266 _ = listener.Close()
267 _ = os.Remove(path)
268 })
269}
270
271func testUnusedX11DisplayNumber(t *testing.T) string {
272 t.Helper()
273 for display := 90; display < 300; display++ {
274 displayNum := strconv.Itoa(display)
275 if !testPathIsSocket("/tmp/.X11-unix/X" + displayNum) {
276 return displayNum
277 }
278 }
279 t.Fatal("could not find an unused X11 display number")
280 return ""
281}
282
283func testCreateX11Socket(t *testing.T, displayNum string) {
284 t.Helper()
285 tmpDir := t.TempDir()
286 t.Setenv("TMPDIR", tmpDir)
287 testCreateUnixSocket(t, filepath.Join(tmpDir, ".X11-unix", "X"+displayNum))
288}
289
290func requireGTKDisplayApp(t *testing.T) *gtk.Application {
291 t.Helper()
292 hasWayland := testHasUsableWaylandDisplay()
293 hasX11 := testHasUsableX11Display()
294 if !hasWayland && !hasX11 {
295 t.Skip("GTK display not available (no DISPLAY/WAYLAND_DISPLAY socket)")
296 }
297 // Only require X11 auth when X11 is the only usable allowed backend.
298 if testNeedsX11Auth() && !testHasX11Auth() {
299 t.Skip("GTK display not available (X11 authorization unavailable)")
300 }
301 EnsureAdwaitaInitialized()
302 if gdk.DisplayGetDefault() == nil {
303 t.Skip("GTK display not available (gdk.DisplayGetDefault returned nil)")
304 }
305 appID := AppID
306 gtkApp := gtk.NewApplication(&appID, gio.GApplicationNonUniqueValue)
307 if gtkApp == nil {
308 t.Fatal("gtk application creation failed")
309 }
310 return gtkApp
311}
312
313func Test_testHasUsableGTKDisplay_PureProbe(t *testing.T) {
314 t.Setenv("GDK_BACKEND", "")
315
316 t.Run("no display env vars", func(t *testing.T) {
317 t.Setenv("DISPLAY", "")
318 t.Setenv("WAYLAND_DISPLAY", "")
319 if got := testHasUsableGTKDisplay(); got {
320 t.Errorf("testHasUsableGTKDisplay() = %v, want false", got)
321 }
322 })
323
324 t.Run("DISPLAY numeric without socket", func(t *testing.T) {
325 displayNum := testUnusedX11DisplayNumber(t)
326 t.Setenv("DISPLAY", ":"+displayNum)
327 t.Setenv("WAYLAND_DISPLAY", "")
328 if got := testHasUsableGTKDisplay(); got {
329 t.Errorf("testHasUsableGTKDisplay() = %v, want false", got)
330 }
331 })
332
333 t.Run("DISPLAY with screen without socket", func(t *testing.T) {
334 displayNum := testUnusedX11DisplayNumber(t)
335 t.Setenv("DISPLAY", ":"+displayNum+".0")
336 t.Setenv("WAYLAND_DISPLAY", "")
337 if got := testHasUsableGTKDisplay(); got {
338 t.Errorf("testHasUsableGTKDisplay() = %v, want false", got)
339 }
340 })
341
342 t.Run("DISPLAY colon only no number", func(t *testing.T) {
343 t.Setenv("DISPLAY", ":")
344 t.Setenv("WAYLAND_DISPLAY", "")
345 if got := testHasUsableGTKDisplay(); got {
346 t.Errorf("testHasUsableGTKDisplay() = %v, want false for empty display number", got)
347 }
348 })
349
350 t.Run("DISPLAY TCP always passes socket check", func(t *testing.T) {
351 t.Setenv("DISPLAY", "remote-host:0")
352 t.Setenv("WAYLAND_DISPLAY", "")
353 if got := testHasUsableGTKDisplay(); !got {
354 t.Errorf("testHasUsableGTKDisplay() = %v, want true for TCP display", got)
355 }
356 })
357
358 t.Run("DISPLAY with existing socket", func(t *testing.T) {
359 displayNum := testUnusedX11DisplayNumber(t)
360 tmpDir := t.TempDir()
361 t.Setenv("TMPDIR", tmpDir)
362 t.Setenv("DISPLAY", ":"+displayNum)
363 t.Setenv("WAYLAND_DISPLAY", "")
364 testCreateUnixSocket(t, filepath.Join(tmpDir, ".X11-unix", "X"+displayNum))
365 if got := testHasUsableGTKDisplay(); !got {
366 t.Errorf("testHasUsableGTKDisplay() = %v, want true with existing X11 socket", got)
367 }
368 })
369
370 t.Run("WAYLAND_DISPLAY with existing socket", func(t *testing.T) {
371 t.Setenv("DISPLAY", "")
372 t.Setenv("WAYLAND_DISPLAY", "wayland-1")
373 tmpDir := t.TempDir()
374 socketPath := filepath.Join(tmpDir, "wayland-1")
375 testCreateUnixSocket(t, socketPath)
376 // XDG_RUNTIME_DIR controls where Wayland sockets are looked up
377 t.Setenv("XDG_RUNTIME_DIR", tmpDir)
378 if got := testHasUsableGTKDisplay(); !got {
379 t.Errorf("testHasUsableGTKDisplay() = %v, want true with existing wayland socket", got)
380 }
381 })
382
383 t.Run("WAYLAND_DISPLAY without socket", func(t *testing.T) {
384 t.Setenv("DISPLAY", "")
385 t.Setenv("WAYLAND_DISPLAY", "wayland-nonexistent")
386 t.Setenv("XDG_RUNTIME_DIR", t.TempDir())
387 if got := testHasUsableGTKDisplay(); got {
388 t.Errorf("testHasUsableGTKDisplay() = %v, want false without wayland socket", got)
389 }
390 })
391
392 t.Run("WAYLAND_DISPLAY absolute path with existing socket", func(t *testing.T) {
393 t.Setenv("DISPLAY", "")
394 socketPath := filepath.Join(t.TempDir(), "wayland.sock")
395 testCreateUnixSocket(t, socketPath)
396 t.Setenv("WAYLAND_DISPLAY", socketPath)
397 t.Setenv("XDG_RUNTIME_DIR", "/nonexistent") // Should be ignored for absolute paths
398 if got := testHasUsableGTKDisplay(); !got {
399 t.Errorf("testHasUsableGTKDisplay() = %v, want true with absolute wayland socket path", got)
400 }
401 })
402}
403
404func Test_testHasX11Auth_PureProbe(t *testing.T) {
405 t.Run("DISPLAY unset", func(t *testing.T) {
406 t.Setenv("DISPLAY", "")
407 t.Setenv("XAUTHORITY", "")
408 if got := testHasX11Auth(); !got {
409 t.Errorf("testHasX11Auth() = %v, want true when DISPLAY unset", got)
410 }
411 })
412
413 t.Run("DISPLAY TCP remote", func(t *testing.T) {
414 t.Setenv("DISPLAY", "remote-host:0")
415 t.Setenv("XAUTHORITY", "")
416 if got := testHasX11Auth(); !got {
417 t.Errorf("testHasX11Auth() = %v, want true for TCP display", got)
418 }
419 })
420
421 t.Run("DISPLAY local no auth file", func(t *testing.T) {
422 t.Setenv("DISPLAY", ":0")
423 t.Setenv("XAUTHORITY", "")
424 // Use a temp home dir with no .Xauthority
425 t.Setenv("HOME", t.TempDir())
426 if got := testHasX11Auth(); got {
427 t.Errorf("testHasX11Auth() = %v, want false without auth file", got)
428 }
429 })
430
431 t.Run("DISPLAY local XAUTHORITY env missing file", func(t *testing.T) {
432 t.Setenv("DISPLAY", ":0")
433 t.Setenv("XAUTHORITY", "/nonexistent/.Xauthority")
434 if got := testHasX11Auth(); got {
435 t.Errorf("testHasX11Auth() = %v, want false when XAUTHORITY file missing", got)
436 }
437 })
438
439 t.Run("DISPLAY local XAUTHORITY with matching entry", func(t *testing.T) {
440 t.Setenv("DISPLAY", ":1")
441 authFile := filepath.Join(t.TempDir(), ".Xauthority")
442 createXauthFile(t, authFile, "1", "MIT-MAGIC-COOKIE-1", []byte{0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10})
443 t.Setenv("XAUTHORITY", authFile)
444 if got := testHasX11Auth(); !got {
445 t.Errorf("testHasX11Auth() = %v, want true with matching auth entry", got)
446 }
447 })
448
449 t.Run("DISPLAY local XAUTHORITY with non-matching entry", func(t *testing.T) {
450 t.Setenv("DISPLAY", ":2")
451 authFile := filepath.Join(t.TempDir(), ".Xauthority")
452 createXauthFile(t, authFile, "0", "MIT-MAGIC-COOKIE-1", []byte{0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10})
453 t.Setenv("XAUTHORITY", authFile)
454 if got := testHasX11Auth(); got {
455 t.Errorf("testHasX11Auth() = %v, want false with non-matching auth entry", got)
456 }
457 })
458
459 t.Run("DISPLAY local with screen number", func(t *testing.T) {
460 t.Setenv("DISPLAY", ":0.0")
461 authFile := filepath.Join(t.TempDir(), ".Xauthority")
462 createXauthFile(t, authFile, "0", "MIT-MAGIC-COOKIE-1", []byte{0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10})
463 t.Setenv("XAUTHORITY", authFile)
464 if got := testHasX11Auth(); !got {
465 t.Errorf("testHasX11Auth() = %v, want true with screen suffix stripped", got)
466 }
467 })
468
469 t.Run("DISPLAY colon only no number defaults to false", func(t *testing.T) {
470 t.Setenv("DISPLAY", ":")
471 t.Setenv("XAUTHORITY", "")
472 if got := testHasX11Auth(); got {
473 t.Errorf("testHasX11Auth() = %v, want false for empty display number", got)
474 }
475 })
476}
477
478// writeXauthRecord appends one local-family .Xauthority record to buf with the given fields.
479// If name or data is empty, the length byte is written as zero but the field
480// data is omitted, producing a truncated record suitable for malformed tests.
481func writeXauthRecord(buf *bytes.Buffer, num, name string, data []byte) {
482 // family (2 bytes, big-endian)
483 familyBytes := make([]byte, 2)
484 binary.BigEndian.PutUint16(familyBytes, 256)
485 buf.Write(familyBytes)
486
487 // address
488 addrLen := make([]byte, 2)
489 addr := "host"
490 binary.BigEndian.PutUint16(addrLen, uint16(len(addr)))
491 buf.Write(addrLen)
492 buf.WriteString(addr)
493
494 // number
495 numLen := make([]byte, 2)
496 binary.BigEndian.PutUint16(numLen, uint16(len(num)))
497 buf.Write(numLen)
498 buf.WriteString(num)
499
500 // name
501 nameLen := make([]byte, 2)
502 binary.BigEndian.PutUint16(nameLen, uint16(len(name)))
503 buf.Write(nameLen)
504 if name != "" {
505 buf.WriteString(name)
506 }
507
508 // data
509 dLen := make([]byte, 2)
510 binary.BigEndian.PutUint16(dLen, uint16(len(data)))
511 buf.Write(dLen)
512 if len(data) > 0 {
513 buf.Write(data)
514 }
515}
516
517func Test_testHasX11Auth_MalformedRecords(t *testing.T) {
518 t.Run("matching display with empty name field", func(t *testing.T) {
519 t.Setenv("DISPLAY", ":1")
520 authFile := filepath.Join(t.TempDir(), ".Xauthority")
521 var buf bytes.Buffer
522 writeXauthRecord(&buf, "1", "", []byte{0x01, 0x02, 0x03, 0x04})
523 if err := os.WriteFile(authFile, buf.Bytes(), 0644); err != nil {
524 t.Fatal(err)
525 }
526 t.Setenv("XAUTHORITY", authFile)
527 if got := testHasX11Auth(); got {
528 t.Errorf("testHasX11Auth() = %v, want false for matching display with empty name", got)
529 }
530 })
531
532 t.Run("matching display with empty data field", func(t *testing.T) {
533 t.Setenv("DISPLAY", ":1")
534 authFile := filepath.Join(t.TempDir(), ".Xauthority")
535 var buf bytes.Buffer
536 writeXauthRecord(&buf, "1", "MIT-MAGIC-COOKIE-1", nil)
537 if err := os.WriteFile(authFile, buf.Bytes(), 0644); err != nil {
538 t.Fatal(err)
539 }
540 t.Setenv("XAUTHORITY", authFile)
541 if got := testHasX11Auth(); got {
542 t.Errorf("testHasX11Auth() = %v, want false for matching display with empty data", got)
543 }
544 })
545
546 t.Run("truncated record after display number", func(t *testing.T) {
547 t.Setenv("DISPLAY", ":1")
548 authFile := filepath.Join(t.TempDir(), ".Xauthority")
549 var buf bytes.Buffer
550 family := make([]byte, 2)
551 binary.BigEndian.PutUint16(family, 256)
552 buf.Write(family)
553 addr := "host"
554 addrLen := make([]byte, 2)
555 binary.BigEndian.PutUint16(addrLen, uint16(len(addr)))
556 buf.Write(addrLen)
557 buf.WriteString(addr)
558 num := "1"
559 numLen := make([]byte, 2)
560 binary.BigEndian.PutUint16(numLen, uint16(len(num)))
561 buf.Write(numLen)
562 buf.WriteString(num)
563 // Record ends here — no name or data fields follow.
564 if err := os.WriteFile(authFile, buf.Bytes(), 0644); err != nil {
565 t.Fatal(err)
566 }
567 t.Setenv("XAUTHORITY", authFile)
568 if got := testHasX11Auth(); got {
569 t.Errorf("testHasX11Auth() = %v, want false for truncated record", got)
570 }
571 })
572
573 t.Run("multiple entries with one matching and valid", func(t *testing.T) {
574 t.Setenv("DISPLAY", ":3")
575 authFile := filepath.Join(t.TempDir(), ".Xauthority")
576 var buf bytes.Buffer
577 writeXauthRecord(&buf, "0", "MIT-MAGIC-COOKIE-1", []byte{0x01, 0x02, 0x03, 0x04})
578 writeXauthRecord(&buf, "1", "MIT-MAGIC-COOKIE-1", []byte{0x05, 0x06, 0x07, 0x08})
579 writeXauthRecord(&buf, "3", "MIT-MAGIC-COOKIE-1", []byte{0x09, 0x0a, 0x0b, 0x0c})
580 if err := os.WriteFile(authFile, buf.Bytes(), 0644); err != nil {
581 t.Fatal(err)
582 }
583 t.Setenv("XAUTHORITY", authFile)
584 if got := testHasX11Auth(); !got {
585 t.Errorf("testHasX11Auth() = %v, want true for third entry matching display :3", got)
586 }
587 })
588
589 t.Run("invalid matching entry does not hide later valid match", func(t *testing.T) {
590 t.Setenv("DISPLAY", ":3")
591 authFile := filepath.Join(t.TempDir(), ".Xauthority")
592 var buf bytes.Buffer
593 writeXauthRecord(&buf, "3", "", []byte{0x01, 0x02, 0x03, 0x04})
594 writeXauthRecord(&buf, "3", "MIT-MAGIC-COOKIE-1", []byte{0x09, 0x0a, 0x0b, 0x0c})
595 if err := os.WriteFile(authFile, buf.Bytes(), 0644); err != nil {
596 t.Fatal(err)
597 }
598 t.Setenv("XAUTHORITY", authFile)
599 if got := testHasX11Auth(); !got {
600 t.Errorf("testHasX11Auth() = %v, want true for later valid matching display", got)
601 }
602 })
603}
604
605func Test_testHasUsableWaylandDisplay_PureProbe(t *testing.T) {
606 t.Setenv("GDK_BACKEND", "")
607
608 t.Run("no WAYLAND_DISPLAY", func(t *testing.T) {
609 t.Setenv("WAYLAND_DISPLAY", "")
610 if got := testHasUsableWaylandDisplay(); got {
611 t.Errorf("testHasUsableWaylandDisplay() = %v, want false", got)
612 }
613 })
614
615 t.Run("WAYLAND_DISPLAY with existing socket", func(t *testing.T) {
616 t.Setenv("WAYLAND_DISPLAY", "wayland-1")
617 tmpDir := t.TempDir()
618 socketPath := filepath.Join(tmpDir, "wayland-1")
619 testCreateUnixSocket(t, socketPath)
620 t.Setenv("XDG_RUNTIME_DIR", tmpDir)
621 if got := testHasUsableWaylandDisplay(); !got {
622 t.Errorf("testHasUsableWaylandDisplay() = %v, want true with existing wayland socket", got)
623 }
624 })
625
626 t.Run("WAYLAND_DISPLAY absolute path", func(t *testing.T) {
627 socketPath := filepath.Join(t.TempDir(), "wayland.sock")
628 testCreateUnixSocket(t, socketPath)
629 t.Setenv("WAYLAND_DISPLAY", socketPath)
630 t.Setenv("XDG_RUNTIME_DIR", "/nonexistent")
631 if got := testHasUsableWaylandDisplay(); !got {
632 t.Errorf("testHasUsableWaylandDisplay() = %v, want true with absolute path", got)
633 }
634 })
635}
636
637func Test_testHasUsableX11Display_PureProbe(t *testing.T) {
638 t.Setenv("GDK_BACKEND", "")
639
640 t.Run("no DISPLAY", func(t *testing.T) {
641 t.Setenv("DISPLAY", "")
642 if got := testHasUsableX11Display(); got {
643 t.Errorf("testHasUsableX11Display() = %v, want false", got)
644 }
645 })
646
647 t.Run("DISPLAY numeric without socket", func(t *testing.T) {
648 displayNum := testUnusedX11DisplayNumber(t)
649 t.Setenv("DISPLAY", ":"+displayNum)
650 if got := testHasUsableX11Display(); got {
651 t.Errorf("testHasUsableX11Display() = %v, want false", got)
652 }
653 })
654
655 t.Run("DISPLAY TCP always passes socket check", func(t *testing.T) {
656 t.Setenv("DISPLAY", "remote-host:0")
657 if got := testHasUsableX11Display(); !got {
658 t.Errorf("testHasUsableX11Display() = %v, want true for TCP display", got)
659 }
660 })
661}
662
663func Test_testHasUsableGTKDisplay_GDKBackendSelection(t *testing.T) {
664 t.Run("GDK_BACKEND=x11 ignores Wayland-only socket", func(t *testing.T) {
665 t.Setenv("GDK_BACKEND", "x11")
666 t.Setenv("WAYLAND_DISPLAY", "wayland-1")
667 tmpDir := t.TempDir()
668 testCreateUnixSocket(t, filepath.Join(tmpDir, "wayland-1"))
669 t.Setenv("XDG_RUNTIME_DIR", tmpDir)
670 t.Setenv("DISPLAY", ":"+testUnusedX11DisplayNumber(t))
671 if got := testHasUsableGTKDisplay(); got {
672 t.Errorf("testHasUsableGTKDisplay() = %v, want false when x11 is forced without an X11 socket", got)
673 }
674 })
675
676 t.Run("GDK_BACKEND=wayland ignores X11-only socket", func(t *testing.T) {
677 displayNum := testUnusedX11DisplayNumber(t)
678 t.Setenv("GDK_BACKEND", "wayland")
679 t.Setenv("DISPLAY", ":"+displayNum)
680 t.Setenv("WAYLAND_DISPLAY", "wayland-missing")
681 t.Setenv("XDG_RUNTIME_DIR", t.TempDir())
682 testCreateX11Socket(t, displayNum)
683 if got := testHasUsableGTKDisplay(); got {
684 t.Errorf("testHasUsableGTKDisplay() = %v, want false when wayland is forced without a Wayland socket", got)
685 }
686 })
687
688 t.Run("GDK_BACKEND=wayland,x11 allows X11 fallback", func(t *testing.T) {
689 displayNum := testUnusedX11DisplayNumber(t)
690 t.Setenv("GDK_BACKEND", "wayland,x11")
691 t.Setenv("DISPLAY", ":"+displayNum)
692 t.Setenv("WAYLAND_DISPLAY", "wayland-missing")
693 t.Setenv("XDG_RUNTIME_DIR", t.TempDir())
694 testCreateX11Socket(t, displayNum)
695 if got := testHasUsableGTKDisplay(); !got {
696 t.Errorf("testHasUsableGTKDisplay() = %v, want true with X11 fallback socket", got)
697 }
698 if got := testNeedsX11Auth(); !got {
699 t.Errorf("testNeedsX11Auth() = %v, want true when X11 is the only usable backend", got)
700 }
701 })
702}
703
704func Test_testNeedsX11Auth_PureProbe(t *testing.T) {
705 t.Run("Wayland available forces no X11 auth", func(t *testing.T) {
706 t.Setenv("GDK_BACKEND", "")
707 t.Setenv("WAYLAND_DISPLAY", "wayland-1")
708 tmpDir := t.TempDir()
709 socketPath := filepath.Join(tmpDir, "wayland-1")
710 testCreateUnixSocket(t, socketPath)
711 t.Setenv("XDG_RUNTIME_DIR", tmpDir)
712 t.Setenv("DISPLAY", ":0")
713 if got := testNeedsX11Auth(); got {
714 t.Errorf("testNeedsX11Auth() = %v, want false when Wayland is available and GDK_BACKEND is unset", got)
715 }
716 })
717
718 t.Run("GDK_BACKEND=x11 forces X11 auth even with Wayland", func(t *testing.T) {
719 t.Setenv("GDK_BACKEND", "x11")
720 t.Setenv("WAYLAND_DISPLAY", "wayland-1")
721 tmpDir := t.TempDir()
722 socketPath := filepath.Join(tmpDir, "wayland-1")
723 testCreateUnixSocket(t, socketPath)
724 t.Setenv("XDG_RUNTIME_DIR", tmpDir)
725 if got := testNeedsX11Auth(); !got {
726 t.Errorf("testNeedsX11Auth() = %v, want true when GDK_BACKEND=x11", got)
727 }
728 })
729
730 t.Run("No Wayland fallback needs X11 auth", func(t *testing.T) {
731 t.Setenv("GDK_BACKEND", "")
732 t.Setenv("WAYLAND_DISPLAY", "")
733 t.Setenv("DISPLAY", ":0")
734 if got := testNeedsX11Auth(); !got {
735 t.Errorf("testNeedsX11Auth() = %v, want true when only X11 is available", got)
736 }
737 })
738}
739
740// createXauthFile writes a minimal .Xauthority file with one entry.
741// family=256 (FamilyLocal), address=hostname, number=displayNum, name=proto, data=cookie.
742func createXauthFile(t *testing.T, path, displayNum, proto string, cookie []byte) {
743 t.Helper()
744
745 hostname, err := os.Hostname()
746 if err != nil {
747 hostname = "localhost"
748 }
749
750 var buf bytes.Buffer
751 // family (2 bytes, big-endian) - 256 = FamilyLocal
752 family := make([]byte, 2)
753 binary.BigEndian.PutUint16(family, 256)
754 buf.Write(family)
755
756 // address length + address (hostname)
757 addrLen := make([]byte, 2)
758 binary.BigEndian.PutUint16(addrLen, uint16(len(hostname)))
759 buf.Write(addrLen)
760 buf.WriteString(hostname)
761
762 // number length + number (display number)
763 numLen := make([]byte, 2)
764 binary.BigEndian.PutUint16(numLen, uint16(len(displayNum)))
765 buf.Write(numLen)
766 buf.WriteString(displayNum)
767
768 // name length + name (auth protocol)
769 nameLen := make([]byte, 2)
770 binary.BigEndian.PutUint16(nameLen, uint16(len(proto)))
771 buf.Write(nameLen)
772 buf.WriteString(proto)
773
774 // data length + data (auth cookie)
775 dataLen := make([]byte, 2)
776 binary.BigEndian.PutUint16(dataLen, uint16(len(cookie)))
777 buf.Write(dataLen)
778 buf.Write(cookie)
779
780 if err := os.WriteFile(path, buf.Bytes(), 0644); err != nil {
781 t.Fatal(err)
782 }
783}
784
785// setDependencyField uses reflection to reach unexported dependency fields for tests.
786// Keep this test-only so production visibility stays narrow.
787func setDependencyField(t *testing.T, deps *Dependencies, field string, value any) {
788 t.Helper()
789
790 rv := reflect.ValueOf(deps).Elem()
791 fv := rv.FieldByName(field)
792 if !fv.IsValid() {
793 t.Fatalf("Dependencies missing %s field", field)
794 }
795 fv.Set(reflect.ValueOf(value))
796}
797
798// windowForTabCount uses reflection to inspect unexported app state in tests.
799// Keep this test-only so production visibility stays narrow.
800func windowForTabCount(t *testing.T, app *App) int {
801 t.Helper()
802
803 rv := reflect.ValueOf(app).Elem()
804 fv := rv.FieldByName("windowForTab")
805 if !fv.IsValid() {
806 t.Fatalf("App missing windowForTab field")
807 }
808 return fv.Len()
809}
810
811func assertWindowOwnershipInvariant(t *testing.T, app *App) {
812 t.Helper()
813
814 seen := make(map[entity.TabID]string)
815 for windowID, bw := range app.browserWindows {
816 if bw == nil {
817 continue
818 }
819 if bw.tabs == nil {
820 t.Errorf("browser window %s has nil tabs", windowID)
821 continue
822 }
823 for _, tab := range bw.tabs.Tabs {
824 if tab == nil {
825 continue
826 }
827 if previous, exists := seen[tab.ID]; exists {
828 t.Errorf("tab %s is owned by both %s and %s", tab.ID, previous, windowID)
829 continue
830 }
831 seen[tab.ID] = windowID
832 if got := app.windowForTab[tab.ID]; got != bw {
833 t.Errorf("windowForTab[%s] = %p, want owner window %s (%p)", tab.ID, got, windowID, bw)
834 }
835 }
836 }
837 for tabID, bw := range app.windowForTab {
838 if bw == nil || bw.tabs == nil || bw.tabs.Find(tabID) == nil {
839 t.Errorf("windowForTab[%s] points to stale or non-owning window %p", tabID, bw)
840 }
841 }
842}
843
844// tabCoordinatorMainWindowPtr uses reflection to inspect unexported coordinator state in tests.
845// Keep this test-only so production visibility stays narrow.
846func tabCoordinatorMainWindowPtr(t *testing.T, tc *coordinator.TabCoordinator) uintptr {
847 t.Helper()
848
849 rv := reflect.ValueOf(tc).Elem()
850 fv := rv.FieldByName("mainWindow")
851 if !fv.IsValid() {
852 t.Fatalf("TabCoordinator missing mainWindow field")
853 }
854 return fv.Pointer()
855}
856
857func setWindowTabBar(t *testing.T, mw *window.MainWindow, tabBar *component.TabBar) {
858 t.Helper()
859
860 rv := reflect.ValueOf(mw).Elem()
861 fv := rv.FieldByName("tabBar")
862 if !fv.IsValid() {
863 t.Fatalf("MainWindow missing tabBar field")
864 }
865 reflect.NewAt(fv.Type(), unsafe.Pointer(fv.UnsafeAddr())).Elem().Set(reflect.ValueOf(tabBar))
866}
867
868func omniboxNavigateCallbackForTest(t *testing.T, omnibox *component.Omnibox) func(context.Context, string) error {
869 t.Helper()
870 if omnibox == nil {
871 t.Fatal("omnibox is nil")
872 }
873
874 rv := reflect.ValueOf(omnibox).Elem()
875 fv := rv.FieldByName("onNavigate")
876 if !fv.IsValid() {
877 t.Fatal("Omnibox missing onNavigate field")
878 }
879 cb, ok := reflect.NewAt(fv.Type(), unsafe.Pointer(fv.UnsafeAddr())).Elem().Interface().(func(context.Context, string) error)
880 if !ok || cb == nil {
881 t.Fatal("omnibox onNavigate callback is nil or has unexpected type")
882 }
883 return cb
884}
885
886func newTestTabBarShell(t *testing.T, tabIDs ...entity.TabID) *component.TabBar {
887 t.Helper()
888
889 tabBar := &component.TabBar{}
890 buttons := make(map[entity.TabID]*component.TabButton, len(tabIDs))
891 for _, tabID := range tabIDs {
892 buttons[tabID] = &component.TabButton{}
893 }
894
895 rv := reflect.ValueOf(tabBar).Elem()
896 fv := rv.FieldByName("buttons")
897 if !fv.IsValid() {
898 t.Fatalf("TabBar missing buttons field")
899 }
900 reflect.NewAt(fv.Type(), unsafe.Pointer(fv.UnsafeAddr())).Elem().Set(reflect.ValueOf(buttons))
901
902 return tabBar
903}
904
905func windowTabBarActiveID(t *testing.T, mw *window.MainWindow) entity.TabID {
906 t.Helper()
907 if mw == nil || mw.TabBar() == nil {
908 return ""
909 }
910 return mw.TabBar().ActiveTabID()
911}
912
913func windowTitle(t *testing.T, mw *window.MainWindow) string {
914 t.Helper()
915 if mw == nil || mw.Window() == nil {
916 return ""
917 }
918 return mw.Window().GetTitle()
919}
920
921func windowTabBarVisible(t *testing.T, mw *window.MainWindow) bool {
922 t.Helper()
923 if mw == nil || mw.TabBar() == nil || mw.TabBar().Box() == nil {
924 return false
925 }
926 return mw.TabBar().Box().GetVisible()
927}
928
929func assertWindowTabBarAutoHidden(t *testing.T, mw *window.MainWindow, wantHidden bool) {
930 t.Helper()
931 if mw == nil || mw.TabBar() == nil || mw.TabBar().Box() == nil {
932 t.Fatal("missing tab bar")
933 }
934 box := mw.TabBar().Box()
935 if got := box.GetVisible(); !got {
936 t.Fatalf("tab bar visible = %v, want true so allocation is preserved", got)
937 }
938 if wantHidden {
939 if got := box.GetOpacity(); got != 0.0 {
940 t.Fatalf("tab bar opacity = %v, want 0", got)
941 }
942 if got := box.GetCanTarget(); got {
943 t.Fatalf("tab bar can target = %v, want false while auto-hidden", got)
944 }
945 return
946 }
947 if got := box.GetOpacity(); got != 1.0 {
948 t.Fatalf("tab bar opacity = %v, want 1", got)
949 }
950 if got := box.GetCanTarget(); !got {
951 t.Fatalf("tab bar can target = %v, want true while shown", got)
952 }
953}
954
955func stackedViewOnActivateIsNil(t *testing.T, sv *layout.StackedView) bool {
956 t.Helper()
957 if sv == nil {
958 return true
959 }
960
961 rv := reflect.ValueOf(sv).Elem()
962 fv := rv.FieldByName("onActivate")
963 if !fv.IsValid() {
964 t.Fatalf("StackedView missing onActivate field")
965 }
966 return reflect.NewAt(fv.Type(), unsafe.Pointer(fv.UnsafeAddr())).Elem().IsNil()
967}
968
969func newTestShellToaster(t *testing.T) (*component.Toaster, *layoutmocks.MockBoxWidget, *layoutmocks.MockLabelWidget) {
970 t.Helper()
971
972 factory := layoutmocks.NewMockWidgetFactory(t)
973 box := layoutmocks.NewMockBoxWidget(t)
974 label := layoutmocks.NewMockLabelWidget(t)
975
976 factory.EXPECT().NewBox(layout.OrientationHorizontal, 0).Return(box).Once()
977 factory.EXPECT().NewLabel("").Return(label).Once()
978 box.EXPECT().AddCssClass("toast").Once()
979 box.EXPECT().AddCssClass("toast-info").Once()
980 box.EXPECT().SetHalign(gtk.AlignStartValue).Twice()
981 box.EXPECT().SetValign(gtk.AlignStartValue).Twice()
982 box.EXPECT().SetHexpand(false).Once()
983 box.EXPECT().SetVexpand(false).Once()
984 box.EXPECT().SetCanTarget(false).Once()
985 box.EXPECT().SetCanFocus(false).Once()
986 box.EXPECT().SetVisible(false).Once()
987 box.EXPECT().Append(label).Once()
988 label.EXPECT().SetCanTarget(false).Once()
989 label.EXPECT().SetCanFocus(false).Once()
990
991 return component.NewToaster(factory), box, label
992}
993
994func TestApp_ShowFilterStatusUsesLastFocusedBrowserWindowToaster(t *testing.T) {
995 ctx := context.Background()
996 toaster, box, label := newTestShellToaster(t)
997 box.EXPECT().SetVisible(true).Once()
998 label.EXPECT().SetText("Ad blocker loading").Once()
999
1000 bw := &browserWindow{id: "window-1", appToaster: toaster}
1001 app := &App{
1002 browserWindows: map[string]*browserWindow{bw.id: bw},
1003 lastFocusedWindowID: bw.id,
1004 }
1005
1006 app.showFilterStatus(ctx, port.FilterStatus{State: port.FilterStateLoading, Message: "Ad blocker loading"})
1007}
1008
1009func TestApp_CheckConfigMigrationUsesLastFocusedBrowserWindowToaster(t *testing.T) {
1010 ctx := context.Background()
1011 toaster, box, label := newTestShellToaster(t)
1012 box.EXPECT().SetVisible(true).Once()
1013 label.EXPECT().SetText("Config has 1 new settings. Run 'dumber config migrate'").Once()
1014
1015 migrator := portmocks.NewMockConfigMigrator(t)
1016 migrator.EXPECT().CheckMigration().Return(&port.MigrationResult{MissingKeys: []string{"update.notify_on_new_settings"}}, nil).Once()
1017
1018 bw := &browserWindow{id: "window-1", appToaster: toaster}
1019 cfg := &config.Config{
1020 Update: config.UpdateConfig{NotifyOnNewSettings: true},
1021 }
1022 app := &App{
1023 deps: &Dependencies{
1024 MigrationChecker: migrator,
1025 },
1026 runtimeConfig: runtimeConfigStateForTest(cfg),
1027 browserWindows: map[string]*browserWindow{bw.id: bw},
1028 lastFocusedWindowID: bw.id,
1029 }
1030
1031 app.checkConfigMigration(ctx)
1032}
1033
1034func TestApp_FinalizeActivationStartsBrowserLaunchRelayOnceAndClosesOnShutdown(t *testing.T) {
1035 relay := &testBrowserLaunchRelay{closer: &testCloser{}}
1036 deps := &Dependencies{}
1037 setDependencyField(t, deps, "BrowserLaunchRelay", relay)
1038
1039 app := &App{deps: deps, cancel: func(error) {}}
1040
1041 app.finalizeActivation(context.Background())
1042 app.finalizeActivation(context.Background())
1043
1044 if relay.listenCalls != 1 {
1045 t.Fatalf("Listen calls = %d, want 1", relay.listenCalls)
1046 }
1047
1048 app.onShutdown(context.Background())
1049
1050 if !relay.closer.closed {
1051 t.Fatalf("relay listener closer was not closed")
1052 }
1053}
1054
1055func TestApp_GetWindowSnapshotStateReturnsUnavailableWhenMainThreadDispatchTimesOut(t *testing.T) {
1056 app := &App{
1057 tabs: entity.NewTabList(),
1058 browserWindows: map[string]*browserWindow{},
1059 dispatchOnMainThread: func(label string, fn func()) syncdispatch.SyncDispatchResult {
1060 if label != "ui.snapshot_window_state" {
1061 t.Fatalf("dispatch label = %q, want ui.snapshot_window_state", label)
1062 }
1063 return syncdispatch.SyncDispatchResult{Label: label, Status: syncdispatch.SyncDispatchTimedOut, Elapsed: 5 * time.Millisecond}
1064 },
1065 }
1066
1067 windows, activeWindowIndex := app.GetWindowSnapshotState()
1068
1069 require.Nil(t, windows)
1070 require.Equal(t, -1, activeWindowIndex)
1071}
1072
1073func TestApp_OpenFreshWindowReportsMainThreadDispatchTimeout(t *testing.T) {
1074 var factoryCalls int
1075 app := &App{
1076 dispatchOnMainThread: func(label string, fn func()) syncdispatch.SyncDispatchResult {
1077 return syncdispatch.SyncDispatchResult{Label: label, Status: syncdispatch.SyncDispatchTimedOut, Elapsed: 5 * time.Millisecond}
1078 },
1079 browserWindowFactory: func(context.Context, string) (*browserWindow, error) {
1080 factoryCalls++
1081 return &browserWindow{id: "window-timeout", tabs: entity.NewTabList()}, nil
1082 },
1083 }
1084
1085 err := app.OpenFreshWindow(context.Background(), "https://example.com")
1086
1087 if err == nil {
1088 t.Fatal("OpenFreshWindow returned nil error, want dispatch timeout")
1089 }
1090 if !strings.Contains(err.Error(), "main thread dispatch did not complete") {
1091 t.Fatalf("OpenFreshWindow error = %q, want dispatch timeout", err.Error())
1092 }
1093 if factoryCalls != 0 {
1094 t.Fatalf("browserWindowFactory calls = %d, want 0", factoryCalls)
1095 }
1096}
1097
1098func TestApp_OpenFreshWindowRecordsTabOwnership(t *testing.T) {
1099 existingTab := entity.NewTab(entity.TabID("existing-tab"), entity.WorkspaceID("existing-workspace"), entity.NewPane(entity.PaneID("existing-pane")))
1100 existingTabs := entity.NewTabList()
1101 existingTabs.Add(existingTab)
1102 existingTabs.SetActive(existingTab.ID)
1103 existingWindow := &browserWindow{id: "existing-window", tabs: existingTabs}
1104 created := &browserWindow{id: "window-1", tabs: entity.NewTabList()}
1105 app := &App{
1106 tabs: entity.NewTabList(),
1107 tabsUC: usecase.NewManageTabsUseCase(func() string { return "id-1" }, nil),
1108 browserWindows: map[string]*browserWindow{existingWindow.id: existingWindow},
1109 windowForTab: map[entity.TabID]*browserWindow{existingTab.ID: existingWindow},
1110 workspaceViews: map[entity.TabID]*component.WorkspaceView{entity.TabID("id-1"): &component.WorkspaceView{}},
1111 browserWindowFactory: func(context.Context, string) (*browserWindow, error) {
1112 return created, nil
1113 },
1114 }
1115
1116 if err := app.OpenFreshWindow(context.Background(), "https://example.com"); err != nil {
1117 t.Fatalf("OpenFreshWindow returned error: %v", err)
1118 }
1119 if got := windowForTabCount(t, app); got != 2 {
1120 t.Fatalf("windowForTab length = %d, want 2 (existing + new tab)", got)
1121 }
1122 assertWindowOwnershipInvariant(t, app)
1123}
1124
1125func TestApp_RestoreSessionHandlesEmptyWindowSnapshotAsSuccess(t *testing.T) {
1126 ctx := context.Background()
1127 sessionID := entity.SessionID("session-empty-windows")
1128 staleTab := entity.NewTab(entity.TabID("stale-tab"), entity.WorkspaceID("stale-workspace"), entity.NewPane(entity.PaneID("stale-pane")))
1129 staleTabs := entity.NewTabList()
1130 staleTabs.Add(staleTab)
1131 staleWindow := &browserWindow{id: "window-stale", tabs: staleTabs}
1132 state := entity.SnapshotFromWindowTabLists(sessionID, []entity.WindowTabListState{}, 0, time.Unix(123, 0))
1133
1134 app := &App{
1135 deps: &Dependencies{
1136 SessionStateRepo: mockSessionStateRepoWithSnapshot(t, sessionID, state),
1137 },
1138 runtimeConfig: runtimeConfigStateForTest(&config.Config{}),
1139 tabs: entity.NewTabList(),
1140 browserWindows: map[string]*browserWindow{staleWindow.id: staleWindow},
1141 lastFocusedWindowID: staleWindow.id,
1142 workspaceViews: map[entity.TabID]*component.WorkspaceView{staleTab.ID: {}},
1143 windowForTab: map[entity.TabID]*browserWindow{staleTab.ID: staleWindow},
1144 }
1145 app.tabs.Add(staleTab)
1146
1147 err := app.restoreSession(ctx, sessionID)
1148 if err != nil {
1149 t.Fatalf("restoreSession returned error for valid v2 empty window snapshot: %v", err)
1150 }
1151 // Stale state should be cleared and stale window should have an empty tab list.
1152 if _, ok := app.workspaceViews[staleTab.ID]; ok {
1153 t.Fatal("stale workspace view was not cleared")
1154 }
1155 if _, ok := app.windowForTab[staleTab.ID]; ok {
1156 t.Fatal("stale windowForTab entry was not cleared")
1157 }
1158 if got := app.tabs.Count(); got != 0 {
1159 t.Fatalf("app.tabs.Count() = %d, want 0 after empty restore", got)
1160 }
1161 if got := staleWindow.tabs.Count(); got != 0 {
1162 t.Fatalf("staleWindow.tabs.Count() = %d, want 0 after empty restore", got)
1163 }
1164}
1165
1166func TestApp_CreateInitialTabNoFallbackAfterEmptyWindowRestore(t *testing.T) {
1167 ctx := context.Background()
1168 sessionID := entity.SessionID("session-empty-windows-fallback")
1169 state := entity.SnapshotFromWindowTabLists(sessionID, []entity.WindowTabListState{}, 0, time.Unix(123, 0))
1170 tabsUC := usecase.NewManageTabsUseCase(func() string { return "fallback-tab" }, nil)
1171 windowTabs := entity.NewTabList()
1172 bw := &browserWindow{id: "window-1", tabs: windowTabs}
1173 app := &App{
1174 deps: &Dependencies{
1175 RestoreSessionID: string(sessionID),
1176 InitialURL: "https://fallback.example",
1177 SessionStateRepo: mockSessionStateRepoWithSnapshot(t, sessionID, state),
1178 },
1179 runtimeConfig: runtimeConfigStateForTest(&config.Config{}),
1180 tabs: entity.NewTabList(),
1181 tabsUC: tabsUC,
1182 tabCoord: coordinator.NewTabCoordinator(ctx, coordinator.TabCoordinatorConfig{TabsUC: tabsUC, Tabs: entity.NewTabList()}),
1183 browserWindows: map[string]*browserWindow{bw.id: bw},
1184 lastFocusedWindowID: bw.id,
1185 }
1186
1187 app.createInitialTab(ctx)
1188
1189 // For valid v2 empty-window restore, no fallback tab should be created.
1190 if created := windowTabs.Find(entity.TabID("fallback-tab")); created != nil {
1191 t.Fatal("fallback tab was created despite valid v2 empty-window restore")
1192 }
1193 if got := app.tabs.Count(); got != 0 {
1194 t.Fatalf("app.tabs.Count() = %d, want 0 after empty restore", got)
1195 }
1196}
1197
1198func TestApp_RestoreSessionClearsStaleUIStateBeforeApplyingRestoredTabs(t *testing.T) {
1199 gtkApp := requireGTKDisplayApp(t)
1200 defer gtkApp.Unref()
1201
1202 mainWindow, err := window.New(context.Background(), gtkApp, "top")
1203 if err != nil {
1204 t.Fatalf("window creation failed: %v", err)
1205 }
1206 defer mainWindow.Destroy()
1207
1208 staleTab := entity.NewTab(entity.TabID("stale-tab"), entity.WorkspaceID("stale-workspace"), entity.NewPane(entity.PaneID("stale-pane")))
1209 staleWindow := &browserWindow{id: "window-stale", mainWindow: mainWindow}
1210 staleSessionKey := floatingSessionKey{tabID: staleTab.ID, sessionID: "profile-stale"}
1211 restoredSessionID := entity.SessionID("session-restore")
1212 restoredTabs := entity.NewTabList()
1213 restoredTabs.Add(entity.NewTab(entity.TabID("restored-tab"), entity.WorkspaceID("restored-workspace"), entity.NewPane(entity.PaneID("restored-pane"))))
1214
1215 app := &App{
1216 deps: &Dependencies{
1217 SessionStateRepo: mockSessionStateRepoWithSnapshot(t, restoredSessionID, entity.SnapshotFromTabList(restoredSessionID, restoredTabs)),
1218 },
1219 runtimeConfig: runtimeConfigStateForTest(&config.Config{}),
1220 mainWindow: mainWindow,
1221 widgetFactory: layout.NewGtkWidgetFactory(),
1222 tabs: entity.NewTabList(),
1223 browserWindows: map[string]*browserWindow{staleWindow.id: staleWindow},
1224 workspaceViews: map[entity.TabID]*component.WorkspaceView{staleTab.ID: &component.WorkspaceView{}},
1225 windowForTab: map[entity.TabID]*browserWindow{staleTab.ID: staleWindow},
1226 floatingSessions: map[floatingSessionKey]*floatingWorkspaceSession{
1227 staleSessionKey: {},
1228 },
1229 }
1230 app.tabs.Add(staleTab)
1231 mainWindow.TabBar().AddTab(staleTab)
1232
1233 if err := app.restoreSession(context.Background(), restoredSessionID); err != nil {
1234 t.Fatalf("restoreSession returned error: %v", err)
1235 }
1236
1237 if got := app.tabs.Count(); got != 1 {
1238 t.Fatalf("tabs.Count() = %d, want 1", got)
1239 }
1240 if got := mainWindow.TabBar().Count(); got != 1 {
1241 t.Fatalf("tab bar count = %d, want 1", got)
1242 }
1243 if _, ok := app.workspaceViews[staleTab.ID]; ok {
1244 t.Fatalf("stale workspace view was not removed")
1245 }
1246 if _, ok := app.windowForTab[staleTab.ID]; ok {
1247 t.Fatalf("stale windowForTab entry was not removed")
1248 }
1249 if _, ok := app.floatingSessions[staleSessionKey]; ok {
1250 t.Fatalf("stale floating session was not removed")
1251 }
1252 assertWindowOwnershipInvariant(t, app)
1253}
1254
1255func TestApp_RestoreSessionWiresStackedPaneTitleBarCallbacks(t *testing.T) {
1256 gtkApp := requireGTKDisplayApp(t)
1257 defer gtkApp.Unref()
1258
1259 mainWindow, err := window.New(context.Background(), gtkApp, "top")
1260 if err != nil {
1261 t.Fatalf("window creation failed: %v", err)
1262 }
1263 defer mainWindow.Destroy()
1264
1265 stackedSessionID := entity.SessionID("session-stacked-restore")
1266 stackedTabs := entity.NewTabList()
1267 stackedTabs.Add(entity.NewTab(entity.TabID("restored-tab"), entity.WorkspaceID("restored-workspace"), entity.NewPane(entity.PaneID("restored-pane-1"))))
1268 stackedTabs.Tabs[0].Workspace = &entity.Workspace{
1269 ID: entity.WorkspaceID("restored-workspace"),
1270 Root: &entity.PaneNode{
1271 ID: "restored-stack-root",
1272 IsStacked: true,
1273 Children: []*entity.PaneNode{
1274 {ID: "restored-child-1", Pane: entity.NewPane(entity.PaneID("restored-pane-1"))},
1275 {ID: "restored-child-2", Pane: entity.NewPane(entity.PaneID("restored-pane-2"))},
1276 },
1277 ActiveStackIndex: 0,
1278 },
1279 ActivePaneID: entity.PaneID("restored-pane-1"),
1280 }
1281
1282 app := &App{
1283 deps: &Dependencies{
1284 SessionStateRepo: mockSessionStateRepoWithSnapshot(t, stackedSessionID, entity.SnapshotFromTabList(stackedSessionID, stackedTabs)),
1285 },
1286 runtimeConfig: runtimeConfigStateForTest(&config.Config{}),
1287 mainWindow: mainWindow,
1288 widgetFactory: layout.NewGtkWidgetFactory(),
1289 contentCoord: &contentcoord.Coordinator{},
1290 wsCoord: coordinator.NewWorkspaceCoordinator(context.Background(), coordinator.WorkspaceCoordinatorConfig{ContentCoord: &contentcoord.Coordinator{}}),
1291 tabs: entity.NewTabList(),
1292 browserWindows: map[string]*browserWindow{},
1293 workspaceViews: map[entity.TabID]*component.WorkspaceView{},
1294 windowForTab: map[entity.TabID]*browserWindow{},
1295 }
1296
1297 if err := app.restoreSession(context.Background(), stackedSessionID); err != nil {
1298 t.Fatalf("restoreSession returned error: %v", err)
1299 }
1300
1301 restoredTab := app.tabs.ActiveTab()
1302 if restoredTab == nil {
1303 t.Fatal("restored active tab missing")
1304 }
1305 wv := app.workspaceViews[restoredTab.ID]
1306 if wv == nil {
1307 t.Fatal("restored workspace view missing")
1308 }
1309 tr := wv.TreeRenderer()
1310 if tr == nil {
1311 t.Fatal("restored tree renderer missing")
1312 }
1313 if restoredTab.Workspace == nil || restoredTab.Workspace.Root == nil || len(restoredTab.Workspace.Root.Children) == 0 {
1314 t.Fatal("restored stacked workspace missing children")
1315 }
1316 stackedView := tr.GetStackedViewForPane(string(restoredTab.Workspace.Root.Children[0].Pane.ID))
1317 if stackedView == nil {
1318 t.Fatal("restored stacked view missing")
1319 }
1320 if stackedViewOnActivateIsNil(t, stackedView) {
1321 t.Fatal("restored stacked view onActivate callback is nil")
1322 }
1323}
1324
1325func TestApp_RemoveBrowserWindowRebindsPromotedTabCoordinatorWindow(t *testing.T) {
1326 oldWindow := &browserWindow{id: "window-1", mainWindow: &window.MainWindow{}}
1327 newWindow := &browserWindow{id: "window-2", mainWindow: &window.MainWindow{}}
1328 tc := coordinator.NewTabCoordinator(context.Background(), coordinator.TabCoordinatorConfig{
1329 MainWindow: oldWindow.mainWindow,
1330 })
1331 app := &App{
1332 mainWindow: oldWindow.mainWindow,
1333 browserWindows: map[string]*browserWindow{oldWindow.id: oldWindow, newWindow.id: newWindow},
1334 tabCoord: tc,
1335 }
1336
1337 app.removeBrowserWindow(oldWindow.id)
1338
1339 if app.mainWindow != newWindow.mainWindow {
1340 t.Fatalf("mainWindow = %p, want %p", app.mainWindow, newWindow.mainWindow)
1341 }
1342 if got := tabCoordinatorMainWindowPtr(t, tc); got != reflect.ValueOf(newWindow.mainWindow).Pointer() {
1343 t.Fatalf("tab coordinator mainWindow = %x, want %x", got, reflect.ValueOf(newWindow.mainWindow).Pointer())
1344 }
1345}
1346
1347func TestApp_OpenFreshWindowRollsBackOnTabCreationFailure(t *testing.T) {
1348 created := &browserWindow{id: "window-1", tabs: entity.NewTabList()}
1349 originalWindow := &window.MainWindow{}
1350 tabBar := &component.TabBar{}
1351 setWindowTabBar(t, originalWindow, tabBar)
1352 existingTabID := entity.TabID("existing-tab")
1353 staleTabID := entity.TabID("stale-tab")
1354 tabBar.SetActive(staleTabID)
1355
1356 existingTab := entity.NewTab(existingTabID, entity.WorkspaceID("existing-workspace"), entity.NewPane(entity.PaneID("existing-pane")))
1357 existingTabs := entity.NewTabList()
1358 existingTabs.Add(existingTab)
1359 existingTabs.SetActive(existingTab.ID)
1360 existingWindow := &browserWindow{id: "existing-window", tabs: existingTabs, mainWindow: originalWindow}
1361
1362 app := &App{
1363 tabs: entity.NewTabList(),
1364 tabsUC: usecase.NewManageTabsUseCase(func() string { return "id-1" }, nil),
1365 browserWindows: map[string]*browserWindow{existingWindow.id: existingWindow},
1366 windowForTab: map[entity.TabID]*browserWindow{existingTab.ID: existingWindow},
1367 browserWindowFactory: func(context.Context, string) (*browserWindow, error) {
1368 return created, nil
1369 },
1370 mainWindow: originalWindow,
1371 }
1372 app.tabCoord = coordinator.NewTabCoordinator(context.Background(), coordinator.TabCoordinatorConfig{
1373 TabsUC: app.tabsUC,
1374 Tabs: entity.NewTabList(),
1375 MainWindow: &window.MainWindow{},
1376 })
1377
1378 if err := app.OpenFreshWindow(context.Background(), "https://example.com/fail"); err == nil {
1379 t.Fatalf("OpenFreshWindow = nil error, want failure")
1380 }
1381 if got := len(app.browserWindows); got != 1 {
1382 t.Fatalf("browserWindows length = %d, want 1 (existing window only)", got)
1383 }
1384 if app.browserWindows[existingWindow.id] == nil {
1385 t.Fatal("existing window should remain after rollback")
1386 }
1387 if app.browserWindows["window-1"] != nil {
1388 t.Fatal("created window should be removed after rollback")
1389 }
1390 if got := windowForTabCount(t, app); got != 1 {
1391 t.Fatalf("windowForTab length = %d, want 1 (existing tab only)", got)
1392 }
1393 if got := windowTabBarActiveID(t, originalWindow); got != staleTabID {
1394 t.Fatalf("tab bar active tab = %q, want %q (original window unchanged)", got, staleTabID)
1395 }
1396}
1397
1398func TestApp_OpenFreshWindowTargetsNewWindowTabBar(t *testing.T) {
1399 existingTabID := entity.TabID("existing-tab")
1400 createdTabID := entity.TabID("id-1")
1401 oldWindow := &window.MainWindow{}
1402 newWindow := &window.MainWindow{}
1403 setWindowTabBar(t, oldWindow, newTestTabBarShell(t))
1404 setWindowTabBar(t, newWindow, newTestTabBarShell(t, createdTabID))
1405 tabs := entity.NewTabList()
1406 tabsUC := usecase.NewManageTabsUseCase(func() string { return string(createdTabID) }, nil)
1407
1408 existingTab := entity.NewTab(existingTabID, entity.WorkspaceID("existing-workspace"), entity.NewPane(entity.PaneID("existing-pane")))
1409 existingTabs := entity.NewTabList()
1410 existingTabs.Add(existingTab)
1411 existingTabs.SetActive(existingTab.ID)
1412 existingWindow := &browserWindow{id: "existing-window", tabs: existingTabs, mainWindow: oldWindow}
1413
1414 app := &App{
1415 tabs: tabs,
1416 tabsUC: tabsUC,
1417 browserWindows: map[string]*browserWindow{existingWindow.id: existingWindow},
1418 windowForTab: map[entity.TabID]*browserWindow{existingTab.ID: existingWindow},
1419 browserWindowFactory: func(context.Context, string) (*browserWindow, error) {
1420 return &browserWindow{id: "window-1", mainWindow: newWindow}, nil
1421 },
1422 mainWindow: oldWindow,
1423 tabCoord: coordinator.NewTabCoordinator(context.Background(), coordinator.TabCoordinatorConfig{
1424 TabsUC: tabsUC,
1425 Tabs: tabs,
1426 MainWindow: oldWindow,
1427 HideTabBarWhenSingleTab: true,
1428 }),
1429 workspaceViews: make(map[entity.TabID]*component.WorkspaceView),
1430 }
1431 app.tabCoord.SetOnTabCreated(func(ctx context.Context, target coordinator.TabTarget, tab *entity.Tab) {
1432 app.workspaceViews[tab.ID] = &component.WorkspaceView{}
1433 })
1434
1435 if err := app.OpenFreshWindow(context.Background(), "https://example.com"); err != nil {
1436 t.Fatalf("OpenFreshWindow returned error: %v", err)
1437 }
1438
1439 gotCreatedTabID := app.tabs.ActiveTabID
1440 if gotCreatedTabID == "" {
1441 t.Fatalf("created tab id = %q, want non-empty", gotCreatedTabID)
1442 }
1443 if got := windowTabBarActiveID(t, newWindow); got != gotCreatedTabID {
1444 t.Fatalf("new window tab bar active tab = %q, want %q", got, gotCreatedTabID)
1445 }
1446 if got := windowTabBarActiveID(t, oldWindow); got != "" {
1447 t.Fatalf("old window tab bar active tab = %q, want empty", got)
1448 }
1449 // The lightweight test tab bar shell has no GTK box, so allocation state is
1450 // covered by TestApp_UpdateBrowserWindowTabBarVisibilityAutoHidesSingleTabButPreservesAllocation.
1451}
1452
1453func TestApp_ActivateBrowserWindowSwitchesActiveWorkspace(t *testing.T) {
1454 tab1 := entity.NewTab(entity.TabID("tab-1"), entity.WorkspaceID("workspace-1"), entity.NewPane(entity.PaneID("pane-1")))
1455 tab2 := entity.NewTab(entity.TabID("tab-2"), entity.WorkspaceID("workspace-2"), entity.NewPane(entity.PaneID("pane-2")))
1456 tabs := entity.NewTabList()
1457 tabs.Add(tab1)
1458 tabs.Add(tab2)
1459 ws1 := &component.WorkspaceView{}
1460 ws2 := &component.WorkspaceView{}
1461 firstTabs := entity.NewTabList()
1462 firstTabs.Add(tab1)
1463 firstTabs.SetActive(tab1.ID)
1464 first := &browserWindow{id: "window-1", tabs: firstTabs, mainWindow: &window.MainWindow{}}
1465 secondTabs := entity.NewTabList()
1466 secondTabs.Add(tab2)
1467 secondTabs.SetActive(tab2.ID)
1468 second := &browserWindow{id: "window-2", tabs: secondTabs, mainWindow: &window.MainWindow{}}
1469 kh := &input.KeyboardHandler{}
1470 gs := &input.GlobalShortcutHandler{}
1471 second.keyboardHandler = kh
1472 second.globalShortcutHandler = gs
1473 app := &App{
1474 tabs: tabs,
1475 mainWindow: first.mainWindow,
1476 browserWindows: map[string]*browserWindow{first.id: first, second.id: second},
1477 windowForTab: map[entity.TabID]*browserWindow{tab1.ID: first, tab2.ID: second},
1478 workspaceViews: map[entity.TabID]*component.WorkspaceView{tab1.ID: ws1, tab2.ID: ws2},
1479 }
1480
1481 app.activateBrowserWindow(second)
1482
1483 if app.mainWindow != second.mainWindow {
1484 t.Fatalf("mainWindow = %p, want %p", app.mainWindow, second.mainWindow)
1485 }
1486 if second.tabs.ActiveTabID != tab2.ID {
1487 t.Fatalf("active tab = %q, want %q", second.tabs.ActiveTabID, tab2.ID)
1488 }
1489 if app.activeWorkspaceView() != ws2 {
1490 t.Fatalf("active workspace view = %p, want %p", app.activeWorkspaceView(), ws2)
1491 }
1492 if app.keyboardHandler != kh {
1493 t.Fatalf("keyboardHandler = %p, want %p", app.keyboardHandler, kh)
1494 }
1495 if app.globalShortcutHandler != gs {
1496 t.Fatalf("globalShortcutHandler = %p, want %p", app.globalShortcutHandler, gs)
1497 }
1498}
1499
1500func TestApp_BrowserWindowForPaneFindsOwningWindow(t *testing.T) {
1501 tab1 := entity.NewTab(entity.TabID("tab-1"), entity.WorkspaceID("workspace-1"), entity.NewPane(entity.PaneID("pane-1")))
1502 tab2 := entity.NewTab(entity.TabID("tab-2"), entity.WorkspaceID("workspace-2"), entity.NewPane(entity.PaneID("pane-2")))
1503 tabs := entity.NewTabList()
1504 tabs.Add(tab1)
1505 tabs.Add(tab2)
1506 firstTabs := entity.NewTabList()
1507 firstTabs.Add(tab1)
1508 secondTabs := entity.NewTabList()
1509 secondTabs.Add(tab2)
1510 first := &browserWindow{id: "window-1", tabs: firstTabs}
1511 second := &browserWindow{id: "window-2", tabs: secondTabs}
1512 app := &App{
1513 tabs: tabs,
1514 browserWindows: map[string]*browserWindow{first.id: first, second.id: second},
1515 windowForTab: map[entity.TabID]*browserWindow{tab1.ID: first, tab2.ID: second},
1516 }
1517
1518 got := app.browserWindowForPane(entity.PaneID("pane-2"))
1519
1520 if got != second {
1521 t.Fatalf("browserWindowForPane = %p, want %p", got, second)
1522 }
1523}
1524
1525func TestApp_OwnerOrLastFocusedBrowserWindowPrefersPaneOwner(t *testing.T) {
1526 tab1 := entity.NewTab(entity.TabID("tab-1"), entity.WorkspaceID("workspace-1"), entity.NewPane(entity.PaneID("pane-1")))
1527 tab2 := entity.NewTab(entity.TabID("tab-2"), entity.WorkspaceID("workspace-2"), entity.NewPane(entity.PaneID("pane-2")))
1528 tabs := entity.NewTabList()
1529 tabs.Add(tab1)
1530 tabs.Add(tab2)
1531 firstTabs := entity.NewTabList()
1532 firstTabs.Add(tab1)
1533 secondTabs := entity.NewTabList()
1534 secondTabs.Add(tab2)
1535 first := &browserWindow{id: "window-1", tabs: firstTabs}
1536 second := &browserWindow{id: "window-2", tabs: secondTabs}
1537 app := &App{
1538 tabs: tabs,
1539 browserWindows: map[string]*browserWindow{first.id: first, second.id: second},
1540 windowForTab: map[entity.TabID]*browserWindow{tab1.ID: first, tab2.ID: second},
1541 lastFocusedWindowID: first.id,
1542 }
1543
1544 got := app.ownerOrLastFocusedBrowserWindow("", entity.PaneID("pane-2"))
1545
1546 if got != second {
1547 t.Fatalf("ownerOrLastFocusedBrowserWindow = %p, want %p", got, second)
1548 }
1549}
1550
1551func TestApp_HandlePaneWindowTitleChangedTargetsOwningWindow(t *testing.T) {
1552 gtkApp := requireGTKDisplayApp(t)
1553 defer gtkApp.Unref()
1554
1555 firstMainWindow, err := window.New(context.Background(), gtkApp, "top")
1556 if err != nil {
1557 t.Fatalf("first window creation failed: %v", err)
1558 }
1559 defer firstMainWindow.Destroy()
1560 secondMainWindow, err := window.New(context.Background(), gtkApp, "top")
1561 if err != nil {
1562 t.Fatalf("second window creation failed: %v", err)
1563 }
1564 defer secondMainWindow.Destroy()
1565
1566 tab1 := entity.NewTab(entity.TabID("tab-1"), entity.WorkspaceID("workspace-1"), entity.NewPane(entity.PaneID("pane-1")))
1567 tab2 := entity.NewTab(entity.TabID("tab-2"), entity.WorkspaceID("workspace-2"), entity.NewPane(entity.PaneID("pane-2")))
1568 tabs := entity.NewTabList()
1569 tabs.Add(tab1)
1570 tabs.Add(tab2)
1571 firstTabs := entity.NewTabList()
1572 firstTabs.Add(tab1)
1573 firstTabs.SetActive(tab1.ID)
1574 secondTabs := entity.NewTabList()
1575 secondTabs.Add(tab2)
1576 secondTabs.SetActive(tab2.ID)
1577 first := &browserWindow{id: "window-1", mainWindow: firstMainWindow, tabs: firstTabs}
1578 second := &browserWindow{id: "window-2", mainWindow: secondMainWindow, tabs: secondTabs}
1579 app := &App{
1580 tabs: tabs,
1581 browserWindows: map[string]*browserWindow{first.id: first, second.id: second},
1582 windowForTab: map[entity.TabID]*browserWindow{tab1.ID: first, tab2.ID: second},
1583 lastFocusedWindowID: first.id,
1584 }
1585
1586 app.handlePaneWindowTitleChanged(entity.PaneID("pane-2"), "Pane Two")
1587
1588 if got := windowTitle(t, firstMainWindow); got != "Dumber" {
1589 t.Fatalf("first window title = %q, want %q", got, "Dumber")
1590 }
1591 if got := windowTitle(t, secondMainWindow); got != "Pane Two - Dumber" {
1592 t.Fatalf("second window title = %q, want %q", got, "Pane Two - Dumber")
1593 }
1594}
1595
1596func TestApp_ActivateBrowserWindowResyncsTitleFromBackgroundPane(t *testing.T) {
1597 gtkApp := requireGTKDisplayApp(t)
1598 defer gtkApp.Unref()
1599
1600 firstMainWindow, err := window.New(context.Background(), gtkApp, "top")
1601 if err != nil {
1602 t.Fatalf("first window creation failed: %v", err)
1603 }
1604 defer firstMainWindow.Destroy()
1605 secondMainWindow, err := window.New(context.Background(), gtkApp, "top")
1606 if err != nil {
1607 t.Fatalf("second window creation failed: %v", err)
1608 }
1609 defer secondMainWindow.Destroy()
1610
1611 tab1 := entity.NewTab(entity.TabID("tab-1"), entity.WorkspaceID("workspace-1"), entity.NewPane(entity.PaneID("pane-1")))
1612 tab2 := entity.NewTab(entity.TabID("tab-2"), entity.WorkspaceID("workspace-2"), entity.NewPane(entity.PaneID("pane-2")))
1613 tabs := entity.NewTabList()
1614 tabs.Add(tab1)
1615 tabs.Add(tab2)
1616 firstTabs := entity.NewTabList()
1617 firstTabs.Add(tab1)
1618 firstTabs.SetActive(tab1.ID)
1619 first := &browserWindow{id: "window-1", mainWindow: firstMainWindow, tabs: firstTabs}
1620 secondTabs := entity.NewTabList()
1621 secondTabs.Add(tab2)
1622 secondTabs.SetActive(tab2.ID)
1623 second := &browserWindow{id: "window-2", mainWindow: secondMainWindow, tabs: secondTabs}
1624 contentCoord := &contentcoord.Coordinator{}
1625 contentTitles := reflect.ValueOf(contentCoord).Elem().FieldByName("paneTitles")
1626 reflect.NewAt(contentTitles.Type(), unsafe.Pointer(contentTitles.UnsafeAddr())).Elem().Set(reflect.ValueOf(map[entity.PaneID]string{tab2.Workspace.ActivePaneID: "Pane Two"}))
1627 app := &App{
1628 tabs: tabs,
1629 browserWindows: map[string]*browserWindow{first.id: first, second.id: second},
1630 windowForTab: map[entity.TabID]*browserWindow{tab1.ID: first, tab2.ID: second},
1631 contentCoord: contentCoord,
1632 }
1633
1634 app.handlePaneWindowTitleChanged(tab2.Workspace.ActivePaneID, "Pane Two")
1635 secondMainWindow.SetTitle("Dumber")
1636
1637 app.activateBrowserWindow(second)
1638
1639 if got := windowTitle(t, secondMainWindow); got != "Pane Two - Dumber" {
1640 t.Fatalf("second window title = %q, want %q", got, "Pane Two - Dumber")
1641 }
1642 if got := windowTitle(t, firstMainWindow); got != "Dumber" {
1643 t.Fatalf("first window title = %q, want %q", got, "Dumber")
1644 }
1645}
1646
1647func TestApp_HandlePaneFullscreenChangedTargetsOwningWindow(t *testing.T) {
1648 gtkApp := requireGTKDisplayApp(t)
1649 defer gtkApp.Unref()
1650
1651 firstMainWindow, err := window.New(context.Background(), gtkApp, "top")
1652 if err != nil {
1653 t.Fatalf("first window creation failed: %v", err)
1654 }
1655 defer firstMainWindow.Destroy()
1656 secondMainWindow, err := window.New(context.Background(), gtkApp, "top")
1657 if err != nil {
1658 t.Fatalf("second window creation failed: %v", err)
1659 }
1660 defer secondMainWindow.Destroy()
1661
1662 tab1 := entity.NewTab(entity.TabID("tab-1"), entity.WorkspaceID("workspace-1"), entity.NewPane(entity.PaneID("pane-1")))
1663 tab2 := entity.NewTab(entity.TabID("tab-2"), entity.WorkspaceID("workspace-2"), entity.NewPane(entity.PaneID("pane-2")))
1664 tabs := entity.NewTabList()
1665 tabs.Add(tab1)
1666 tabs.Add(tab2)
1667 secondMainWindow.TabBar().AddTab(tab1)
1668 secondMainWindow.TabBar().AddTab(tab2)
1669 firstTabs := entity.NewTabList()
1670 firstTabs.Add(tab1)
1671 firstTabs.SetActive(tab1.ID)
1672 secondTabs := entity.NewTabList()
1673 secondTabs.Add(tab2)
1674 secondTabs.SetActive(tab2.ID)
1675 first := &browserWindow{id: "window-1", mainWindow: firstMainWindow, tabs: firstTabs}
1676 second := &browserWindow{id: "window-2", mainWindow: secondMainWindow, tabs: secondTabs}
1677 app := &App{
1678 tabs: tabs,
1679 browserWindows: map[string]*browserWindow{first.id: first, second.id: second},
1680 windowForTab: map[entity.TabID]*browserWindow{tab1.ID: first, tab2.ID: second},
1681 lastFocusedWindowID: first.id,
1682 }
1683
1684 app.handlePaneFullscreenChanged(entity.PaneID("pane-2"), true)
1685
1686 if windowTabBarVisible(t, firstMainWindow) != true {
1687 t.Fatalf("first window tab bar visibility changed unexpectedly")
1688 }
1689 if got := windowTabBarVisible(t, secondMainWindow); got {
1690 t.Fatalf("second window tab bar visible = %v, want false", got)
1691 }
1692
1693 app.handlePaneFullscreenChanged(entity.PaneID("pane-2"), false)
1694
1695 if got := windowTabBarVisible(t, secondMainWindow); !got {
1696 t.Fatalf("second window tab bar visible = %v, want true", got)
1697 }
1698}
1699
1700func TestApp_HandlePaneFullscreenChangedIgnoresUnresolvedPane(t *testing.T) {
1701 gtkApp := requireGTKDisplayApp(t)
1702 defer gtkApp.Unref()
1703
1704 firstMainWindow, err := window.New(context.Background(), gtkApp, "top")
1705 if err != nil {
1706 t.Fatalf("first window creation failed: %v", err)
1707 }
1708 defer firstMainWindow.Destroy()
1709 secondMainWindow, err := window.New(context.Background(), gtkApp, "top")
1710 if err != nil {
1711 t.Fatalf("second window creation failed: %v", err)
1712 }
1713 defer secondMainWindow.Destroy()
1714 firstMainWindow.TabBar().SetVisible(true)
1715 secondMainWindow.TabBar().SetVisible(true)
1716 first := &browserWindow{id: "window-1", mainWindow: firstMainWindow}
1717 second := &browserWindow{id: "window-2", mainWindow: secondMainWindow}
1718 app := &App{
1719 browserWindows: map[string]*browserWindow{first.id: first, second.id: second},
1720 lastFocusedWindowID: first.id,
1721 }
1722
1723 app.handlePaneFullscreenChanged(entity.PaneID("missing-pane"), true)
1724
1725 if got := windowTabBarVisible(t, firstMainWindow); !got {
1726 t.Fatalf("first window tab bar visible = %v, want true", got)
1727 }
1728 if got := windowTabBarVisible(t, secondMainWindow); !got {
1729 t.Fatalf("second window tab bar visible = %v, want true", got)
1730 }
1731}
1732
1733func TestApp_HandleAccentKeyPressDelegatesToOwningWindow(t *testing.T) {
1734 first := newTestAccentUseCase(t, true)
1735 second := newTestAccentUseCase(t, false)
1736 firstWindow := &browserWindow{id: "window-1", insertAccentUC: first}
1737 secondWindow := &browserWindow{id: "window-2", insertAccentUC: second}
1738 app := &App{
1739 browserWindows: map[string]*browserWindow{firstWindow.id: firstWindow, secondWindow.id: secondWindow},
1740 lastFocusedWindowID: secondWindow.id,
1741 }
1742
1743 if got := app.handleAccentKeyPress(context.Background(), uint('e'), 0); got {
1744 t.Fatalf("handleAccentKeyPress returned %v, want false from active shell handler", got)
1745 }
1746 if got := first.IsPickerVisible(); !got {
1747 t.Fatalf("first shell accent handler should remain visible")
1748 }
1749 if got := second.IsPickerVisible(); got {
1750 t.Fatalf("second shell accent handler should remain hidden")
1751 }
1752}
1753
1754func TestApp_MoveActivePaneToTabFromBrowserWindowAnchorsOwningWindow(t *testing.T) {
1755 tabs := entity.NewTabList()
1756 tab1 := entity.NewTab(entity.TabID("tab-1"), entity.WorkspaceID("workspace-1"), entity.NewPane(entity.PaneID("pane-1")))
1757 tab2 := entity.NewTab(entity.TabID("tab-2"), entity.WorkspaceID("workspace-2"), entity.NewPane(entity.PaneID("pane-2")))
1758 tabs.Add(tab1)
1759 tabs.Add(tab2)
1760 firstTabs := entity.NewTabList()
1761 firstTabs.Add(tab1)
1762 firstTabs.SetActive(tab1.ID)
1763 first := &browserWindow{id: "window-1", tabs: firstTabs}
1764 secondTabs := entity.NewTabList()
1765 secondTabs.Add(tab2)
1766 secondTabs.SetActive(tab2.ID)
1767 second := &browserWindow{id: "window-2", tabs: secondTabs}
1768 app := &App{
1769 tabs: tabs,
1770 movePaneToTabUC: usecase.NewMovePaneToTabUseCase(func() string { return "new-tab" }),
1771 deps: &Dependencies{},
1772 runtimeConfig: runtimeConfigStateForTest(&config.Config{}),
1773 browserWindows: map[string]*browserWindow{first.id: first, second.id: second},
1774 lastFocusedWindowID: second.id,
1775 }
1776
1777 if err := app.moveActivePaneToTabFromBrowserWindow(context.Background(), first, ""); err != nil {
1778 t.Fatalf("moveActivePaneToTabFromBrowserWindow returned error: %v", err)
1779 }
1780
1781 if tabs.Find(tab1.ID) != nil {
1782 t.Fatalf("tab-1 should have been moved from owning window")
1783 }
1784 if tabs.Find(tab2.ID) == nil {
1785 t.Fatalf("tab-2 should remain when owning window is preserved")
1786 }
1787}
1788
1789func TestApp_MoveActivePaneToTabFromBrowserWindowActivatesTargetOwnerForCrossWindowTab(t *testing.T) {
1790 tabs := entity.NewTabList()
1791 tab1 := entity.NewTab(entity.TabID("tab-1"), entity.WorkspaceID("workspace-1"), entity.NewPane(entity.PaneID("pane-1")))
1792 tab2 := entity.NewTab(entity.TabID("tab-2"), entity.WorkspaceID("workspace-2"), entity.NewPane(entity.PaneID("pane-2")))
1793 tab3 := entity.NewTab(entity.TabID("tab-3"), entity.WorkspaceID("workspace-3"), entity.NewPane(entity.PaneID("pane-3")))
1794 tabs.Add(tab1)
1795 tabs.Add(tab2)
1796 tabs.Add(tab3)
1797 tabs.SetActive(tab1.ID)
1798 firstTabs := entity.NewTabList()
1799 firstTabs.Add(tab1)
1800 firstTabs.SetActive(tab1.ID)
1801 first := &browserWindow{id: "window-1", tabs: firstTabs}
1802 secondTabs := entity.NewTabList()
1803 secondTabs.Add(tab2)
1804 secondTabs.SetActive(tab2.ID)
1805 second := &browserWindow{id: "window-2", tabs: secondTabs}
1806 app := &App{
1807 tabs: tabs,
1808 movePaneToTabUC: usecase.NewMovePaneToTabUseCase(func() string { return "generated" }),
1809 deps: &Dependencies{},
1810 runtimeConfig: runtimeConfigStateForTest(&config.Config{}),
1811 browserWindows: map[string]*browserWindow{first.id: first, second.id: second},
1812 windowForTab: map[entity.TabID]*browserWindow{tab1.ID: first, tab2.ID: second, tab3.ID: second},
1813 lastFocusedWindowID: first.id,
1814 }
1815
1816 if err := app.moveActivePaneToTabFromBrowserWindow(context.Background(), first, tab3.ID); err != nil {
1817 t.Fatalf("moveActivePaneToTabFromBrowserWindow returned error: %v", err)
1818 }
1819
1820 if tabs.Find(tab1.ID) != nil {
1821 t.Fatalf("tab-1 should be removed after its active pane moves away")
1822 }
1823 if tabs.Find(tab2.ID) == nil {
1824 t.Fatalf("tab-2 should remain in the target owner window")
1825 }
1826 if tabs.Find(tab3.ID) == nil {
1827 t.Fatalf("tab-3 should remain as the move target")
1828 }
1829 if app.tabs.ActiveTabID != tab3.ID {
1830 t.Fatalf("active tab = %q, want %q", app.tabs.ActiveTabID, tab3.ID)
1831 }
1832 if app.lastFocusedWindowID != second.id {
1833 t.Fatalf("lastFocusedWindowID = %q, want %q", app.lastFocusedWindowID, second.id)
1834 }
1835}
1836
1837func TestApp_MoveActivePaneToTabFromBrowserWindowCrossWindowDerivedMirror(t *testing.T) {
1838 // app.tabs initially contains ONLY source tab (not target tab) to simulate
1839 // a target created only in browserWindow.tabs (the real-world scenario that
1840 // CodeRabbit flagged). The fix ensures buildMovePaneToTabInput syncs the
1841 // derived global mirror before passing a.tabs to the move usecase.
1842 tab1 := entity.NewTab(entity.TabID("tab-1"), entity.WorkspaceID("workspace-1"), entity.NewPane(entity.PaneID("pane-1")))
1843 tab3 := entity.NewTab(entity.TabID("tab-3"), entity.WorkspaceID("workspace-3"), entity.NewPane(entity.PaneID("pane-3")))
1844
1845 // app.tabs contains only tab1 — tab3 is absent from the mirror.
1846 tabs := entity.NewTabList()
1847 tabs.Add(tab1)
1848 tabs.SetActive(tab1.ID)
1849
1850 firstTabs := entity.NewTabList()
1851 firstTabs.Add(tab1)
1852 firstTabs.SetActive(tab1.ID)
1853 first := &browserWindow{id: "window-1", tabs: firstTabs}
1854
1855 secondTabs := entity.NewTabList()
1856 secondTabs.Add(tab3)
1857 secondTabs.SetActive(tab3.ID)
1858 second := &browserWindow{id: "window-2", tabs: secondTabs}
1859
1860 initialPaneCount := tab3.Workspace.PaneCount()
1861
1862 app := &App{
1863 tabs: tabs,
1864 movePaneToTabUC: usecase.NewMovePaneToTabUseCase(func() string { return "generated" }),
1865 deps: &Dependencies{},
1866 runtimeConfig: runtimeConfigStateForTest(&config.Config{}),
1867 browserWindows: map[string]*browserWindow{first.id: first, second.id: second},
1868 windowForTab: map[entity.TabID]*browserWindow{tab1.ID: first, tab3.ID: second},
1869 lastFocusedWindowID: first.id,
1870 }
1871
1872 if err := app.moveActivePaneToTabFromBrowserWindow(context.Background(), first, tab3.ID); err != nil {
1873 t.Fatalf("moveActivePaneToTabFromBrowserWindow returned error: %v", err)
1874 }
1875
1876 // The derived mirror must now contain tab3 (synced by the fix).
1877 if app.tabs.Find(tab3.ID) == nil {
1878 t.Fatalf("tab-3 should have been synced into the derived global mirror")
1879 }
1880
1881 // No new tab should have been generated — the move should target tab3.
1882 if app.tabs.Find(entity.TabID("generated")) != nil {
1883 t.Fatalf("a new tab was incorrectly generated; the move should target tab-3")
1884 }
1885
1886 // The moved pane should be in tab3's workspace.
1887 if got := tab3.Workspace.PaneCount(); got != initialPaneCount+1 {
1888 t.Fatalf("tab-3 pane count = %d, want %d (original + moved pane)", got, initialPaneCount+1)
1889 }
1890
1891 // windowForTab for tab3 must remain second.
1892 if app.windowForTab[tab3.ID] != second {
1893 t.Fatalf("windowForTab[tab-3] = %p, want second window %p", app.windowForTab[tab3.ID], second)
1894 }
1895}
1896
1897func TestApp_InitKeyboardHandlerDoesNotReattachExistingWindowInput(t *testing.T) {
1898 gtkApp := requireGTKDisplayApp(t)
1899 defer gtkApp.Unref()
1900
1901 mainWindow, err := window.New(context.Background(), gtkApp, "top")
1902 if err != nil {
1903 t.Fatalf("window creation failed: %v", err)
1904 }
1905 defer mainWindow.Destroy()
1906
1907 sentinelKeyboardHandler := &input.KeyboardHandler{}
1908 sentinelShortcutHandler := &input.GlobalShortcutHandler{}
1909 bw := &browserWindow{
1910 id: "window-1",
1911 mainWindow: mainWindow,
1912 keyboardHandler: sentinelKeyboardHandler,
1913 globalShortcutHandler: sentinelShortcutHandler,
1914 }
1915 app := &App{
1916 deps: &Dependencies{},
1917 runtimeConfig: runtimeConfigStateForTest(&config.Config{}),
1918 mainWindow: mainWindow,
1919 browserWindows: map[string]*browserWindow{bw.id: bw},
1920 kbDispatcher: dispatcher.NewKeyboardDispatcher(context.Background(), nil, nil, nil, nil, dispatcher.KeyboardActions{}, func(context.Context) entity.PaneID { return "" }),
1921 }
1922
1923 app.initKeyboardHandler(context.Background())
1924
1925 if bw.keyboardHandler != sentinelKeyboardHandler {
1926 t.Fatalf("keyboardHandler was reattached")
1927 }
1928 if bw.globalShortcutHandler != sentinelShortcutHandler {
1929 t.Fatalf("globalShortcutHandler was reattached")
1930 }
1931}
1932
1933func newTestAccentUseCase(t *testing.T, pickerVisible bool) *usecase.InsertAccentUseCase {
1934 t.Helper()
1935 uc := usecase.NewInsertAccentUseCase(&testFocusedInputProvider{}, nil, nil)
1936 if uc == nil {
1937 t.Fatal("failed to create accent use case")
1938 }
1939 setAccentUseCaseBoolField(t, uc, "pickerVisible", pickerVisible)
1940 return uc
1941}
1942
1943func setAccentUseCaseBoolField(t *testing.T, uc *usecase.InsertAccentUseCase, field string, value bool) {
1944 t.Helper()
1945 rv := reflect.ValueOf(uc).Elem()
1946 fv := rv.FieldByName(field)
1947 if !fv.IsValid() {
1948 t.Fatalf("InsertAccentUseCase missing %s field", field)
1949 }
1950 reflect.NewAt(fv.Type(), unsafe.Pointer(fv.UnsafeAddr())).Elem().SetBool(value)
1951}
1952
1953type testFocusedInputProvider struct{}
1954
1955func (*testFocusedInputProvider) GetFocusedInput() port.TextInputTarget { return nil }
1956func (*testFocusedInputProvider) SetFocusedInput(port.TextInputTarget) {}
1957
1958func TestApp_UpdateBrowserWindowTabBarVisibilityAutoHidesSingleTabButPreservesAllocation(t *testing.T) {
1959 gtkApp := requireGTKDisplayApp(t)
1960 defer gtkApp.Unref()
1961
1962 mainWindow := &window.MainWindow{}
1963 tabBar := component.NewTabBar()
1964 if tabBar == nil {
1965 t.Fatal("tab bar creation failed")
1966 }
1967 setWindowTabBar(t, mainWindow, tabBar)
1968 bw := &browserWindow{id: "window-1", mainWindow: mainWindow}
1969 cfg := &config.Config{}
1970 cfg.Workspace.HideTabBarWhenSingleTab = true
1971 app := &App{
1972 deps: &Dependencies{},
1973 runtimeConfig: runtimeConfigStateForTest(cfg),
1974 }
1975
1976 tab1 := entity.NewTab(entity.TabID("tab-1"), entity.WorkspaceID("workspace-1"), entity.NewPane(entity.PaneID("pane-1")))
1977 tab2 := entity.NewTab(entity.TabID("tab-2"), entity.WorkspaceID("workspace-2"), entity.NewPane(entity.PaneID("pane-2")))
1978 tabBar.AddTab(tab1)
1979
1980 app.updateBrowserWindowTabBarVisibility(bw)
1981 assertWindowTabBarAutoHidden(t, mainWindow, true)
1982
1983 tabBar.AddTab(tab2)
1984 app.updateBrowserWindowTabBarVisibility(bw)
1985 assertWindowTabBarAutoHidden(t, mainWindow, false)
1986
1987 tabBar.SetVisible(false)
1988 app.updateBrowserWindowTabBarVisibility(bw)
1989 if got := tabBar.Box().GetVisible(); got {
1990 t.Fatalf("tab bar visible = %v, want false while explicitly hidden", got)
1991 }
1992
1993 tabBar.SetVisible(true)
1994 app.updateBrowserWindowTabBarVisibility(bw)
1995 assertWindowTabBarAutoHidden(t, mainWindow, false)
1996
1997 tabBar.RemoveTab(tab2.ID)
1998 app.updateBrowserWindowTabBarVisibility(bw)
1999 assertWindowTabBarAutoHidden(t, mainWindow, true)
2000}
2001
2002func TestTabBarSetVisibleDoesNotClearAutoHiddenInteractionState(t *testing.T) {
2003 gtkApp := requireGTKDisplayApp(t)
2004 defer gtkApp.Unref()
2005
2006 tabBar := component.NewTabBar()
2007 if tabBar == nil {
2008 t.Fatal("tab bar creation failed")
2009 }
2010
2011 tabBar.SetAutoHidden(true)
2012 tabBar.SetVisible(false)
2013 tabBar.SetVisible(true)
2014
2015 box := tabBar.Box()
2016 if !box.GetVisible() {
2017 t.Fatal("tab bar should stay mounted when explicitly visible")
2018 }
2019 if got := box.GetOpacity(); got != 0.0 {
2020 t.Fatalf("tab bar opacity = %v, want 0 while auto-hidden", got)
2021 }
2022 if got := box.GetCanTarget(); got {
2023 t.Fatalf("tab bar can target = %v, want false while auto-hidden", got)
2024 }
2025 if got := box.GetFocusable(); got {
2026 t.Fatalf("tab bar focusable = %v, want false while auto-hidden", got)
2027 }
2028}
2029
2030func TestApp_UpdateBrowserWindowTabBarVisibilityHonorsHideWhenSingleTabDisabled(t *testing.T) {
2031 gtkApp := requireGTKDisplayApp(t)
2032 defer gtkApp.Unref()
2033
2034 mainWindow := &window.MainWindow{}
2035 tabBar := component.NewTabBar()
2036 if tabBar == nil {
2037 t.Fatal("tab bar creation failed")
2038 }
2039 setWindowTabBar(t, mainWindow, tabBar)
2040 bw := &browserWindow{id: "window-1", mainWindow: mainWindow}
2041 cfg := &config.Config{}
2042 cfg.Workspace.HideTabBarWhenSingleTab = false
2043 app := &App{
2044 deps: &Dependencies{},
2045 runtimeConfig: runtimeConfigStateForTest(cfg),
2046 }
2047
2048 app.updateBrowserWindowTabBarVisibility(bw)
2049
2050 assertWindowTabBarAutoHidden(t, mainWindow, false)
2051}
2052
2053func TestApp_ActivePaneIDForNilBrowserWindowIgnoresStaleOverride(t *testing.T) {
2054 ctx := context.Background()
2055 contentCoord := contentcoord.NewCoordinator(ctx, nil, nil, nil, nil, nil, nil, nil)
2056 contentCoord.SetActivePaneOverride(entity.PaneID("stale-pane"))
2057 app := &App{contentCoord: contentCoord}
2058
2059 if got := app.activePaneIDForBrowserWindow(nil); got != "" {
2060 t.Fatalf("active pane for nil browser window = %q, want empty", got)
2061 }
2062}
2063
2064func TestApp_BuildRestoredWindowUIUpdatesEmptyWindowTabBarVisibility(t *testing.T) {
2065 gtkApp := requireGTKDisplayApp(t)
2066 defer gtkApp.Unref()
2067
2068 mw, err := window.New(context.Background(), gtkApp, "bottom")
2069 if err != nil {
2070 t.Fatalf("window creation failed: %v", err)
2071 }
2072 defer mw.Destroy()
2073
2074 mw.SetTabBarContentInsetVisible(true)
2075 bw := &browserWindow{id: "window-1", mainWindow: mw, tabs: entity.NewTabList()}
2076 cfg := &config.Config{}
2077 cfg.Workspace.HideTabBarWhenSingleTab = true
2078 app := appWithRuntimeConfigForTest(cfg)
2079
2080 app.buildRestoredWindowUI(context.Background(), []*browserWindow{bw})
2081
2082 assertWindowTabBarAutoHidden(t, mw, true)
2083 if mw.HasTabBarContentInset() {
2084 t.Fatal("empty restored bottom window should not keep tab bar content inset")
2085 }
2086}
2087
2088func TestApp_UpdateBrowserWindowTabBarVisibility_BottomInset(t *testing.T) {
2089 gtkApp := requireGTKDisplayApp(t)
2090 defer gtkApp.Unref()
2091
2092 // Use a real bottom MainWindow so SetTabBarContentInsetVisible is effective.
2093 mainWindow, err := window.New(context.Background(), gtkApp, "bottom")
2094 if err != nil {
2095 t.Fatalf("window creation failed: %v", err)
2096 }
2097 defer mainWindow.Destroy()
2098
2099 bw := &browserWindow{id: "window-1", mainWindow: mainWindow}
2100 cfg := &config.Config{}
2101 cfg.Workspace.HideTabBarWhenSingleTab = true
2102 app := &App{
2103 deps: &Dependencies{},
2104 runtimeConfig: runtimeConfigStateForTest(cfg),
2105 }
2106
2107 tab1 := entity.NewTab(entity.TabID("tab-1"), entity.WorkspaceID("workspace-1"), entity.NewPane(entity.PaneID("pane-1")))
2108 tab2 := entity.NewTab(entity.TabID("tab-2"), entity.WorkspaceID("workspace-2"), entity.NewPane(entity.PaneID("pane-2")))
2109
2110 // One tab + hide enabled: auto-hidden and no inset
2111 mainWindow.TabBar().AddTab(tab1)
2112 app.updateBrowserWindowTabBarVisibility(bw)
2113 assertWindowTabBarAutoHidden(t, mainWindow, true)
2114 if mainWindow.HasTabBarContentInset() {
2115 t.Fatal("one tab: expected no content area inset")
2116 }
2117
2118 // Two tabs: visible and inset present
2119 mainWindow.TabBar().AddTab(tab2)
2120 app.updateBrowserWindowTabBarVisibility(bw)
2121 assertWindowTabBarAutoHidden(t, mainWindow, false)
2122 if !mainWindow.HasTabBarContentInset() {
2123 t.Fatal("two tabs: expected content area inset")
2124 }
2125
2126 // Remove back to one tab: auto-hidden and no inset
2127 mainWindow.TabBar().RemoveTab(tab2.ID)
2128 app.updateBrowserWindowTabBarVisibility(bw)
2129 assertWindowTabBarAutoHidden(t, mainWindow, true)
2130 if mainWindow.HasTabBarContentInset() {
2131 t.Fatal("back to one tab: expected no content area inset")
2132 }
2133}
2134
2135func TestApp_HandlePaneFullscreenChanged_ClearsBottomInset(t *testing.T) {
2136 gtkApp := requireGTKDisplayApp(t)
2137 defer gtkApp.Unref()
2138
2139 // Use a bottom MainWindow so SetTabBarContentInsetVisible is effective.
2140 mainWindow, err := window.New(context.Background(), gtkApp, "bottom")
2141 if err != nil {
2142 t.Fatalf("window creation failed: %v", err)
2143 }
2144 defer mainWindow.Destroy()
2145
2146 tab1 := entity.NewTab(entity.TabID("tab-1"), entity.WorkspaceID("workspace-1"), entity.NewPane(entity.PaneID("pane-1")))
2147 tab2 := entity.NewTab(entity.TabID("tab-2"), entity.WorkspaceID("workspace-2"), entity.NewPane(entity.PaneID("pane-2")))
2148 mainWindow.TabBar().AddTab(tab1)
2149 mainWindow.TabBar().AddTab(tab2)
2150
2151 tabs := entity.NewTabList()
2152 tabs.Add(tab1)
2153 tabs.Add(tab2)
2154 tabs.SetActive(tab2.ID)
2155
2156 bw := &browserWindow{id: "window-1", mainWindow: mainWindow, tabs: tabs}
2157 cfg := &config.Config{}
2158 cfg.Workspace.HideTabBarWhenSingleTab = true
2159 app := &App{
2160 tabs: tabs,
2161 deps: &Dependencies{},
2162 runtimeConfig: runtimeConfigStateForTest(cfg),
2163 browserWindows: map[string]*browserWindow{bw.id: bw},
2164 lastFocusedWindowID: bw.id,
2165 }
2166
2167 // Start with two tabs: inset should be present
2168 app.updateBrowserWindowTabBarVisibility(bw)
2169 assertWindowTabBarAutoHidden(t, mainWindow, false)
2170 if !mainWindow.HasTabBarContentInset() {
2171 t.Fatal("two tabs: expected content area inset before fullscreen")
2172 }
2173
2174 // Enter fullscreen: tab bar hidden, inset cleared
2175 app.handlePaneFullscreenChanged(entity.PaneID("pane-2"), true)
2176 if windowTabBarVisible(t, mainWindow) {
2177 t.Fatal("fullscreen: tab bar should be not visible")
2178 }
2179 if mainWindow.HasTabBarContentInset() {
2180 t.Fatal("fullscreen: expected no content area inset")
2181 }
2182
2183 // Exit fullscreen: tab bar restored, inset restored
2184 app.handlePaneFullscreenChanged(entity.PaneID("pane-2"), false)
2185 if !windowTabBarVisible(t, mainWindow) {
2186 t.Fatal("exited fullscreen: tab bar should be visible")
2187 }
2188 if !mainWindow.HasTabBarContentInset() {
2189 t.Fatal("exited fullscreen: expected content area inset to be restored")
2190 }
2191}
2192
2193func TestApp_RemoveBrowserWindowPromotesDeterministicFallbackWithMainWindow(t *testing.T) {
2194 for i := 0; i < 20; i++ {
2195 removed := &browserWindow{id: "window-z", mainWindow: &window.MainWindow{}}
2196 nilWindow := &browserWindow{id: "window-a"}
2197 firstValid := &browserWindow{id: "window-b", mainWindow: &window.MainWindow{}}
2198 secondValid := &browserWindow{id: "window-c", mainWindow: &window.MainWindow{}}
2199 app := &App{
2200 mainWindow: removed.mainWindow,
2201 browserWindows: map[string]*browserWindow{
2202 removed.id: removed,
2203 nilWindow.id: nilWindow,
2204 firstValid.id: firstValid,
2205 secondValid.id: secondValid,
2206 },
2207 browserWindowOrder: []string{removed.id, nilWindow.id, secondValid.id, firstValid.id},
2208 lastFocusedWindowID: removed.id,
2209 }
2210
2211 app.removeBrowserWindow(removed.id)
2212
2213 if app.mainWindow != secondValid.mainWindow {
2214 t.Fatalf("mainWindow = %p, want %p", app.mainWindow, secondValid.mainWindow)
2215 }
2216 if app.lastFocusedWindowID != secondValid.id {
2217 t.Fatalf("lastFocusedWindowID = %q, want %q", app.lastFocusedWindowID, secondValid.id)
2218 }
2219 }
2220}
2221
2222func TestApp_BrowserWindowActivationHookUpdatesLastFocusedWindowID(t *testing.T) {
2223 first := &browserWindow{id: "window-1", mainWindow: &window.MainWindow{}}
2224 second := &browserWindow{id: "window-2", mainWindow: &window.MainWindow{}}
2225 app := &App{
2226 browserWindows: map[string]*browserWindow{first.id: first, second.id: second},
2227 lastFocusedWindowID: first.id,
2228 }
2229
2230 app.handleBrowserWindowActivationChanged(second, true)
2231
2232 if app.lastFocusedWindowID != second.id {
2233 t.Fatalf("lastFocusedWindowID = %q, want %q", app.lastFocusedWindowID, second.id)
2234 }
2235}
2236
2237func TestApp_ActivateBrowserWindowTracksLastFocusedWindow(t *testing.T) {
2238 first := &browserWindow{id: "window-1", mainWindow: &window.MainWindow{}}
2239 second := &browserWindow{id: "window-2", mainWindow: &window.MainWindow{}}
2240 app := &App{
2241 browserWindows: map[string]*browserWindow{first.id: first, second.id: second},
2242 }
2243
2244 app.activateBrowserWindow(second)
2245
2246 if app.lastFocusedWindowID != second.id {
2247 t.Fatalf("lastFocusedWindowID = %q, want %q", app.lastFocusedWindowID, second.id)
2248 }
2249}
2250
2251func TestApp_LastFocusedBrowserWindowFallsBackWhenOwnerMissing(t *testing.T) {
2252 first := &browserWindow{id: "window-1"}
2253 second := &browserWindow{id: "window-2"}
2254 app := &App{
2255 browserWindows: map[string]*browserWindow{first.id: first, second.id: second},
2256 lastFocusedWindowID: second.id,
2257 }
2258
2259 got := app.lastFocusedBrowserWindow()
2260
2261 if got != second {
2262 t.Fatalf("lastFocusedBrowserWindow = %p, want %p", got, second)
2263 }
2264}
2265
2266func TestApp_CreatePopupTabUsesParentPaneOwnerWhenFocusIsStale(t *testing.T) {
2267 ctx := context.Background()
2268 firstTab := entity.NewTab(entity.TabID("first-tab"), entity.WorkspaceID("ws-first"), entity.NewPane(entity.PaneID("pane-first")))
2269 secondTab := entity.NewTab(entity.TabID("second-tab"), entity.WorkspaceID("ws-second"), entity.NewPane(entity.PaneID("pane-second")))
2270 globalTabs := entity.NewTabList()
2271 globalTabs.Add(firstTab)
2272 globalTabs.Add(secondTab)
2273 firstTabs := entity.NewTabList()
2274 firstTabs.Add(firstTab)
2275 secondTabs := entity.NewTabList()
2276 secondTabs.Add(secondTab)
2277 first := &browserWindow{id: "window-1", tabs: firstTabs, mainWindow: &window.MainWindow{}}
2278 second := &browserWindow{id: "window-2", tabs: secondTabs, mainWindow: &window.MainWindow{}}
2279 app := &App{
2280 deps: &Dependencies{},
2281 runtimeConfig: runtimeConfigStateForTest(&config.Config{}),
2282 tabs: globalTabs,
2283 tabsUC: usecase.NewManageTabsUseCase(func() string { return "popup-tab" }, nil),
2284 browserWindows: map[string]*browserWindow{first.id: first, second.id: second},
2285 windowForTab: map[entity.TabID]*browserWindow{firstTab.ID: first, secondTab.ID: second},
2286 workspaceViews: make(map[entity.TabID]*component.WorkspaceView),
2287 lastFocusedWindowID: first.id,
2288 mainWindow: first.mainWindow,
2289 }
2290 app.initTabCoordinator(ctx)
2291
2292 err := app.createPopupTab(ctx, contentcoord.InsertPopupInput{
2293 ParentPaneID: secondTab.Workspace.ActivePaneID,
2294 PopupPane: entity.NewPane(entity.PaneID("popup-pane")),
2295 TargetURI: "https://example.com/popup",
2296 })
2297 if err != nil {
2298 t.Fatalf("createPopupTab returned error: %v", err)
2299 }
2300
2301 if first.tabs.Find(entity.TabID("popup-tab")) != nil {
2302 t.Fatalf("popup tab was added to stale focused window")
2303 }
2304 created := second.tabs.Find(entity.TabID("popup-tab"))
2305 if created == nil {
2306 t.Fatalf("popup tab was not added to parent pane owner window")
2307 }
2308 if got := app.windowForTab[created.ID]; got != second {
2309 t.Fatalf("popup tab owner = %p, want %p", got, second)
2310 }
2311}
2312
2313func TestApp_RemoveBrowserWindowClearsLastFocusedFallback(t *testing.T) {
2314 first := &browserWindow{id: "window-1", mainWindow: &window.MainWindow{}}
2315 second := &browserWindow{id: "window-2", mainWindow: &window.MainWindow{}}
2316 app := &App{
2317 mainWindow: second.mainWindow,
2318 browserWindows: map[string]*browserWindow{first.id: first, second.id: second},
2319 lastFocusedWindowID: second.id,
2320 }
2321
2322 app.removeBrowserWindow(second.id)
2323
2324 if app.lastFocusedWindowID != first.id {
2325 t.Fatalf("lastFocusedWindowID = %q, want %q", app.lastFocusedWindowID, first.id)
2326 }
2327}
2328
2329func TestApp_TabCoordinatorCallbacksUseTargetWindowWhenFocusIsStale(t *testing.T) {
2330 ctx := context.Background()
2331 firstTabs := entity.NewTabList()
2332 firstTab := entity.NewTab(entity.TabID("first-tab"), entity.WorkspaceID("ws-first"), entity.NewPane(entity.PaneID("pane-first")))
2333 firstTabs.Add(firstTab)
2334 secondTabs := entity.NewTabList()
2335 secondTab := entity.NewTab(entity.TabID("second-tab"), entity.WorkspaceID("ws-second"), entity.NewPane(entity.PaneID("pane-second")))
2336 secondTabs.Add(secondTab)
2337
2338 first := &browserWindow{id: "window-1", tabs: firstTabs, mainWindow: &window.MainWindow{}}
2339 second := &browserWindow{id: "window-2", tabs: secondTabs, mainWindow: &window.MainWindow{}}
2340 app := &App{
2341 deps: &Dependencies{},
2342 runtimeConfig: runtimeConfigStateForTest(&config.Config{}),
2343 tabs: entity.NewTabList(),
2344 tabsUC: usecase.NewManageTabsUseCase(func() string { return "created-tab" }, nil),
2345 browserWindows: map[string]*browserWindow{first.id: first, second.id: second},
2346 lastFocusedWindowID: first.id,
2347 mainWindow: first.mainWindow,
2348 windowForTab: map[entity.TabID]*browserWindow{firstTab.ID: first, secondTab.ID: second},
2349 workspaceViews: make(map[entity.TabID]*component.WorkspaceView),
2350 }
2351 app.tabs.Add(firstTab)
2352 app.tabs.Add(secondTab)
2353 app.initTabCoordinator(ctx)
2354
2355 created, err := app.tabCoord.Create(ctx, app.tabTargetForBrowserWindow(second), "https://example.com")
2356 if err != nil {
2357 t.Fatalf("Create returned error: %v", err)
2358 }
2359 if got := app.windowForTab[created.ID]; got != second {
2360 t.Fatalf("created tab owner = %p, want second window %p", got, second)
2361 }
2362 if app.mainWindow != second.mainWindow {
2363 t.Fatalf("mainWindow after create switch = %p, want %p", app.mainWindow, second.mainWindow)
2364 }
2365
2366 app.mainWindow = first.mainWindow
2367 app.lastFocusedWindowID = first.id
2368 if err := app.tabCoord.Switch(ctx, app.tabTargetForBrowserWindow(second), secondTab.ID); err != nil {
2369 t.Fatalf("Switch returned error: %v", err)
2370 }
2371 if app.mainWindow != second.mainWindow {
2372 t.Fatalf("mainWindow after target switch = %p, want %p", app.mainWindow, second.mainWindow)
2373 }
2374}
2375
2376func TestApp_ActivateBrowserWindowAddsGlobalSnapshotWithoutChangingWindowTabPosition(t *testing.T) {
2377 firstTab := entity.NewTab(entity.TabID("first-tab"), entity.WorkspaceID("ws-first"), entity.NewPane(entity.PaneID("pane-first")))
2378 secondTab := entity.NewTab(entity.TabID("second-tab"), entity.WorkspaceID("ws-second"), entity.NewPane(entity.PaneID("pane-second")))
2379 firstTabs := entity.NewTabList()
2380 firstTabs.Add(firstTab)
2381 secondTabs := entity.NewTabList()
2382 secondTabs.Add(secondTab)
2383 secondTabs.SetActive(secondTab.ID)
2384 first := &browserWindow{id: "window-1", tabs: firstTabs, mainWindow: &window.MainWindow{}}
2385 second := &browserWindow{id: "window-2", tabs: secondTabs, mainWindow: &window.MainWindow{}}
2386 globalTabs := entity.NewTabList()
2387 globalTabs.Add(firstTab)
2388 app := &App{
2389 tabs: globalTabs,
2390 browserWindows: map[string]*browserWindow{first.id: first, second.id: second},
2391 }
2392
2393 app.activateBrowserWindow(second)
2394
2395 if secondTab.Position != 0 {
2396 t.Fatalf("window tab position = %d, want 0", secondTab.Position)
2397 }
2398 globalSecond := app.tabs.Find(secondTab.ID)
2399 if globalSecond == nil {
2400 t.Fatalf("global tab snapshot was not added")
2401 }
2402 if globalSecond == secondTab {
2403 t.Fatalf("global tab should be a snapshot, not the live per-window tab")
2404 }
2405 if globalSecond.Position != 1 {
2406 t.Fatalf("global tab position = %d, want 1", globalSecond.Position)
2407 }
2408}
2409
2410func TestApp_ActiveWorkspaceRequiresFocusedBrowserWindow(t *testing.T) {
2411 globalTab := entity.NewTab(entity.TabID("global-tab"), entity.WorkspaceID("ws-global"), entity.NewPane(entity.PaneID("pane-global")))
2412 globalTabs := entity.NewTabList()
2413 globalTabs.Add(globalTab)
2414 globalTabs.SetActive(globalTab.ID)
2415 app := &App{
2416 tabs: globalTabs,
2417 browserWindows: map[string]*browserWindow{},
2418 workspaceViews: map[entity.TabID]*component.WorkspaceView{globalTab.ID: {}},
2419 }
2420
2421 if got := app.activeWorkspace(); got != nil {
2422 t.Fatalf("activeWorkspace() = %p, want nil without focused browser window", got)
2423 }
2424 if got := app.activeWorkspaceView(); got != nil {
2425 t.Fatalf("activeWorkspaceView() = %p, want nil without focused browser window", got)
2426 }
2427}
2428
2429func TestApp_ActiveWorkspaceUsesLastFocusedWindowTabsNotGlobalActiveTab(t *testing.T) {
2430 firstTab := entity.NewTab(entity.TabID("first-tab"), entity.WorkspaceID("ws-first"), entity.NewPane(entity.PaneID("pane-first")))
2431 secondTab := entity.NewTab(entity.TabID("second-tab"), entity.WorkspaceID("ws-second"), entity.NewPane(entity.PaneID("pane-second")))
2432 firstTabs := entity.NewTabList()
2433 firstTabs.Add(firstTab)
2434 firstTabs.SetActive(firstTab.ID)
2435 secondTabs := entity.NewTabList()
2436 secondTabs.Add(secondTab)
2437 secondTabs.SetActive(secondTab.ID)
2438 first := &browserWindow{id: "window-1", tabs: firstTabs}
2439 second := &browserWindow{id: "window-2", tabs: secondTabs}
2440 globalTabs := entity.NewTabList()
2441 globalTabs.Add(firstTab)
2442 globalTabs.Add(secondTab)
2443 globalTabs.SetActive(firstTab.ID)
2444 secondView := &component.WorkspaceView{}
2445 app := &App{
2446 tabs: globalTabs,
2447 browserWindows: map[string]*browserWindow{first.id: first, second.id: second},
2448 lastFocusedWindowID: second.id,
2449 workspaceViews: map[entity.TabID]*component.WorkspaceView{secondTab.ID: secondView},
2450 }
2451
2452 if got := app.activeWorkspace(); got != secondTab.Workspace {
2453 t.Fatalf("activeWorkspace() = %p, want second workspace %p", got, secondTab.Workspace)
2454 }
2455 if got := app.activeWorkspaceView(); got != secondView {
2456 t.Fatalf("activeWorkspaceView() = %p, want second view %p", got, secondView)
2457 }
2458}
2459
2460// recordingWebView is a complete port.WebView that records which navigation
2461// methods were called and with what arguments. It does not use reflect or unsafe.
2462type recordingWebView struct {
2463 id port.WebViewID
2464
2465 loadURICalled bool
2466 loadURILastURI string
2467 reloadCalls int
2468 reloadBypassCacheCalls int
2469 stopCalls int
2470 goBackCalls int
2471 goForwardCalls int
2472 loadHTMLCalls int
2473 setZoomLevelCalls int
2474 setZoomLevelLastLevel float64
2475 openDevToolsCalls int
2476 printPageCalls int
2477 destroyCalls int
2478}
2479
2480func (f *recordingWebView) ID() port.WebViewID { return f.id }
2481
2482func (f *recordingWebView) LoadURI(_ context.Context, uri string) error {
2483 f.loadURICalled = true
2484 f.loadURILastURI = uri
2485 return nil
2486}
2487
2488func (f *recordingWebView) LoadHTML(_ context.Context, _, _ string) error {
2489 f.loadHTMLCalls++
2490 return nil
2491}
2492
2493func (f *recordingWebView) Reload(_ context.Context) error {
2494 f.reloadCalls++
2495 return nil
2496}
2497
2498func (f *recordingWebView) ReloadBypassCache(_ context.Context) error {
2499 f.reloadBypassCacheCalls++
2500 return nil
2501}
2502
2503func (f *recordingWebView) Stop(_ context.Context) error {
2504 f.stopCalls++
2505 return nil
2506}
2507
2508func (f *recordingWebView) GoBack(_ context.Context) error {
2509 f.goBackCalls++
2510 return nil
2511}
2512
2513func (f *recordingWebView) GoForward(_ context.Context) error {
2514 f.goForwardCalls++
2515 return nil
2516}
2517
2518func (f *recordingWebView) State() port.WebViewState { return port.WebViewState{} }
2519
2520func (f *recordingWebView) URI() string { return f.loadURILastURI }
2521func (f *recordingWebView) Title() string { return "" }
2522
2523func (f *recordingWebView) IsLoading() bool { return false }
2524func (f *recordingWebView) EstimatedProgress() float64 { return 0 }
2525func (f *recordingWebView) CanGoBack() bool { return false }
2526func (f *recordingWebView) CanGoForward() bool { return false }
2527
2528func (f *recordingWebView) SetZoomLevel(_ context.Context, level float64) error {
2529 f.setZoomLevelCalls++
2530 f.setZoomLevelLastLevel = level
2531 return nil
2532}
2533
2534func (f *recordingWebView) OpenDevTools() { f.openDevToolsCalls++ }
2535func (f *recordingWebView) PrintPage() { f.printPageCalls++ }
2536
2537func (f *recordingWebView) GetZoomLevel() float64 { return 1.0 }
2538func (f *recordingWebView) GetFindController() port.FindController { return nil }
2539func (f *recordingWebView) SetCallbacks(_ *port.WebViewCallbacks) {}
2540func (f *recordingWebView) RunJavaScript(_ context.Context, _ string) {}
2541func (f *recordingWebView) SetBackgroundColor(_, _, _, _ float64) {}
2542func (f *recordingWebView) ResetBackgroundToDefault() {}
2543func (f *recordingWebView) Favicon() port.Texture { return nil }
2544func (f *recordingWebView) Generation() uint64 { return 0 }
2545func (f *recordingWebView) IsFullscreen() bool { return false }
2546func (f *recordingWebView) IsPlayingAudio() bool { return false }
2547func (f *recordingWebView) IsDestroyed() bool { return f.destroyCalls > 0 }
2548func (f *recordingWebView) Destroy() { f.destroyCalls++ }
2549
2550func TestApp_AttachPopupToTabDestroysPopupWhenWorkspaceViewMissing(t *testing.T) {
2551 ctx := context.Background()
2552 popupWV := &recordingWebView{id: 1}
2553 app := &App{workspaceViews: map[entity.TabID]*component.WorkspaceView{}}
2554
2555 app.attachPopupToTab(ctx, entity.TabID("missing-tab"), entity.NewPane(entity.PaneID("popup-pane")), popupWV)
2556
2557 if popupWV.destroyCalls != 1 {
2558 t.Fatalf("popup webview destroy calls = %d, want 1", popupWV.destroyCalls)
2559 }
2560}
2561
2562func TestApp_AttachPopupToTabDestroysPopupWhenPaneNil(t *testing.T) {
2563 ctx := context.Background()
2564 popupWV := &recordingWebView{id: 1}
2565 tabID := entity.TabID("tab-1")
2566 app := &App{workspaceViews: map[entity.TabID]*component.WorkspaceView{tabID: &component.WorkspaceView{}}}
2567
2568 app.attachPopupToTab(ctx, tabID, nil, popupWV)
2569
2570 if popupWV.destroyCalls != 1 {
2571 t.Fatalf("popup webview destroy calls = %d, want 1", popupWV.destroyCalls)
2572 }
2573}
2574
2575func TestApp_AttachPopupToTabSkipsRegistrationWhenPaneViewMissing(t *testing.T) {
2576 ctx := context.Background()
2577 contentCoord := contentcoord.NewCoordinator(ctx, nil, nil, nil, nil, nil, nil, nil)
2578 tabID := entity.TabID("tab-1")
2579 pane := entity.NewPane(entity.PaneID("missing-pane"))
2580 app := &App{
2581 contentCoord: contentCoord,
2582 workspaceViews: map[entity.TabID]*component.WorkspaceView{
2583 tabID: &component.WorkspaceView{},
2584 },
2585 }
2586
2587 popupWV := &recordingWebView{id: 1}
2588 app.attachPopupToTab(ctx, tabID, pane, popupWV)
2589
2590 if got := contentCoord.GetWebView(pane.ID); got != nil {
2591 t.Fatalf("popup webview was registered for missing pane view: %v", got)
2592 }
2593 if popupWV.destroyCalls != 1 {
2594 t.Fatalf("popup webview destroy calls = %d, want 1", popupWV.destroyCalls)
2595 }
2596}
2597
2598func TestApp_AttachPopupToTabReleasesRegistrationWhenWrapFails(t *testing.T) {
2599 ctx := context.Background()
2600 contentCoord := contentcoord.NewCoordinator(ctx, nil, nil, nil, nil, nil, nil, nil)
2601 tabID := entity.TabID("tab-1")
2602 pane := entity.NewPane(entity.PaneID("popup-pane"))
2603
2604 factory := layoutmocks.NewMockWidgetFactory(t)
2605 container := layoutmocks.NewMockBoxWidget(t)
2606 overlay := layoutmocks.NewMockOverlayWidget(t)
2607 factory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(container).Once()
2608 container.EXPECT().SetHexpand(true).Once()
2609 container.EXPECT().SetVexpand(true).Once()
2610 container.EXPECT().SetVisible(true).Once()
2611 factory.EXPECT().NewOverlay().Return(overlay).Once()
2612 overlay.EXPECT().SetHexpand(true).Once()
2613 overlay.EXPECT().SetVexpand(true).Once()
2614 overlay.EXPECT().SetChild(container).Once()
2615 overlay.EXPECT().SetVisible(true).Once()
2616
2617 wsView := component.NewWorkspaceView(ctx, factory)
2618 container.EXPECT().AddCssClass("single-pane").Once()
2619 wsView.RegisterPaneView(pane.ID, &component.PaneView{})
2620
2621 app := &App{
2622 contentCoord: contentCoord,
2623 workspaceViews: map[entity.TabID]*component.WorkspaceView{
2624 tabID: wsView,
2625 },
2626 }
2627 popupWV := &recordingWebView{id: 1}
2628
2629 app.attachPopupToTab(ctx, tabID, pane, popupWV)
2630
2631 if got := contentCoord.GetWebView(pane.ID); got != nil {
2632 t.Fatalf("popup webview registration remained after wrap failure: %v", got)
2633 }
2634 if popupWV.destroyCalls != 1 {
2635 t.Fatalf("popup webview destroy calls = %d, want 1", popupWV.destroyCalls)
2636 }
2637}
2638
2639func TestApp_BrowserWindowWebViewActionsIgnoreStaleFocusedWindow(t *testing.T) {
2640 ctx := context.Background()
2641
2642 // Build two browser windows with independent bw.tabs and active tabs/panes.
2643 tab1 := entity.NewTab(entity.TabID("tab-1"), entity.WorkspaceID("ws-1"), entity.NewPane(entity.PaneID("pane-1")))
2644 tab2 := entity.NewTab(entity.TabID("tab-2"), entity.WorkspaceID("ws-2"), entity.NewPane(entity.PaneID("pane-2")))
2645
2646 firstTabs := entity.NewTabList()
2647 firstTabs.Add(tab1)
2648 firstTabs.SetActive(tab1.ID)
2649 first := &browserWindow{id: "window-1", tabs: firstTabs}
2650
2651 secondTabs := entity.NewTabList()
2652 secondTabs.Add(tab2)
2653 secondTabs.SetActive(tab2.ID)
2654 second := &browserWindow{id: "window-2", tabs: secondTabs}
2655
2656 // Create recording webviews.
2657 recordingWv1 := &recordingWebView{id: 1}
2658 recordingWv2 := &recordingWebView{id: 2}
2659
2660 // Create content coordinator with initialized internal maps to avoid
2661 // nil-map panics in SetNavigationOrigin during NavigateWebView calls.
2662 contentCoord := contentcoord.NewCoordinator(ctx, nil, nil, nil, nil, nil, nil, nil)
2663 contentCoord.RegisterPopupWebView(entity.PaneID("pane-1"), recordingWv1)
2664 contentCoord.RegisterPopupWebView(entity.PaneID("pane-2"), recordingWv2)
2665
2666 // Create NavigationCoordinator (no navigateUC needed for direct webview calls).
2667 navCoord := coordinator.NewNavigationCoordinator(ctx, nil, contentCoord)
2668
2669 app := &App{
2670 tabs: entity.NewTabList(),
2671 browserWindows: map[string]*browserWindow{first.id: first, second.id: second},
2672 lastFocusedWindowID: first.id, // stale! should NOT be used
2673 contentCoord: contentCoord,
2674 navCoord: navCoord,
2675 workspaceViews: map[entity.TabID]*component.WorkspaceView{
2676 tab1.ID: &component.WorkspaceView{},
2677 tab2.ID: &component.WorkspaceView{},
2678 },
2679 }
2680 app.tabs.Add(tab1)
2681 app.tabs.Add(tab2)
2682
2683 // Call window-scoped app helpers for the second window.
2684 if err := app.navigateFromBrowserWindow(ctx, second, "https://google.com"); err != nil {
2685 t.Fatalf("navigateFromBrowserWindow returned error: %v", err)
2686 }
2687 if err := app.reloadBrowserWindow(ctx, second, false); err != nil {
2688 t.Fatalf("reloadBrowserWindow (soft) returned error: %v", err)
2689 }
2690 if err := app.reloadBrowserWindow(ctx, second, true); err != nil {
2691 t.Fatalf("reloadBrowserWindow (hard) returned error: %v", err)
2692 }
2693 if err := app.stopBrowserWindow(ctx, second); err != nil {
2694 t.Fatalf("stopBrowserWindow returned error: %v", err)
2695 }
2696 if err := app.goBackBrowserWindow(ctx, second); err != nil {
2697 t.Fatalf("goBackBrowserWindow returned error: %v", err)
2698 }
2699 if err := app.goForwardBrowserWindow(ctx, second); err != nil {
2700 t.Fatalf("goForwardBrowserWindow returned error: %v", err)
2701 }
2702
2703 assertRecordingWebViewIdle(t, "first", recordingWv1)
2704 assertRecordingWebViewActions(t, "second", recordingWv2, "https://google.com")
2705}
2706
2707func assertRecordingWebViewIdle(t *testing.T, name string, wv *recordingWebView) {
2708 t.Helper()
2709 if wv.loadURICalled || wv.loadURILastURI != "" {
2710 t.Errorf("%s webview LoadURI was called, expected zero calls", name)
2711 }
2712 if wv.reloadCalls > 0 {
2713 t.Errorf("%s webview Reload calls = %d, want 0", name, wv.reloadCalls)
2714 }
2715 if wv.reloadBypassCacheCalls > 0 {
2716 t.Errorf("%s webview ReloadBypassCache calls = %d, want 0", name, wv.reloadBypassCacheCalls)
2717 }
2718 if wv.stopCalls > 0 {
2719 t.Errorf("%s webview Stop calls = %d, want 0", name, wv.stopCalls)
2720 }
2721 if wv.goBackCalls > 0 {
2722 t.Errorf("%s webview GoBack calls = %d, want 0", name, wv.goBackCalls)
2723 }
2724 if wv.goForwardCalls > 0 {
2725 t.Errorf("%s webview GoForward calls = %d, want 0", name, wv.goForwardCalls)
2726 }
2727}
2728
2729func assertRecordingWebViewActions(t *testing.T, name string, wv *recordingWebView, wantURI string) {
2730 t.Helper()
2731 if !wv.loadURICalled {
2732 t.Errorf("%s webview LoadURI was not called", name)
2733 }
2734 if wv.loadURILastURI != wantURI {
2735 t.Errorf("%s webview LoadURI URI = %q, want %q", name, wv.loadURILastURI, wantURI)
2736 }
2737 if wv.reloadCalls != 1 {
2738 t.Errorf("%s webview Reload calls = %d, want 1", name, wv.reloadCalls)
2739 }
2740 if wv.reloadBypassCacheCalls != 1 {
2741 t.Errorf("%s webview ReloadBypassCache calls = %d, want 1", name, wv.reloadBypassCacheCalls)
2742 }
2743 if wv.stopCalls != 1 {
2744 t.Errorf("%s webview Stop calls = %d, want 1", name, wv.stopCalls)
2745 }
2746 if wv.goBackCalls != 1 {
2747 t.Errorf("%s webview GoBack calls = %d, want 1", name, wv.goBackCalls)
2748 }
2749 if wv.goForwardCalls != 1 {
2750 t.Errorf("%s webview GoForward calls = %d, want 1", name, wv.goForwardCalls)
2751 }
2752}
2753
2754func assertRecordingWebViewBrowserActionsIdle(t *testing.T, name string, wv *recordingWebView) {
2755 t.Helper()
2756 if wv.reloadCalls > 0 {
2757 t.Errorf("%s webview Reload calls = %d, want 0", name, wv.reloadCalls)
2758 }
2759 if wv.reloadBypassCacheCalls > 0 {
2760 t.Errorf("%s webview ReloadBypassCache calls = %d, want 0", name, wv.reloadBypassCacheCalls)
2761 }
2762 if wv.stopCalls > 0 {
2763 t.Errorf("%s webview Stop calls = %d, want 0", name, wv.stopCalls)
2764 }
2765 if wv.goBackCalls > 0 {
2766 t.Errorf("%s webview GoBack calls = %d, want 0", name, wv.goBackCalls)
2767 }
2768 if wv.goForwardCalls > 0 {
2769 t.Errorf("%s webview GoForward calls = %d, want 0", name, wv.goForwardCalls)
2770 }
2771 if wv.printPageCalls > 0 {
2772 t.Errorf("%s webview PrintPage calls = %d, want 0", name, wv.printPageCalls)
2773 }
2774 if wv.openDevToolsCalls > 0 {
2775 t.Errorf("%s webview OpenDevTools calls = %d, want 0", name, wv.openDevToolsCalls)
2776 }
2777 if wv.setZoomLevelCalls > 0 {
2778 t.Errorf("%s webview SetZoomLevel calls = %d, want 0", name, wv.setZoomLevelCalls)
2779 }
2780}
2781
2782func assertRecordingWebViewBrowserActionsCalledOnce(t *testing.T, name string, wv *recordingWebView) {
2783 t.Helper()
2784 if wv.reloadCalls != 1 {
2785 t.Errorf("%s webview Reload calls = %d, want 1", name, wv.reloadCalls)
2786 }
2787 if wv.reloadBypassCacheCalls != 1 {
2788 t.Errorf("%s webview ReloadBypassCache calls = %d, want 1", name, wv.reloadBypassCacheCalls)
2789 }
2790 if wv.stopCalls != 1 {
2791 t.Errorf("%s webview Stop calls = %d, want 1", name, wv.stopCalls)
2792 }
2793 if wv.goBackCalls != 1 {
2794 t.Errorf("%s webview GoBack calls = %d, want 1", name, wv.goBackCalls)
2795 }
2796 if wv.goForwardCalls != 1 {
2797 t.Errorf("%s webview GoForward calls = %d, want 1", name, wv.goForwardCalls)
2798 }
2799 if wv.printPageCalls != 1 {
2800 t.Errorf("%s webview PrintPage calls = %d, want 1", name, wv.printPageCalls)
2801 }
2802 if wv.openDevToolsCalls != 1 {
2803 t.Errorf("%s webview OpenDevTools calls = %d, want 1", name, wv.openDevToolsCalls)
2804 }
2805 if wv.setZoomLevelCalls != 1 {
2806 t.Errorf("%s webview SetZoomLevel calls = %d, want 1", name, wv.setZoomLevelCalls)
2807 }
2808}
2809
2810func TestApp_OmniboxNavigateCallbackCapturesBrowserWindow(t *testing.T) {
2811 second := &browserWindow{id: "window-2"}
2812
2813 var capturedBW *browserWindow
2814 var capturedURL string
2815 testNavigate := func(_ context.Context, bw *browserWindow, url string) error {
2816 capturedBW = bw
2817 capturedURL = url
2818 return nil
2819 }
2820
2821 ctx := context.Background()
2822 cb := omniboxNavigateForBrowserWindow(ctx, second, testNavigate)
2823 if err := cb(ctx, "https://google.com"); err != nil {
2824 t.Fatalf("omnibox navigate callback returned error: %v", err)
2825 }
2826
2827 if capturedBW != second {
2828 capturedID := "<nil>"
2829 if capturedBW != nil {
2830 capturedID = capturedBW.id
2831 }
2832 t.Errorf("captured browser window = %p (id=%s), want %p (id=%s)", capturedBW, capturedID, second, second.id)
2833 }
2834 if capturedURL != "https://google.com" {
2835 t.Errorf("captured URL = %q, want %q", capturedURL, "https://google.com")
2836 }
2837}
2838
2839func TestApp_DispatchBrowserWindowActionUsesSourceWindow(t *testing.T) {
2840 ctx := context.Background()
2841
2842 // Build two browser windows with independent bw.tabs and active panes.
2843 tab1 := entity.NewTab(entity.TabID("tab-1"), entity.WorkspaceID("ws-1"), entity.NewPane(entity.PaneID("pane-1")))
2844 tab2 := entity.NewTab(entity.TabID("tab-2"), entity.WorkspaceID("ws-2"), entity.NewPane(entity.PaneID("pane-2")))
2845
2846 firstTabs := entity.NewTabList()
2847 firstTabs.Add(tab1)
2848 firstTabs.SetActive(tab1.ID)
2849 first := &browserWindow{id: "window-1", tabs: firstTabs}
2850
2851 secondTabs := entity.NewTabList()
2852 secondTabs.Add(tab2)
2853 secondTabs.SetActive(tab2.ID)
2854 second := &browserWindow{id: "window-2", tabs: secondTabs}
2855
2856 // Create recording webviews.
2857 recordingWv1 := &recordingWebView{id: 1, loadURILastURI: "https://first.example"}
2858 recordingWv2 := &recordingWebView{id: 2, loadURILastURI: "https://second.example"}
2859
2860 // Register recording webviews in content coordinator.
2861 contentCoord := contentcoord.NewCoordinator(ctx, nil, nil, nil, nil, nil, nil, nil)
2862 contentCoord.RegisterPopupWebView(entity.PaneID("pane-1"), recordingWv1)
2863 contentCoord.RegisterPopupWebView(entity.PaneID("pane-2"), recordingWv2)
2864
2865 // Create navCoord (no navigateUC needed for direct webview calls).
2866 navCoord := coordinator.NewNavigationCoordinator(ctx, nil, contentCoord)
2867
2868 app := &App{
2869 tabs: entity.NewTabList(),
2870 browserWindows: map[string]*browserWindow{first.id: first, second.id: second},
2871 lastFocusedWindowID: first.id, // stale! should NOT be used
2872 contentCoord: contentCoord,
2873 navCoord: navCoord,
2874 deps: &Dependencies{ZoomUC: usecase.NewManageZoomUseCase(mockZoomRepo(t), 1.0, nil)},
2875 workspaceViews: map[entity.TabID]*component.WorkspaceView{
2876 tab1.ID: &component.WorkspaceView{},
2877 tab2.ID: &component.WorkspaceView{},
2878 },
2879 }
2880 app.tabs.Add(tab1)
2881 app.tabs.Add(tab2)
2882
2883 // Dispatch browser window actions targeting the second window.
2884 for _, a := range []input.Action{
2885 input.ActionReload,
2886 input.ActionHardReload,
2887 input.ActionStop,
2888 input.ActionGoBack,
2889 input.ActionGoForward,
2890 input.ActionPrintPage,
2891 input.ActionOpenDevTools,
2892 input.ActionZoomIn,
2893 } {
2894 if err := app.dispatchBrowserWindowAction(ctx, second, a); err != nil {
2895 t.Fatalf("dispatchBrowserWindowAction (%s) returned error: %v", a, err)
2896 }
2897 }
2898
2899 // Assert first recording webview receives zero calls (stale focus is ignored).
2900 assertRecordingWebViewBrowserActionsIdle(t, "first", recordingWv1)
2901
2902 // Assert second recording webview receives exactly one call per action.
2903 assertRecordingWebViewBrowserActionsCalledOnce(t, "second", recordingWv2)
2904}
2905
2906func TestApp_DispatchBrowserWindowActionZoomInSupportsFileURLs(t *testing.T) {
2907 ctx := context.Background()
2908
2909 tab := entity.NewTab(entity.TabID("tab-1"), entity.WorkspaceID("ws-1"), entity.NewPane(entity.PaneID("pane-1")))
2910 tabs := entity.NewTabList()
2911 tabs.Add(tab)
2912 tabs.SetActive(tab.ID)
2913 bw := &browserWindow{id: "window-1", tabs: tabs}
2914
2915 recordingWv := &recordingWebView{id: 1, loadURILastURI: "file:///tmp/demo.html"}
2916 contentCoord := contentcoord.NewCoordinator(ctx, nil, nil, nil, nil, nil, nil, nil)
2917 contentCoord.RegisterPopupWebView(entity.PaneID("pane-1"), recordingWv)
2918
2919 app := &App{
2920 browserWindows: map[string]*browserWindow{bw.id: bw},
2921 tabs: entity.NewTabList(),
2922 windowForTab: map[entity.TabID]*browserWindow{tab.ID: bw},
2923 contentCoord: contentCoord,
2924 deps: &Dependencies{ZoomUC: usecase.NewManageZoomUseCase(mockZoomRepo(t), 1.0, nil)},
2925 workspaceViews: map[entity.TabID]*component.WorkspaceView{
2926 tab.ID: &component.WorkspaceView{},
2927 },
2928 }
2929 app.tabs.Add(tab)
2930 app.tabs.SetActive(tab.ID)
2931
2932 if err := app.dispatchBrowserWindowAction(ctx, bw, input.ActionZoomIn); err != nil {
2933 t.Fatalf("dispatchBrowserWindowAction returned error: %v", err)
2934 }
2935 if recordingWv.setZoomLevelCalls != 1 {
2936 t.Fatalf("webview SetZoomLevel calls = %d, want 1", recordingWv.setZoomLevelCalls)
2937 }
2938 if diff := math.Abs(recordingWv.setZoomLevelLastLevel - 1.1); diff > 0.0001 {
2939 t.Fatalf("webview SetZoomLevel level = %f, want 1.1", recordingWv.setZoomLevelLastLevel)
2940 }
2941}
2942
2943func counterIDGen() func() string {
2944 var counter int
2945 return func() string {
2946 counter++
2947 return strconv.Itoa(counter)
2948 }
2949}
2950
2951func TestApp_DispatchBrowserWindowActionSwitchTabIndexUsesSourceWindow(t *testing.T) {
2952 ctx := context.Background()
2953
2954 firstTab := entity.NewTab(entity.TabID("first-tab"), entity.WorkspaceID("ws-first"), entity.NewPane(entity.PaneID("pane-first")))
2955 firstTabs := entity.NewTabList()
2956 firstTabs.Add(firstTab)
2957 firstTabs.SetActive(firstTab.ID)
2958 first := &browserWindow{id: "window-1", tabs: firstTabs}
2959
2960 secondTab1 := entity.NewTab(entity.TabID("second-tab-1"), entity.WorkspaceID("ws-second-1"), entity.NewPane(entity.PaneID("pane-second-1")))
2961 secondTab2 := entity.NewTab(entity.TabID("second-tab-2"), entity.WorkspaceID("ws-second-2"), entity.NewPane(entity.PaneID("pane-second-2")))
2962 secondTabs := entity.NewTabList()
2963 secondTabs.Add(secondTab1)
2964 secondTabs.Add(secondTab2)
2965 secondTabs.SetActive(secondTab1.ID)
2966 second := &browserWindow{id: "window-2", tabs: secondTabs}
2967
2968 cfg := &config.Config{
2969 Workspace: config.WorkspaceConfig{
2970 NewPaneURL: "about:blank",
2971 },
2972 }
2973 app := &App{
2974 deps: &Dependencies{},
2975 runtimeConfig: runtimeConfigStateForTest(cfg),
2976 tabsUC: usecase.NewManageTabsUseCase(counterIDGen(), nil),
2977 browserWindows: map[string]*browserWindow{first.id: first, second.id: second},
2978 windowForTab: map[entity.TabID]*browserWindow{firstTab.ID: first, secondTab1.ID: second, secondTab2.ID: second},
2979 lastFocusedWindowID: first.id, // stale — should NOT be used
2980 }
2981 app.initTabCoordinator(ctx)
2982
2983 err := app.dispatchBrowserWindowAction(ctx, second, input.ActionSwitchTabIndex2)
2984 if err != nil {
2985 t.Fatalf("dispatchBrowserWindowAction returned error: %v", err)
2986 }
2987
2988 if got := secondTabs.ActiveTabID; got != secondTab2.ID {
2989 t.Fatalf("second active tab = %q, want %q", got, secondTab2.ID)
2990 }
2991 if got := firstTabs.ActiveTabID; got != firstTab.ID {
2992 t.Fatalf("first active tab = %q, want %q", got, firstTab.ID)
2993 }
2994}
2995
2996func TestApp_DispatchBrowserWindowActionSwitchTabIndexCreatesOnlyOneMissingTab(t *testing.T) {
2997 ctx := context.Background()
2998
2999 firstTab := entity.NewTab(entity.TabID("first-tab"), entity.WorkspaceID("ws-first"), entity.NewPane(entity.PaneID("pane-first")))
3000 firstTabs := entity.NewTabList()
3001 firstTabs.Add(firstTab)
3002 firstTabs.SetActive(firstTab.ID)
3003 first := &browserWindow{id: "window-1", tabs: firstTabs}
3004
3005 secondTab := entity.NewTab(entity.TabID("second-tab-1"), entity.WorkspaceID("ws-second-1"), entity.NewPane(entity.PaneID("pane-second-1")))
3006 secondTabs := entity.NewTabList()
3007 secondTabs.Add(secondTab)
3008 secondTabs.SetActive(secondTab.ID)
3009 second := &browserWindow{id: "window-2", tabs: secondTabs}
3010
3011 cfg := &config.Config{
3012 Workspace: config.WorkspaceConfig{
3013 NewPaneURL: "about:blank",
3014 },
3015 }
3016 app := &App{
3017 deps: &Dependencies{},
3018 runtimeConfig: runtimeConfigStateForTest(cfg),
3019 tabsUC: usecase.NewManageTabsUseCase(counterIDGen(), nil),
3020 browserWindows: map[string]*browserWindow{first.id: first, second.id: second},
3021 windowForTab: map[entity.TabID]*browserWindow{firstTab.ID: first, secondTab.ID: second},
3022 lastFocusedWindowID: first.id, // stale — should NOT be used
3023 }
3024 app.initTabCoordinator(ctx)
3025
3026 // Dispatch ActionSwitchTabIndex4 targeting the second window.
3027 err := app.dispatchBrowserWindowAction(ctx, second, input.ActionSwitchTabIndex4)
3028 if err != nil {
3029 t.Fatalf("dispatchBrowserWindowAction returned error: %v", err)
3030 }
3031
3032 // Out-of-range switches should create a single new tab in the source window.
3033 if got := secondTabs.Count(); got != 2 {
3034 t.Fatalf("second target tab count = %d, want 2", got)
3035 }
3036 if got := secondTabs.ActiveTabID; got != secondTabs.Tabs[1].ID {
3037 t.Fatalf("active tab = %q, want %q", got, secondTabs.Tabs[1].ID)
3038 }
3039
3040 if got := firstTabs.Count(); got != 1 {
3041 t.Fatalf("first target tab count = %d, want 1", got)
3042 }
3043 if got := firstTabs.ActiveTabID; got != firstTab.ID {
3044 t.Fatalf("first active tab = %q, want %q", got, firstTab.ID)
3045 }
3046
3047 if got := app.windowForTab[secondTabs.Tabs[1].ID]; got != second {
3048 t.Fatalf("windowForTab[%s] = %p, want second window %p", secondTabs.Tabs[1].ID, got, second)
3049 }
3050}
3051
3052func TestApp_SwitchBrowserWindowTabIndexNegativeIndexIsIgnored(t *testing.T) {
3053 ctx := context.Background()
3054
3055 secondTab := entity.NewTab(entity.TabID("second-tab-1"), entity.WorkspaceID("ws-second-1"), entity.NewPane(entity.PaneID("pane-second-1")))
3056 secondTabs := entity.NewTabList()
3057 secondTabs.Add(secondTab)
3058 secondTabs.SetActive(secondTab.ID)
3059 second := &browserWindow{id: "window-2", tabs: secondTabs}
3060
3061 cfg := &config.Config{Workspace: config.WorkspaceConfig{NewPaneURL: "about:blank"}}
3062 app := &App{
3063 deps: &Dependencies{},
3064 runtimeConfig: runtimeConfigStateForTest(cfg),
3065 tabsUC: usecase.NewManageTabsUseCase(counterIDGen(), nil),
3066 browserWindows: map[string]*browserWindow{second.id: second},
3067 windowForTab: map[entity.TabID]*browserWindow{secondTab.ID: second},
3068 }
3069 app.initTabCoordinator(ctx)
3070
3071 err := app.switchBrowserWindowTabIndex(ctx, second, -1)
3072 if err != nil {
3073 t.Fatalf("switchBrowserWindowTabIndex returned error: %v", err)
3074 }
3075 if got := secondTabs.Count(); got != 1 {
3076 t.Fatalf("second target tab count = %d, want 1", got)
3077 }
3078 if got := secondTabs.ActiveTabID; got != secondTab.ID {
3079 t.Fatalf("active tab = %q, want %q", got, secondTab.ID)
3080 }
3081}
3082
3083func TestApp_DispatchBrowserWindowActionSwitchTabIndexNegativeDoesNotMutateWindowState(t *testing.T) {
3084 ctx := context.Background()
3085
3086 second := &browserWindow{id: "window-2"}
3087
3088 cfg := &config.Config{Workspace: config.WorkspaceConfig{NewPaneURL: "about:blank"}}
3089 app := &App{
3090 deps: &Dependencies{},
3091 runtimeConfig: runtimeConfigStateForTest(cfg),
3092 tabsUC: usecase.NewManageTabsUseCase(counterIDGen(), nil),
3093 browserWindows: map[string]*browserWindow{second.id: second},
3094 lastFocusedWindowID: "window-1",
3095 }
3096 app.initTabCoordinator(ctx)
3097
3098 err := app.switchBrowserWindowTabIndex(ctx, second, -1)
3099 if err != nil {
3100 t.Fatalf("switchBrowserWindowTabIndex returned error: %v", err)
3101 }
3102 if got := app.lastFocusedWindowID; got != "window-1" {
3103 t.Fatalf("lastFocusedWindowID = %q, want %q", got, "window-1")
3104 }
3105 if second.tabs != nil {
3106 t.Fatalf("second tabs should stay nil for invalid index")
3107 }
3108}
3109
3110func TestApp_DispatchBrowserWindowActionSwitchTabIndexMissingURLDoesNotCreateTab(t *testing.T) {
3111 ctx := context.Background()
3112
3113 secondTab := entity.NewTab(entity.TabID("second-tab-1"), entity.WorkspaceID("ws-second-1"), entity.NewPane(entity.PaneID("pane-second-1")))
3114 secondTabs := entity.NewTabList()
3115 secondTabs.Add(secondTab)
3116 secondTabs.SetActive(secondTab.ID)
3117 second := &browserWindow{id: "window-2", tabs: secondTabs}
3118
3119 app := &App{
3120 deps: &Dependencies{},
3121 runtimeConfig: runtimeConfigStateForTest(&config.Config{}),
3122 tabsUC: usecase.NewManageTabsUseCase(counterIDGen(), nil),
3123 browserWindows: map[string]*browserWindow{second.id: second},
3124 windowForTab: map[entity.TabID]*browserWindow{secondTab.ID: second},
3125 }
3126 app.initTabCoordinator(ctx)
3127
3128 err := app.dispatchBrowserWindowAction(ctx, second, input.ActionSwitchTabIndex4)
3129 if err == nil {
3130 t.Fatal("dispatchBrowserWindowAction returned nil error, want config error")
3131 }
3132 if got := secondTabs.Count(); got != 1 {
3133 t.Fatalf("second target tab count = %d, want 1", got)
3134 }
3135 if got := secondTabs.ActiveTabID; got != secondTab.ID {
3136 t.Fatalf("active tab = %q, want %q", got, secondTab.ID)
3137 }
3138}
3139
3140func TestApp_SwitchBrowserWindowTabIndexMissingURLDoesNotMutateWindowState(t *testing.T) {
3141 ctx := context.Background()
3142
3143 second := &browserWindow{id: "window-2"}
3144
3145 app := &App{
3146 deps: &Dependencies{},
3147 runtimeConfig: runtimeConfigStateForTest(&config.Config{}),
3148 tabsUC: usecase.NewManageTabsUseCase(counterIDGen(), nil),
3149 browserWindows: map[string]*browserWindow{second.id: second},
3150 lastFocusedWindowID: "window-1",
3151 }
3152 app.initTabCoordinator(ctx)
3153
3154 err := app.switchBrowserWindowTabIndex(ctx, second, 3)
3155 if err == nil {
3156 t.Fatal("switchBrowserWindowTabIndex returned nil error, want config error")
3157 }
3158 if got := app.lastFocusedWindowID; got != "window-1" {
3159 t.Fatalf("lastFocusedWindowID = %q, want %q", got, "window-1")
3160 }
3161 if second.tabs != nil {
3162 t.Fatalf("second tabs should stay nil when new pane URL is missing")
3163 }
3164}
3165
3166func TestApp_WorkspaceOmniboxNavigateUsesOwnerWindow(t *testing.T) {
3167 gtkApp := requireGTKDisplayApp(t)
3168 defer gtkApp.Unref()
3169
3170 ctx := context.Background()
3171 tab1 := entity.NewTab(entity.TabID("tab-1"), entity.WorkspaceID("ws-1"), entity.NewPane(entity.PaneID("pane-1")))
3172 tab2 := entity.NewTab(entity.TabID("tab-2"), entity.WorkspaceID("ws-2"), entity.NewPane(entity.PaneID("pane-2")))
3173 firstTabs := entity.NewTabList()
3174 firstTabs.Add(tab1)
3175 firstTabs.SetActive(tab1.ID)
3176 secondTabs := entity.NewTabList()
3177 secondTabs.Add(tab2)
3178 secondTabs.SetActive(tab2.ID)
3179 first := &browserWindow{id: "window-1", tabs: firstTabs}
3180 second := &browserWindow{id: "window-2", tabs: secondTabs}
3181
3182 recordingWv1 := &recordingWebView{id: 1}
3183 recordingWv2 := &recordingWebView{id: 2}
3184 contentCoord := contentcoord.NewCoordinator(ctx, nil, nil, nil, nil, nil, nil, nil)
3185 contentCoord.RegisterPopupWebView(entity.PaneID("pane-1"), recordingWv1)
3186 contentCoord.RegisterPopupWebView(entity.PaneID("pane-2"), recordingWv2)
3187
3188 app := &App{
3189 deps: &Dependencies{},
3190 runtimeConfig: runtimeConfigStateForTest(&config.Config{}),
3191 widgetFactory: layout.NewGtkWidgetFactory(),
3192 browserWindows: map[string]*browserWindow{first.id: first, second.id: second},
3193 windowForTab: map[entity.TabID]*browserWindow{tab1.ID: first, tab2.ID: second},
3194 lastFocusedWindowID: first.id,
3195 contentCoord: contentCoord,
3196 navCoord: coordinator.NewNavigationCoordinator(ctx, nil, contentCoord),
3197 workspaceViews: map[entity.TabID]*component.WorkspaceView{},
3198 }
3199
3200 app.createWorkspaceViewWithoutAttach(ctx, tab2)
3201 wsView := app.workspaceViews[tab2.ID]
3202 if wsView == nil {
3203 t.Fatal("workspace view was not created for second tab")
3204 }
3205 wsView.ShowOmnibox(ctx, "")
3206 cb := omniboxNavigateCallbackForTest(t, wsView.GetOmnibox())
3207 if err := cb(ctx, "https://example.com"); err != nil {
3208 t.Fatalf("omnibox navigate callback returned error: %v", err)
3209 }
3210
3211 if recordingWv1.loadURICalled {
3212 t.Errorf("first window webview navigated to %q, want no navigation", recordingWv1.loadURILastURI)
3213 }
3214 if !recordingWv2.loadURICalled || recordingWv2.loadURILastURI != "https://example.com" {
3215 t.Errorf("second window webview navigation = called %v uri %q, want https://example.com", recordingWv2.loadURICalled, recordingWv2.loadURILastURI)
3216 }
3217}
3218
3219func TestApp_FloatingOmniboxNavigateUsesSessionPane(t *testing.T) {
3220 gtkApp := requireGTKDisplayApp(t)
3221 defer gtkApp.Unref()
3222
3223 ctx := context.Background()
3224 factory := layout.NewGtkWidgetFactory()
3225 overlay := factory.NewOverlay()
3226
3227 loadedURL := ""
3228 pane := component.NewFloatingPane(overlay, component.FloatingPaneOptions{
3229 OnNavigate: func(_ context.Context, url string) error {
3230 loadedURL = url
3231 return nil
3232 },
3233 })
3234 session := &floatingWorkspaceSession{
3235 paneID: entity.PaneID("floating-pane"),
3236 pane: pane,
3237 overlay: overlay,
3238 }
3239 app := &App{widgetFactory: factory}
3240
3241 app.showFloatingOmnibox(ctx, session)
3242 cb := omniboxNavigateCallbackForTest(t, session.omnibox)
3243 if err := cb(ctx, "https://example.com"); err != nil {
3244 t.Fatalf("floating omnibox navigate callback returned error: %v", err)
3245 }
3246
3247 if loadedURL != "https://example.com" {
3248 t.Fatalf("floating pane loaded URL = %q, want https://example.com", loadedURL)
3249 }
3250}
3251
3252// TestApp_RestoreSessionDoesNotLeakStaleWindowsIntoTabMerge verifies that existing
3253// browserWindows not participating in the restore do not leak their tabs into the
3254// restored global tab list or UI aggregation. Only runtimeWindows (the windows
3255// actually restored) contribute.
3256func TestApp_RestoreSessionDoesNotLeakStaleWindowsIntoTabMerge(t *testing.T) {
3257 // Two stale browserWindows exist in the map; only one window is restored.
3258 staleBW1 := &browserWindow{id: "stale-w1"}
3259 staleBW2 := &browserWindow{id: "stale-w2"}
3260
3261 staleTab1 := entity.NewTab(entity.TabID("stale-tab"), entity.WorkspaceID("stale-ws"), entity.NewPane(entity.PaneID("stale-pane")))
3262 staleTab1.Name = "StaleTab"
3263 staleBW1.tabs = entity.NewTabList()
3264 staleBW1.tabs.Add(staleTab1)
3265
3266 staleBW2.tabs = entity.NewTabList()
3267
3268 // One restored window with its own tab. Tab names survive the
3269 // snapshot/restore cycle even though IDs are regenerated.
3270 restoredTabs := entity.NewTabList()
3271 restoredTab := entity.NewTab(entity.TabID("restored-tab"), entity.WorkspaceID("restored-ws"), entity.NewPane(entity.PaneID("restored-pane")))
3272 restoredTab.Name = "RestoredTab"
3273 restoredTabs.Add(restoredTab)
3274
3275 sessionID := entity.SessionID("test-session")
3276 state := entity.SnapshotFromWindowTabLists(sessionID, []entity.WindowTabListState{
3277 {WindowID: "saved-w1", Tabs: restoredTabs},
3278 }, 0, time.Unix(123, 0))
3279
3280 mainWindow := &window.MainWindow{}
3281 runtimeBW := &browserWindow{id: "runtime-w1", mainWindow: mainWindow}
3282
3283 app := &App{
3284 deps: &Dependencies{
3285 SessionStateRepo: mockSessionStateRepoWithSnapshot(t, sessionID, state),
3286 },
3287 runtimeConfig: runtimeConfigStateForTest(&config.Config{}),
3288 mainWindow: mainWindow,
3289 browserWindows: map[string]*browserWindow{
3290 staleBW1.id: staleBW1,
3291 staleBW2.id: staleBW2,
3292 runtimeBW.id: runtimeBW,
3293 },
3294 tabs: entity.NewTabList(),
3295 }
3296 // Seed global tabs with a stale tab to verify it is replaced.
3297 app.tabs.Add(staleTab1)
3298
3299 if err := app.restoreSession(context.Background(), sessionID); err != nil {
3300 t.Fatalf("restoreSession returned error: %v", err)
3301 }
3302
3303 // Global tabs must only contain the restored tab, not stale tabs.
3304 if got := app.tabs.Count(); got != 1 {
3305 t.Fatalf("tabs.Count() = %d, want 1 (only restored tab)", got)
3306 }
3307 // Verify by name since IDs are regenerated during restore.
3308 restored := app.tabs.Tabs[0]
3309 if restored == nil || restored.Name != "RestoredTab" {
3310 t.Fatalf("restored tab name = %q, want RestoredTab", firstTabName(app.tabs))
3311 }
3312 if app.tabs.Find(staleTab1.ID) != nil {
3313 t.Fatal("stale tab leaked into global tabs")
3314 }
3315}
3316
3317func firstTabName(tl *entity.TabList) string {
3318 if len(tl.Tabs) == 0 || tl.Tabs[0] == nil {
3319 return ""
3320 }
3321 return tl.Tabs[0].Name
3322}
3323
3324// TestApp_RestoreSessionHonorsActiveWindowIndex verifies that when restoring a
3325// v2 multi-window session, the ActiveWindowIndex from the snapshot is used to
3326// determine the focused window and global active tab.
3327func TestApp_RestoreSessionHonorsActiveWindowIndex(t *testing.T) {
3328 gtkApp := requireGTKDisplayApp(t)
3329 defer gtkApp.Unref()
3330
3331 mainWindow, err := window.New(context.Background(), gtkApp, "top")
3332 if err != nil {
3333 t.Fatalf("window creation failed: %v", err)
3334 }
3335 defer mainWindow.Destroy()
3336
3337 // Window 0 has a tab named "Tab-W0", window 1 has "Tab-W1" (active).
3338 // Tab names survive snapshot/restore; IDs are regenerated.
3339 pane0 := entity.NewPane("pane-w0")
3340 tabW0 := entity.NewTab("tab-w0", "ws-w0", pane0)
3341 tabW0.Name = "Tab-W0"
3342 tabs0 := entity.NewTabList()
3343 tabs0.Add(tabW0)
3344
3345 pane1 := entity.NewPane("pane-w1")
3346 tabW1 := entity.NewTab("tab-w1", "ws-w1", pane1)
3347 tabW1.Name = "Tab-W1"
3348 tabs1 := entity.NewTabList()
3349 tabs1.Add(tabW1)
3350 tabs1.SetActive("tab-w1")
3351
3352 sessionID := entity.SessionID("test-sess")
3353 // ActiveWindowIndex = 1: second window should be focused.
3354 state := entity.SnapshotFromWindowTabLists(sessionID, []entity.WindowTabListState{
3355 {WindowID: "saved-w0", Tabs: tabs0},
3356 {WindowID: "saved-w1", Tabs: tabs1},
3357 }, 1, time.Unix(123, 0))
3358
3359 firstBW := &browserWindow{id: "runtime-w0", mainWindow: mainWindow}
3360 // Factory creates the second runtime window; share the mainWindow so the UI
3361 // restore loop can walk its tab bar without needing a second GTK window.
3362 var runtimeW1 *browserWindow
3363 app := &App{
3364 deps: &Dependencies{
3365 SessionStateRepo: mockSessionStateRepoWithSnapshot(t, sessionID, state),
3366 },
3367 runtimeConfig: runtimeConfigStateForTest(&config.Config{}),
3368 mainWindow: mainWindow,
3369 widgetFactory: layout.NewGtkWidgetFactory(),
3370 browserWindows: map[string]*browserWindow{firstBW.id: firstBW},
3371 tabs: entity.NewTabList(),
3372 windowForTab: map[entity.TabID]*browserWindow{},
3373 workspaceViews: map[entity.TabID]*component.WorkspaceView{},
3374 browserWindowFactory: func(ctx context.Context, url string) (*browserWindow, error) {
3375 runtimeW1 = &browserWindow{id: "runtime-w1", mainWindow: mainWindow}
3376 return runtimeW1, nil
3377 },
3378 }
3379
3380 if err := app.restoreSession(context.Background(), sessionID); err != nil {
3381 t.Fatalf("restoreSession returned error: %v", err)
3382 }
3383
3384 // lastFocusedWindowID must point to the active window (index 1).
3385 if app.lastFocusedWindowID != "runtime-w1" {
3386 t.Errorf("lastFocusedWindowID = %s, want runtime-w1 (active window index 1)", app.lastFocusedWindowID)
3387 }
3388
3389 // Global active tab must come from the active window (Tab-W1), not the first (Tab-W0).
3390 activeTab := app.tabs.ActiveTab()
3391 if activeTab == nil {
3392 t.Fatal("active tab is nil")
3393 }
3394 if activeTab.Name != "Tab-W1" {
3395 t.Errorf("active tab name = %q, want Tab-W1 (from active window)", activeTab.Name)
3396 }
3397}
3398
3399// TestApp_RestoreSessionFailsOnAdditionalWindowCreationError verifies that
3400// restoreSession returns an error (not silent partial restore) when creating
3401// a browser window for an additional restored window fails.
3402func TestApp_RestoreSessionFailsOnAdditionalWindowCreationError(t *testing.T) {
3403 gtkApp := requireGTKDisplayApp(t)
3404 defer gtkApp.Unref()
3405
3406 mainWindow, err := window.New(context.Background(), gtkApp, "top")
3407 if err != nil {
3408 t.Fatalf("window creation failed: %v", err)
3409 }
3410 defer mainWindow.Destroy()
3411
3412 pane0 := entity.NewPane("pane-w0")
3413 tab0 := entity.NewTab("tab-w0", "ws-w0", pane0)
3414 tabs0 := entity.NewTabList()
3415 tabs0.Add(tab0)
3416
3417 pane1 := entity.NewPane("pane-w1")
3418 tab1 := entity.NewTab("tab-w1", "ws-w1", pane1)
3419 tabs1 := entity.NewTabList()
3420 tabs1.Add(tab1)
3421
3422 sessionID := entity.SessionID("test-sess")
3423 state := entity.SnapshotFromWindowTabLists(sessionID, []entity.WindowTabListState{
3424 {WindowID: "saved-w0", Tabs: tabs0},
3425 {WindowID: "saved-w1", Tabs: tabs1},
3426 }, 0, time.Unix(123, 0))
3427
3428 firstBW := &browserWindow{id: "runtime-w0", mainWindow: mainWindow}
3429 factoryErr := errors.New("window creation failed")
3430 app := &App{
3431 deps: &Dependencies{
3432 SessionStateRepo: mockSessionStateRepoWithSnapshot(t, sessionID, state),
3433 },
3434 runtimeConfig: runtimeConfigStateForTest(&config.Config{}),
3435 mainWindow: mainWindow,
3436 browserWindows: map[string]*browserWindow{firstBW.id: firstBW},
3437 tabs: entity.NewTabList(),
3438 windowForTab: map[entity.TabID]*browserWindow{},
3439 browserWindowFactory: func(ctx context.Context, url string) (*browserWindow, error) {
3440 return nil, factoryErr
3441 },
3442 }
3443
3444 gotErr := app.restoreSession(context.Background(), sessionID)
3445 if gotErr == nil {
3446 t.Fatal("restoreSession returned nil error, want failure when additional window creation fails")
3447 }
3448 if !strings.Contains(gotErr.Error(), "create browser window 1 for restore") {
3449 t.Errorf("error message should mention window creation for restore, got: %v", gotErr)
3450 }
3451 if !errors.Is(gotErr, factoryErr) {
3452 t.Errorf("error should wrap factoryErr, got: %v", gotErr)
3453 }
3454}
3455
3456func TestMainWindow_BottomTabBar_ContentAreaHasInset(t *testing.T) {
3457 gtkApp := requireGTKDisplayApp(t)
3458 defer gtkApp.Unref()
3459
3460 mw, err := window.New(context.Background(), gtkApp, "bottom")
3461 if err != nil {
3462 t.Fatalf("window creation failed: %v", err)
3463 }
3464 defer mw.Destroy()
3465
3466 // Bottom window should start WITHOUT the inset
3467 if mw.ContentArea().HasCssClass("content-area-tabbar-inset-bottom") {
3468 t.Fatal("bottom-tab-bar content area: should NOT have inset at window creation")
3469 }
3470
3471 // HasTabBarContentInset must return false initially
3472 if mw.HasTabBarContentInset() {
3473 t.Fatal("HasTabBarContentInset should return false initially")
3474 }
3475
3476 // SetTabBarContentInsetVisible(true) must add the class
3477 mw.SetTabBarContentInsetVisible(true)
3478 if !mw.ContentArea().HasCssClass("content-area-tabbar-inset-bottom") {
3479 t.Fatal("bottom-tab-bar content area: expected content-area-tabbar-inset-bottom CSS class after SetTabBarContentInsetVisible(true)")
3480 }
3481 if !mw.HasTabBarContentInset() {
3482 t.Fatal("HasTabBarContentInset should return true after SetTabBarContentInsetVisible(true)")
3483 }
3484
3485 // SetTabBarContentInsetVisible(false) must remove the class
3486 mw.SetTabBarContentInsetVisible(false)
3487 if mw.ContentArea().HasCssClass("content-area-tabbar-inset-bottom") {
3488 t.Fatal("bottom-tab-bar content area: should NOT have inset after SetTabBarContentInsetVisible(false)")
3489 }
3490 if mw.HasTabBarContentInset() {
3491 t.Fatal("HasTabBarContentInset should return false after SetTabBarContentInsetVisible(false)")
3492 }
3493
3494 // Verify tab bar remains a non-measured overlay regardless of inset state
3495 if mw.ContentOverlay().GetMeasureOverlay(mw.TabBar().Widget()) {
3496 t.Fatal("bottom-tab-bar: expected tab bar to remain a non-measured overlay")
3497 }
3498}
3499
3500func TestMainWindow_TopTabBar_ContentAreaHasTopInset(t *testing.T) {
3501 gtkApp := requireGTKDisplayApp(t)
3502 defer gtkApp.Unref()
3503
3504 mw, err := window.New(context.Background(), gtkApp, "top")
3505 if err != nil {
3506 t.Fatalf("window creation failed: %v", err)
3507 }
3508 defer mw.Destroy()
3509
3510 if mw.ContentArea().HasCssClass("content-area-tabbar-inset-top") {
3511 t.Fatal("top-tab-bar content area: should NOT have inset at window creation")
3512 }
3513 if mw.ContentArea().HasCssClass("content-area-tabbar-inset-bottom") {
3514 t.Fatal("top-tab-bar content area: should NOT have content-area-tabbar-inset-bottom CSS class")
3515 }
3516
3517 mw.SetTabBarContentInsetVisible(true)
3518 if !mw.ContentArea().HasCssClass("content-area-tabbar-inset-top") {
3519 t.Fatal("top-tab-bar content area: expected content-area-tabbar-inset-top CSS class after SetTabBarContentInsetVisible(true)")
3520 }
3521 if mw.ContentArea().HasCssClass("content-area-tabbar-inset-bottom") {
3522 t.Fatal("top-tab-bar content area: should NOT have bottom inset after SetTabBarContentInsetVisible(true)")
3523 }
3524 if !mw.HasTabBarContentInset() {
3525 t.Fatal("HasTabBarContentInset should return true for top inset")
3526 }
3527
3528 mw.SetTabBarContentInsetVisible(false)
3529 if mw.ContentArea().HasCssClass("content-area-tabbar-inset-top") {
3530 t.Fatal("top-tab-bar content area: should NOT have inset after SetTabBarContentInsetVisible(false)")
3531 }
3532 if mw.HasTabBarContentInset() {
3533 t.Fatal("HasTabBarContentInset should return false after removing top inset")
3534 }
3535
3536 if mw.ContentOverlay().GetMeasureOverlay(mw.TabBar().Widget()) {
3537 t.Fatal("top-tab-bar: expected tab bar to remain a non-measured overlay")
3538 }
3539}