Files
netbird/client/ui/services/windowmanager_test.go
T
Zoltán Papp fced1e068e Merge origin/main into fix/browser-login-popup-show-from-go
main brought #7449, which rewrote WindowManager around withWindow and
closeWindow so no Wails call runs under mu and creation is serialised per
slot. Both sides changed the same functions, so the resolution takes main's
structure and re-expresses this branch on top of it:

- Dialogs are created with armReady in their factories and shown from Go via
  showWhenReady in the withWindow op; the browser opens from an afterShow
  callback once the login popup is visible. windowByName knows every dialog.
- Hidden windows carry the popup that hid them; restoreHiddenWindows takes
  that owner and the restore-generation guard from #7449 is kept per owner.
  restoreAndClose became restoringCloser(owner), used by CloseBrowserLogin,
  CloseRenewFlow and now CloseInstallProgress, whose WindowClosing hook only
  restores on a user close.
- ready is split into painted and mounted; the fallback timer starts at
  creation and is rearmed from the WindowRuntimeReady hook.
- Painted reports carry the generation stamped into the dialog start URL.
  useAutoSizeWindow, which is what the dialogs actually emit from, now echoes
  it; without that every dialog report would have been dropped as stale.

The withWindow tests from main are kept as is, with the CloseRenewFlow test
seeding an owner-tagged entry.
2026-09-15 15:13:59 +02:00

567 lines
17 KiB
Go

//go:build !android && !ios && !freebsd && !js
package services
import (
"fmt"
"sync/atomic"
"testing"
"time"
"github.com/stretchr/testify/require"
"github.com/wailsapp/wails/v3/pkg/application"
)
func newTestWindowManager() *WindowManager {
return &WindowManager{
creating: map[string]bool{},
pendingOps: map[string][]windowOp{},
pendingClose: map[string]windowCloser{},
restoreGen: map[string]uint64{},
generation: map[string]uint64{},
}
}
type fakeWindow struct {
name string
visible bool
shown int
hidden int
}
func newFakeWindow(name string) *fakeWindow {
return &fakeWindow{name: name, visible: true}
}
func (f *fakeWindow) Show() application.Window {
f.visible = true
f.shown++
return nil
}
func (f *fakeWindow) Hide() application.Window {
f.visible = false
f.hidden++
return nil
}
func (f *fakeWindow) IsVisible() bool { return f.visible }
func (f *fakeWindow) Name() string { return f.name }
type fakeDesktop struct {
windows []*fakeWindow
raised int
}
func newFakeDesktop(s *WindowManager, windows ...*fakeWindow) *fakeDesktop {
d := &fakeDesktop{windows: windows}
s.allWindows = func() []hideableWindow {
all := make([]hideableWindow, 0, len(d.windows))
for _, w := range d.windows {
all = append(all, w)
}
return all
}
s.raiseMain = func() { d.raised++ }
return d
}
func ownersOf(hidden []hiddenWindow) []string {
owners := make([]string, 0, len(hidden))
for _, h := range hidden {
owners = append(owners, h.owner)
}
return owners
}
func waitDone(t *testing.T, done <-chan struct{}, msg string) {
t.Helper()
select {
case <-done:
case <-time.After(5 * time.Second):
t.Fatal(msg)
}
}
func TestWithWindowReusesExistingWindow(t *testing.T) {
s := newTestWindowManager()
existing := &application.WebviewWindow{}
slot := existing
factoryCalls := 0
var got *application.WebviewWindow
created := true
s.withWindow(windowMain, &slot, func() *application.WebviewWindow {
factoryCalls++
return &application.WebviewWindow{}
}, func(w *application.WebviewWindow, c bool) {
got, created = w, c
})
require.Equal(t, 0, factoryCalls)
require.Same(t, existing, got)
require.False(t, created)
}
func TestWithWindowNilFactoryWithoutWindowSkipsOp(t *testing.T) {
s := newTestWindowManager()
var slot *application.WebviewWindow
opCalls := 0
s.withWindow(windowMain, &slot, nil, func(*application.WebviewWindow, bool) {
opCalls++
})
require.Equal(t, 0, opCalls)
require.Nil(t, slot)
}
func TestWithWindowReentrantCallDuringCreationIsQueued(t *testing.T) {
s := newTestWindowManager()
var slot *application.WebviewWindow
factoryCalls := 0
var order []string
var factory func() *application.WebviewWindow
factory = func() *application.WebviewWindow {
factoryCalls++
// Simulates the Windows message pump re-entering the tray click handler
// while WebView2 is still initialising the window being created.
s.withWindow(windowMain, &slot, factory, func(_ *application.WebviewWindow, created bool) {
order = append(order, fmt.Sprintf("reentrant:%v", created))
})
return &application.WebviewWindow{}
}
done := make(chan struct{})
go func() {
defer close(done)
s.withWindow(windowMain, &slot, factory, func(_ *application.WebviewWindow, created bool) {
order = append(order, fmt.Sprintf("outer:%v", created))
})
}()
waitDone(t, done, "withWindow deadlocked on a re-entrant call during creation")
require.Equal(t, 1, factoryCalls)
require.Equal(t, []string{"outer:true", "reentrant:false"}, order)
require.NotNil(t, slot)
require.Empty(t, s.creating)
require.Empty(t, s.pendingOps)
}
func TestWithWindowConcurrentCallersShareOneCreation(t *testing.T) {
s := newTestWindowManager()
var slot *application.WebviewWindow
factoryEntered := make(chan struct{})
release := make(chan struct{})
var factoryCalls, opCalls atomic.Int32
factory := func() *application.WebviewWindow {
factoryCalls.Add(1)
close(factoryEntered)
<-release
return &application.WebviewWindow{}
}
op := func(*application.WebviewWindow, bool) { opCalls.Add(1) }
first := make(chan struct{})
go func() {
defer close(first)
s.withWindow(windowSettings, &slot, factory, op)
}()
<-factoryEntered
second := make(chan struct{})
go func() {
defer close(second)
s.withWindow(windowSettings, &slot, factory, op)
}()
waitDone(t, second, "second caller blocked while the window was being created")
require.Equal(t, int32(0), opCalls.Load())
close(release)
waitDone(t, first, "creator did not finish")
require.Equal(t, int32(1), factoryCalls.Load())
require.Equal(t, int32(2), opCalls.Load())
require.NotNil(t, slot)
}
func TestWithWindowOpsQueuedDuringCreationRunInArrivalOrder(t *testing.T) {
s := newTestWindowManager()
var slot *application.WebviewWindow
var order []string
record := func(label string) windowOp {
return func(_ *application.WebviewWindow, created bool) {
order = append(order, fmt.Sprintf("%s:%v", label, created))
}
}
var factory func() *application.WebviewWindow
factory = func() *application.WebviewWindow {
s.withWindow(windowMain, &slot, factory, func(w *application.WebviewWindow, created bool) {
record("a")(w, created)
// Arrives while the creator is still draining the queue: it must not
// jump ahead of "b" through the existing-window fast path.
s.withWindow(windowMain, &slot, factory, record("c"))
})
s.withWindow(windowMain, &slot, factory, record("b"))
return &application.WebviewWindow{}
}
done := make(chan struct{})
go func() {
defer close(done)
s.withWindow(windowMain, &slot, factory, record("outer"))
}()
waitDone(t, done, "withWindow deadlocked while draining queued operations")
require.Equal(t, []string{"outer:true", "a:false", "b:false", "c:false"}, order)
require.Empty(t, s.creating)
require.Empty(t, s.pendingOps)
}
func TestWithWindowFactoryPanicReleasesCreation(t *testing.T) {
s := newTestWindowManager()
var slot *application.WebviewWindow
func() {
defer func() { require.NotNil(t, recover()) }()
s.withWindow(windowMain, &slot, func() *application.WebviewWindow {
panic("factory failed")
}, func(*application.WebviewWindow, bool) {})
}()
require.Empty(t, s.creating)
require.Empty(t, s.pendingOps)
require.Nil(t, slot)
created := false
s.withWindow(windowMain, &slot, func() *application.WebviewWindow {
return &application.WebviewWindow{}
}, func(_ *application.WebviewWindow, c bool) {
created = c
})
require.True(t, created)
require.NotNil(t, slot)
}
func TestWithWindowNilFromFactoryReleasesCreation(t *testing.T) {
s := newTestWindowManager()
var slot *application.WebviewWindow
opCalls := 0
s.withWindow(windowMain, &slot, func() *application.WebviewWindow {
return nil
}, func(*application.WebviewWindow, bool) {
opCalls++
})
require.Equal(t, 0, opCalls)
require.Empty(t, s.creating)
require.Nil(t, slot)
}
func TestCloseWindowDuringCreationDefersCloseAndSkipsOps(t *testing.T) {
s := newTestWindowManager()
var slot *application.WebviewWindow
created := &application.WebviewWindow{}
opCalls, closeCalls := 0, 0
var closed *application.WebviewWindow
s.withWindow(windowError, &slot, func() *application.WebviewWindow {
s.closeWindow(windowError, &slot, func(w *application.WebviewWindow) {
closeCalls++
closed = w
})
require.Equal(t, 0, closeCalls)
return created
}, func(*application.WebviewWindow, bool) {
opCalls++
})
require.Equal(t, 0, opCalls)
require.Equal(t, 1, closeCalls)
require.Same(t, created, closed)
require.Nil(t, slot)
require.Empty(t, s.creating)
require.Empty(t, s.pendingOps)
require.Empty(t, s.pendingClose)
factoryCalls := 0
reopened := false
s.withWindow(windowError, &slot, func() *application.WebviewWindow {
factoryCalls++
return &application.WebviewWindow{}
}, func(_ *application.WebviewWindow, c bool) {
reopened = c
})
require.Equal(t, 1, factoryCalls)
require.True(t, reopened)
require.NotNil(t, slot)
}
func TestCloseWindowDuringDrainStopsRemainingOps(t *testing.T) {
s := newTestWindowManager()
var slot *application.WebviewWindow
var order []string
closeCalls := 0
var factory func() *application.WebviewWindow
factory = func() *application.WebviewWindow {
s.withWindow(windowWelcome, &slot, factory, func(*application.WebviewWindow, bool) {
order = append(order, "a")
s.closeWindow(windowWelcome, &slot, func(*application.WebviewWindow) { closeCalls++ })
s.withWindow(windowWelcome, &slot, factory, func(*application.WebviewWindow, bool) {
order = append(order, "c")
})
})
s.withWindow(windowWelcome, &slot, factory, func(*application.WebviewWindow, bool) {
order = append(order, "b")
})
return &application.WebviewWindow{}
}
s.withWindow(windowWelcome, &slot, factory, func(*application.WebviewWindow, bool) {
order = append(order, "outer")
})
// "b" was queued before the close and "c" after it; a close supersedes both
// rather than showing a window that is about to be destroyed.
require.Equal(t, []string{"outer", "a"}, order)
require.Equal(t, 1, closeCalls)
require.Nil(t, slot)
require.Empty(t, s.creating)
require.Empty(t, s.pendingOps)
require.Empty(t, s.pendingClose)
}
func TestCloseWindowWithoutWindowSkipsCloser(t *testing.T) {
s := newTestWindowManager()
var slot *application.WebviewWindow
calls := 0
s.closeWindow(windowBrowserLogin, &slot, func(*application.WebviewWindow) { calls++ })
require.Equal(t, 0, calls)
require.Nil(t, slot)
require.Empty(t, s.pendingClose)
}
func TestCloseWindowWithExistingWindowRunsCloser(t *testing.T) {
s := newTestWindowManager()
existing := &application.WebviewWindow{}
slot := existing
var got *application.WebviewWindow
s.closeWindow(windowError, &slot, func(w *application.WebviewWindow) { got = w })
require.Same(t, existing, got)
require.Nil(t, slot)
require.Empty(t, s.pendingClose)
}
func TestWithWindowNilFromFactoryDropsPendingClose(t *testing.T) {
s := newTestWindowManager()
var slot *application.WebviewWindow
closeCalls := 0
s.withWindow(windowError, &slot, func() *application.WebviewWindow {
s.closeWindow(windowError, &slot, func(*application.WebviewWindow) { closeCalls++ })
return nil
}, func(*application.WebviewWindow, bool) {})
require.Equal(t, 0, closeCalls)
require.Nil(t, slot)
require.Empty(t, s.creating)
require.Empty(t, s.pendingClose)
}
func TestWithWindowFactoryPanicDropsPendingClose(t *testing.T) {
s := newTestWindowManager()
var slot *application.WebviewWindow
closeCalls := 0
func() {
defer func() { require.NotNil(t, recover()) }()
s.withWindow(windowError, &slot, func() *application.WebviewWindow {
s.closeWindow(windowError, &slot, func(*application.WebviewWindow) { closeCalls++ })
panic("factory failed")
}, func(*application.WebviewWindow, bool) {})
}()
require.Equal(t, 0, closeCalls)
require.Empty(t, s.creating)
require.Empty(t, s.pendingClose)
}
func TestCloseWindowKeepsFirstDeferredCloser(t *testing.T) {
s := newTestWindowManager()
var slot *application.WebviewWindow
var ran []string
s.withWindow(windowError, &slot, func() *application.WebviewWindow {
s.closeWindow(windowError, &slot, func(*application.WebviewWindow) { ran = append(ran, "first") })
s.closeWindow(windowError, &slot, func(*application.WebviewWindow) { ran = append(ran, "second") })
return &application.WebviewWindow{}
}, func(*application.WebviewWindow, bool) {})
require.Equal(t, []string{"first"}, ran)
require.Nil(t, slot)
require.Empty(t, s.pendingClose)
}
func TestCloseRenewFlowDuringBrowserLoginCreationRestoresHiddenWindows(t *testing.T) {
s := newTestWindowManager()
s.withWindow(windowBrowserLogin, &s.browserLogin, func() *application.WebviewWindow {
s.CloseRenewFlow()
// Seeded after the call so the deferred closer, not CloseRenewFlow's own
// immediate restore, is what has to drain it. A nil entry is skipped by
// restoreHiddenWindows, so no Wails window is needed.
s.hiddenWindows = []hiddenWindow{{owner: windowBrowserLogin}}
return &application.WebviewWindow{}
}, func(*application.WebviewWindow, bool) {})
require.Nil(t, s.browserLogin)
require.Empty(t, s.hiddenWindows)
require.Empty(t, s.creating)
require.Empty(t, s.pendingClose)
}
func TestHideOtherWindowsSkipsKeepNameAndInvisible(t *testing.T) {
main := newFakeWindow(windowMain)
settings := newFakeWindow(windowSettings)
settings.visible = false
popup := newFakeWindow(windowBrowserLogin)
s := newTestWindowManager()
newFakeDesktop(s, main, settings, popup)
s.hideOtherWindows(windowBrowserLogin)
require.False(t, main.visible)
require.Equal(t, 1, main.hidden)
require.Equal(t, 0, settings.hidden, "an already hidden window must not be recorded")
require.Equal(t, 0, popup.hidden, "the popup itself must stay visible")
require.Equal(t, []string{windowBrowserLogin}, ownersOf(s.hiddenWindows))
}
func TestInstallDuringLoginKeepsMainHiddenUntilLoginCloses(t *testing.T) {
main := newFakeWindow(windowMain)
login := newFakeWindow(windowBrowserLogin)
install := newFakeWindow(windowInstallProgress)
install.visible = false
s := newTestWindowManager()
d := newFakeDesktop(s, main, login, install)
s.hideOtherWindows(windowBrowserLogin)
require.False(t, main.visible)
install.visible = true
s.hideOtherWindows(windowInstallProgress)
require.False(t, login.visible, "the install popup hides the login popup")
s.restoreHiddenWindows(windowInstallProgress)
require.True(t, login.visible, "the install popup restores the login popup it hid")
require.False(t, main.visible, "the main window stays hidden for the login popup")
require.Equal(t, 0, d.raised)
require.Equal(t, []string{windowBrowserLogin}, ownersOf(s.hiddenWindows))
s.restoreHiddenWindows(windowBrowserLogin)
require.True(t, main.visible)
require.Equal(t, 1, d.raised, "restoring the main window raises it above the SSO browser")
require.Empty(t, s.hiddenWindows)
}
func TestRestoreHiddenWindowsUnknownOwnerKeepsEverything(t *testing.T) {
main := newFakeWindow(windowMain)
s := newTestWindowManager()
d := newFakeDesktop(s, main)
s.hideOtherWindows(windowBrowserLogin)
s.restoreHiddenWindows(windowWelcome)
require.False(t, main.visible)
require.Equal(t, []string{windowBrowserLogin}, ownersOf(s.hiddenWindows))
require.Equal(t, 0, d.raised)
}
func TestRestoreHiddenWindowsWithoutMainDoesNotRaise(t *testing.T) {
settings := newFakeWindow(windowSettings)
s := newTestWindowManager()
d := newFakeDesktop(s, settings)
s.hideOtherWindows(windowBrowserLogin)
s.restoreHiddenWindows(windowBrowserLogin)
require.True(t, settings.visible)
require.Equal(t, 0, d.raised)
}
func TestRestoreHiddenWindowsEmptyIsNoop(t *testing.T) {
s := newTestWindowManager()
require.NotPanics(t, func() { s.restoreHiddenWindows(windowBrowserLogin) })
require.Empty(t, s.hiddenWindows)
}
func TestHideOtherWindowsRacingOwnRestoreReshowsWhatItHid(t *testing.T) {
main := newFakeWindow(windowMain)
s := newTestWindowManager()
d := newFakeDesktop(s, main)
enumerate := s.allWindows
// A restore for the same owner lands between the generation snapshot and the record.
s.allWindows = func() []hideableWindow {
s.restoreHiddenWindows(windowBrowserLogin)
return enumerate()
}
s.hideOtherWindows(windowBrowserLogin)
require.True(t, main.visible)
require.Equal(t, 1, main.hidden)
require.Empty(t, s.hiddenWindows)
require.Equal(t, 0, d.raised)
}
func TestHideOtherWindowsIgnoresRestoreOfAnotherOwner(t *testing.T) {
main := newFakeWindow(windowMain)
s := newTestWindowManager()
newFakeDesktop(s, main)
enumerate := s.allWindows
s.allWindows = func() []hideableWindow {
s.restoreHiddenWindows(windowInstallProgress)
return enumerate()
}
s.hideOtherWindows(windowBrowserLogin)
require.False(t, main.visible)
require.Equal(t, []string{windowBrowserLogin}, ownersOf(s.hiddenWindows))
}
func TestRestoringCloserRestoresOnlyItsOwner(t *testing.T) {
main := newFakeWindow(windowMain)
s := newTestWindowManager()
newFakeDesktop(s, main)
s.hideOtherWindows(windowBrowserLogin)
s.hiddenWindows = append(s.hiddenWindows, hiddenWindow{owner: windowInstallProgress})
s.restoringCloser(windowBrowserLogin)(&application.WebviewWindow{})
require.True(t, main.visible)
require.Equal(t, []string{windowInstallProgress}, ownersOf(s.hiddenWindows))
}
func TestStampGenerationTracksLatestPerWindow(t *testing.T) {
s := newTestWindowManager()
first := s.stampGeneration(windowBrowserLogin, "/#/dialog/browser-login")
require.Equal(t, "/#/dialog/browser-login?gen=1", first)
require.True(t, s.matchesGeneration(windowBrowserLogin, 1))
second := s.stampGeneration(windowBrowserLogin, "/#/dialog/browser-login?uri=x")
require.Equal(t, "/#/dialog/browser-login?uri=x&gen=2", second)
require.False(t, s.matchesGeneration(windowBrowserLogin, 1))
require.True(t, s.matchesGeneration(windowBrowserLogin, 2))
}
func TestMatchesGenerationUntrackedWindowAccepts(t *testing.T) {
s := newTestWindowManager()
require.True(t, s.matchesGeneration(windowMain, 0))
}
func TestPaintedGeneration(t *testing.T) {
tests := []struct {
name string
data any
want uint64
}{
{"string", "7", 7},
{"float", float64(7), 7},
{"slice", []any{"7"}, 7},
{"empty slice", []any{}, 0},
{"unparsable", "abc", 0},
{"nil", nil, 0},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
require.Equal(t, tc.want, paintedGeneration(tc.data))
})
}
}