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