test(activation): cover fullscreen exit on activate and sync focus waits
- commit
- 4fa5f7d43b5dc09d6aba34272538055bcde9d305
- author
- brice · · 2026-09-25 19:47 UTC
- parent
- 9e3a591
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
4 files · +50 -25
M internal/adapters/wayland/activation.go +3 -1
| @@ -24,7 +24,9 @@ const tokenLifetime = 10 * time.Second | |||
| 24 | 24 | const maxTokens = 32 | |
| 25 | 25 | ||
| 26 | 26 | // activationToken is an issued valid token. Tokens of a client die when | |
| 27 | - | // the user moves the focus away from it. | |
| 27 | + | // the focus moves away from it, even on an automatic focus change such as | |
| 28 | + | // a new window mapping: a hand-off racing a transient window then fails | |
| 29 | + | // safe, without stealing focus. | |
| 28 | 30 | type activationToken struct { | |
| 29 | 31 | client server.Client | |
| 30 | 32 | at time.Time | |
| @@ -24,7 +24,9 @@ const tokenLifetime = 10 * time.Second | |||||
| 24 | const maxTokens = 32 | 24 | const maxTokens = 32 | ||
| 25 | 25 | ||||
| 26 | // activationToken is an issued valid token. Tokens of a client die when | 26 | // activationToken is an issued valid token. Tokens of a client die when | ||
| 27 | - | // the user moves the focus away from it. | 27 | + | // the focus moves away from it, even on an automatic focus change such as |
| 28 | + | // a new window mapping: a hand-off racing a transient window then fails | |||
| 29 | + | // safe, without stealing focus. | |||
| 28 | type activationToken struct { | 30 | type activationToken struct { | ||
| 29 | client server.Client | 31 | client server.Client | ||
| 30 | at time.Time | 32 | at time.Time | ||
M internal/adapters/wayland/activation_test.go +15 -8
| @@ -133,10 +133,7 @@ func TestActivationAcrossClients(t *testing.T) { | |||
| 133 | 133 | // A token dies when the focus leaves its client. | |
| 134 | 134 | token = newToken(t, a, managerA) | |
| 135 | 135 | commands <- ports.FocusWindow{ID: winB.ID} | |
| 136 | - | if err := b.Roundtrip(); err != nil { | |
| 137 | - | t.Fatal(err) | |
| 138 | - | } | |
| 139 | - | time.Sleep(20 * time.Millisecond) | |
| 136 | + | waitFocus(t, s, winB.ID) | |
| 140 | 137 | requestProtocol(t, b, managerB, xdgactivation.ActivationV1RequestActivate, token, surfB) | |
| 141 | 138 | noActivation(t, b, events) | |
| 142 | 139 | } | |
| @@ -173,10 +170,7 @@ func TestActivationPressSerial(t *testing.T) { | |||
| 173 | 170 | // The focus moved after the press: stale. | |
| 174 | 171 | commands <- ports.FocusWindow{ID: w.ID} | |
| 175 | 172 | commands <- ports.FocusWindow{} | |
| 176 | - | if err := c.Roundtrip(); err != nil { | |
| 177 | - | t.Fatal(err) | |
| 178 | - | } | |
| 179 | - | time.Sleep(20 * time.Millisecond) | |
| 173 | + | waitFocus(t, s, 0) | |
| 180 | 174 | token = newToken(t, c, manager, serial, seat) | |
| 181 | 175 | requestProtocol(t, c, manager, xdgactivation.ActivationV1RequestActivate, token, surf) | |
| 182 | 176 | noActivation(t, c, events) | |
| @@ -193,3 +187,16 @@ func TestActivationTokenCommittedTwice(t *testing.T) { | |||
| 193 | 187 | requestProtocol(t, c, id, xdgactivation.ActivationTokenV1RequestCommit) | |
| 194 | 188 | expectProtocolError(t, c, id, uint32(xdgactivation.ActivationTokenV1ErrorAlreadyUsed)) | |
| 195 | 189 | } | |
| 190 | + | ||
| 191 | + | // waitFocus waits until the display goroutine has applied a focus change. | |
| 192 | + | func waitFocus(t *testing.T, s *Server, id ports.WindowID) { | |
| 193 | + | t.Helper() | |
| 194 | + | for deadline := time.Now().Add(time.Second); time.Now().Before(deadline); time.Sleep(time.Millisecond) { | |
| 195 | + | done := make(chan ports.WindowID, 1) | |
| 196 | + | s.display.Do(func() { done <- s.focused }) | |
| 197 | + | if <-done == id { | |
| 198 | + | return | |
| 199 | + | } | |
| 200 | + | } | |
| 201 | + | t.Fatal("focus not applied", id) | |
| 202 | + | } | |
| @@ -133,10 +133,7 @@ func TestActivationAcrossClients(t *testing.T) { | |||||
| 133 | // A token dies when the focus leaves its client. | 133 | // A token dies when the focus leaves its client. | ||
| 134 | token = newToken(t, a, managerA) | 134 | token = newToken(t, a, managerA) | ||
| 135 | commands <- ports.FocusWindow{ID: winB.ID} | 135 | commands <- ports.FocusWindow{ID: winB.ID} | ||
| 136 | - | if err := b.Roundtrip(); err != nil { | 136 | + | waitFocus(t, s, winB.ID) |
| 137 | - | t.Fatal(err) | |||
| 138 | - | } | |||
| 139 | - | time.Sleep(20 * time.Millisecond) | |||
| 140 | requestProtocol(t, b, managerB, xdgactivation.ActivationV1RequestActivate, token, surfB) | 137 | requestProtocol(t, b, managerB, xdgactivation.ActivationV1RequestActivate, token, surfB) | ||
| 141 | noActivation(t, b, events) | 138 | noActivation(t, b, events) | ||
| 142 | } | 139 | } | ||
| @@ -173,10 +170,7 @@ func TestActivationPressSerial(t *testing.T) { | |||||
| 173 | // The focus moved after the press: stale. | 170 | // The focus moved after the press: stale. | ||
| 174 | commands <- ports.FocusWindow{ID: w.ID} | 171 | commands <- ports.FocusWindow{ID: w.ID} | ||
| 175 | commands <- ports.FocusWindow{} | 172 | commands <- ports.FocusWindow{} | ||
| 176 | - | if err := c.Roundtrip(); err != nil { | 173 | + | waitFocus(t, s, 0) |
| 177 | - | t.Fatal(err) | |||
| 178 | - | } | |||
| 179 | - | time.Sleep(20 * time.Millisecond) | |||
| 180 | token = newToken(t, c, manager, serial, seat) | 174 | token = newToken(t, c, manager, serial, seat) | ||
| 181 | requestProtocol(t, c, manager, xdgactivation.ActivationV1RequestActivate, token, surf) | 175 | requestProtocol(t, c, manager, xdgactivation.ActivationV1RequestActivate, token, surf) | ||
| 182 | noActivation(t, c, events) | 176 | noActivation(t, c, events) | ||
| @@ -193,3 +187,16 @@ func TestActivationTokenCommittedTwice(t *testing.T) { | |||||
| 193 | requestProtocol(t, c, id, xdgactivation.ActivationTokenV1RequestCommit) | 187 | requestProtocol(t, c, id, xdgactivation.ActivationTokenV1RequestCommit) | ||
| 194 | expectProtocolError(t, c, id, uint32(xdgactivation.ActivationTokenV1ErrorAlreadyUsed)) | 188 | expectProtocolError(t, c, id, uint32(xdgactivation.ActivationTokenV1ErrorAlreadyUsed)) | ||
| 195 | } | 189 | } | ||
| 190 | + | ||||
| 191 | + | // waitFocus waits until the display goroutine has applied a focus change. | |||
| 192 | + | func waitFocus(t *testing.T, s *Server, id ports.WindowID) { | |||
| 193 | + | t.Helper() | |||
| 194 | + | for deadline := time.Now().Add(time.Second); time.Now().Before(deadline); time.Sleep(time.Millisecond) { | |||
| 195 | + | done := make(chan ports.WindowID, 1) | |||
| 196 | + | s.display.Do(func() { done <- s.focused }) | |||
| 197 | + | if <-done == id { | |||
| 198 | + | return | |||
| 199 | + | } | |||
| 200 | + | } | |||
| 201 | + | t.Fatal("focus not applied", id) | |||
| 202 | + | } | |||
M internal/core/core_test.go +0 -16
| @@ -6,7 +6,6 @@ import ( | |||
| 6 | 6 | "github.com/bnema/nefertty/internal/adapters/config" | |
| 7 | 7 | "github.com/bnema/nefertty/internal/core" | |
| 8 | 8 | "github.com/bnema/nefertty/internal/ports" | |
| 9 | - | "slices" | |
| 10 | 9 | "testing" | |
| 11 | 10 | "time" | |
| 12 | 11 | ) | |
| @@ -774,19 +773,4 @@ func TestWindowActivateShowsAndFocuses(t *testing.T) { | |||
| 774 | 773 | } | |
| 775 | 774 | } | |
| 776 | 775 | ||
| 777 | - | // An activated window behind a fullscreen one comes out. | |
| 778 | - | client <- ports.WindowFullscreenRequest{ID: 2, Fullscreen: true} | |
| 779 | - | scene(t, scenes) | |
| 780 | - | client <- ports.WindowMapped{ID: 3} | |
| 781 | - | scene(t, scenes) | |
| 782 | - | client <- ports.WindowFullscreenRequest{ID: 2, Fullscreen: true} | |
| 783 | - | scene(t, scenes) | |
| 784 | - | client <- ports.WindowActivate{ID: 3} | |
| 785 | - | for { | |
| 786 | - | sc := scene(t, scenes) | |
| 787 | - | shown := visible(sc) | |
| 788 | - | if len(shown) > 0 && slices.ContainsFunc(shown, func(w ports.SceneWindow) bool { return w.ID == 3 && w.Focused }) { | |
| 789 | - | break | |
| 790 | - | } | |
| 791 | - | } | |
| 792 | 776 | } | |
| @@ -6,7 +6,6 @@ import ( | |||||
| 6 | "github.com/bnema/nefertty/internal/adapters/config" | 6 | "github.com/bnema/nefertty/internal/adapters/config" | ||
| 7 | "github.com/bnema/nefertty/internal/core" | 7 | "github.com/bnema/nefertty/internal/core" | ||
| 8 | "github.com/bnema/nefertty/internal/ports" | 8 | "github.com/bnema/nefertty/internal/ports" | ||
| 9 | - | "slices" | |||
| 10 | "testing" | 9 | "testing" | ||
| 11 | "time" | 10 | "time" | ||
| 12 | ) | 11 | ) | ||
| @@ -774,19 +773,4 @@ func TestWindowActivateShowsAndFocuses(t *testing.T) { | |||||
| 774 | } | 773 | } | ||
| 775 | } | 774 | } | ||
| 776 | 775 | ||||
| 777 | - | // An activated window behind a fullscreen one comes out. | |||
| 778 | - | client <- ports.WindowFullscreenRequest{ID: 2, Fullscreen: true} | |||
| 779 | - | scene(t, scenes) | |||
| 780 | - | client <- ports.WindowMapped{ID: 3} | |||
| 781 | - | scene(t, scenes) | |||
| 782 | - | client <- ports.WindowFullscreenRequest{ID: 2, Fullscreen: true} | |||
| 783 | - | scene(t, scenes) | |||
| 784 | - | client <- ports.WindowActivate{ID: 3} | |||
| 785 | - | for { | |||
| 786 | - | sc := scene(t, scenes) | |||
| 787 | - | shown := visible(sc) | |||
| 788 | - | if len(shown) > 0 && slices.ContainsFunc(shown, func(w ports.SceneWindow) bool { return w.ID == 3 && w.Focused }) { | |||
| 789 | - | break | |||
| 790 | - | } | |||
| 791 | - | } | |||
| 792 | } | 776 | } | ||
M internal/core/layout_test.go +32 -0
| @@ -449,3 +449,35 @@ func TestFixedOverflowEdgeCases(t *testing.T) { | |||
| 449 | 449 | } | |
| 450 | 450 | }) | |
| 451 | 451 | } | |
| 452 | + | ||
| 453 | + | // Activating a window hidden by another's fullscreen shows it; a floating | |
| 454 | + | // target stays above the fullscreen window, which keeps it. | |
| 455 | + | func TestActivateLeavesFullscreen(t *testing.T) { | |
| 456 | + | for _, tc := range []struct { | |
| 457 | + | name string | |
| 458 | + | setup func(w *Workspace) | |
| 459 | + | keep bool | |
| 460 | + | }{ | |
| 461 | + | {"fixed", func(w *Workspace) { w.Overflow = OverflowFixed; w.MaxColumns = 2; w.AddWindow(3) }, false}, | |
| 462 | + | {"same column", func(w *Workspace) { w.Columns[0].Windows = append(w.Columns[0].Windows, 3) }, false}, | |
| 463 | + | {"floating", func(w *Workspace) { w.AddFloating(3, 10, 10) }, true}, | |
| 464 | + | } { | |
| 465 | + | t.Run(tc.name, func(t *testing.T) { | |
| 466 | + | w := &Workspace{} | |
| 467 | + | w.SetOutput(100, 80) | |
| 468 | + | w.AddWindow(2) | |
| 469 | + | tc.setup(w) | |
| 470 | + | w.FocusID(2) | |
| 471 | + | w.SetFullscreen(2, true) | |
| 472 | + | w.Activate(3) | |
| 473 | + | for _, p := range w.Layout() { | |
| 474 | + | if p.ID == 3 && (p.Hidden || !p.Focused) { | |
| 475 | + | t.Fatalf("3 not shown and focused: %+v", p) | |
| 476 | + | } | |
| 477 | + | if p.ID == 2 && p.Fullscreen != tc.keep { | |
| 478 | + | t.Fatalf("fullscreen of 2 = %v", p.Fullscreen) | |
| 479 | + | } | |
| 480 | + | } | |
| 481 | + | }) | |
| 482 | + | } | |
| 483 | + | } | |
| @@ -449,3 +449,35 @@ func TestFixedOverflowEdgeCases(t *testing.T) { | |||||
| 449 | } | 449 | } | ||
| 450 | }) | 450 | }) | ||
| 451 | } | 451 | } | ||
| 452 | + | ||||
| 453 | + | // Activating a window hidden by another's fullscreen shows it; a floating | |||
| 454 | + | // target stays above the fullscreen window, which keeps it. | |||
| 455 | + | func TestActivateLeavesFullscreen(t *testing.T) { | |||
| 456 | + | for _, tc := range []struct { | |||
| 457 | + | name string | |||
| 458 | + | setup func(w *Workspace) | |||
| 459 | + | keep bool | |||
| 460 | + | }{ | |||
| 461 | + | {"fixed", func(w *Workspace) { w.Overflow = OverflowFixed; w.MaxColumns = 2; w.AddWindow(3) }, false}, | |||
| 462 | + | {"same column", func(w *Workspace) { w.Columns[0].Windows = append(w.Columns[0].Windows, 3) }, false}, | |||
| 463 | + | {"floating", func(w *Workspace) { w.AddFloating(3, 10, 10) }, true}, | |||
| 464 | + | } { | |||
| 465 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 466 | + | w := &Workspace{} | |||
| 467 | + | w.SetOutput(100, 80) | |||
| 468 | + | w.AddWindow(2) | |||
| 469 | + | tc.setup(w) | |||
| 470 | + | w.FocusID(2) | |||
| 471 | + | w.SetFullscreen(2, true) | |||
| 472 | + | w.Activate(3) | |||
| 473 | + | for _, p := range w.Layout() { | |||
| 474 | + | if p.ID == 3 && (p.Hidden || !p.Focused) { | |||
| 475 | + | t.Fatalf("3 not shown and focused: %+v", p) | |||
| 476 | + | } | |||
| 477 | + | if p.ID == 2 && p.Fullscreen != tc.keep { | |||
| 478 | + | t.Fatalf("fullscreen of 2 = %v", p.Fullscreen) | |||
| 479 | + | } | |||
| 480 | + | } | |||
| 481 | + | }) | |||
| 482 | + | } | |||
| 483 | + | } | |||