diff --git a/ILSpy.Tests/MainWindow/MainMenuTests.cs b/ILSpy.Tests/MainWindow/MainMenuTests.cs index 3933814145..b9e9d84d42 100644 --- a/ILSpy.Tests/MainWindow/MainMenuTests.cs +++ b/ILSpy.Tests/MainWindow/MainMenuTests.cs @@ -17,6 +17,7 @@ // DEALINGS IN THE SOFTWARE. using System; +using System.Collections.Generic; using System.Linq; using Avalonia; @@ -95,6 +96,83 @@ public void File_Open_Carries_The_Ctrl_O_Gesture() openItem.Gesture!.Should().Be(expected); } + // The app-level NativeMenu (App.axaml) is process-wide, while every MainWindow builds + // its own Help items over its own command instances. On macOS each new window promotes + // them into that app menu; the ones an earlier window promoted must be replaced, not + // kept - otherwise the app menu pins every earlier window's command graph (and, in the + // headless suite, every test's app graph) for the life of the process. + [AvaloniaTest] + public void Promoting_Help_Again_Replaces_The_Items_An_Earlier_Window_Promoted() + { + var appMenu = NativeMenu.GetMenu(Application.Current!); + appMenu.Should().NotBeNull("App.axaml declares the NativeMenu the Help items move into"); + + MainMenu.PromoteHelpToMacAppMenu( + WindowMenuWithHelpItems("About (first window)", out var firstByTag), firstByTag); + var afterFirst = appMenu!.Items.Count; + var promoted = MainMenu.PromoteHelpToMacAppMenu( + WindowMenuWithHelpItems("About (second window)", out var secondByTag), secondByTag); + try + { + appMenu.Items.Count.Should().Be(afterFirst, "the second window's Help items replace the first window's"); + appMenu.Items.OfType().Select(i => i.Header) + .Should().Contain("About (second window)") + .And.NotContain("About (first window)"); + } + finally + { + RestoreAppMenu(appMenu, promoted); + } + } + + // The Help items a window promotes are withdrawn when it closes, but only that window's own: + // a window closing after a second one has promoted its items must leave those in the app menu, + // or macOS shows an app menu with no About / Check for Updates while the second window is still + // on screen and nothing ever puts them back. + [AvaloniaTest] + public void Closing_An_Earlier_Window_Leaves_A_Later_Window_Help_Items_In_Place() + { + var appMenu = NativeMenu.GetMenu(Application.Current!); + appMenu.Should().NotBeNull("App.axaml declares the NativeMenu the Help items move into"); + + var first = MainMenu.PromoteHelpToMacAppMenu( + WindowMenuWithHelpItems("About (first window)", out var firstByTag), firstByTag); + var second = MainMenu.PromoteHelpToMacAppMenu( + WindowMenuWithHelpItems("About (second window)", out var secondByTag), secondByTag); + try + { + // What the first window's Closed handler does, now that the second window has promoted. + MainMenu.WithdrawHelpItems(first); + + appMenu!.Items.OfType().Select(i => i.Header) + .Should().Contain("About (second window)", + "the still-open window's Help items must survive an earlier window closing"); + } + finally + { + RestoreAppMenu(appMenu!, second); + } + } + + // The app menu is declared on Application and outlives every test, so a test that promotes + // placeholder items into it has to take them back out; otherwise a later test reading it + // (see MainMenu_top_level_items_are_File_View_Window_in_order) sees this test's leftovers. + static void RestoreAppMenu(NativeMenu appMenu, List promoted) + { + foreach (var item in promoted) + appMenu.Items.Remove(item); + } + + static NativeMenu WindowMenuWithHelpItems(string header, out Dictionary byTag) + { + var help = new NativeMenuItem { Header = "_Help", Menu = new NativeMenu() }; + help.Menu.Items.Add(new NativeMenuItem { Header = header }); + var root = new NativeMenu(); + root.Items.Add(help); + byTag = new Dictionary(StringComparer.Ordinal) { ["_Help"] = help }; + return root; + } + // Avalonia's macOS NativeMenu bridge maps NativeMenuItem to NSMenuItem and sets // NSMenuItem.action ONLY when Command != null. Without it, NSMenuValidation marks // the item disabled (greyed out) and no click ever reaches managed code - which diff --git a/ILSpy.Tests/ResetAppStateAttribute.cs b/ILSpy.Tests/ResetAppStateAttribute.cs index 8aea605b91..de957195ae 100644 --- a/ILSpy.Tests/ResetAppStateAttribute.cs +++ b/ILSpy.Tests/ResetAppStateAttribute.cs @@ -17,14 +17,16 @@ // DEALINGS IN THE SOFTWARE. using System; +using System.Collections.Generic; using System.IO; using System.Linq; using System.Threading; using System.Threading.Tasks; using Avalonia; -using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.Controls; using Avalonia.Threading; +using Avalonia.VisualTree; using ICSharpCode.ILSpyX.Settings; @@ -92,6 +94,13 @@ public void AfterTest(ITest test) if (Application.Current == null || !Dispatcher.UIThread.CheckAccess()) return; + TearDownTestState(); + } + + // Everything the per-test teardown does on the dispatcher thread; exposed so a test can + // perform the teardown itself and check what it leaves behind (see TeardownRetentionTests). + internal static void TearDownTestState() + { // Drive background work to quiescence BEFORE the next test rebuilds the composition. A test // that triggers a decompile spawns a Task.Run plus dispatcher continuations and rarely awaits // them to completion; left running, that continuation lands during the next test and reads @@ -100,15 +109,50 @@ public void AfterTest(ITest test) DrainPendingWork(); // Close any windows the test showed so their view-models (alive and weakly subscribed to - // MessageBus) can't react to events raised by later tests, then drain once more. - if (Application.Current.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) + // MessageBus) can't react to events raised by later tests, then drain once more. A window + // left open also outlives its container: the compositor keeps every open top level + // reachable, and with it the view-models, the assembly tree and the loaded assemblies - + // about 13 MB per test, which over the suite is what pushed the CI runner into paging. + foreach (var window in openWindows.ToArray()) { - foreach (var window in desktop.Windows.ToArray()) - window.Close(); + DetachFlyouts(window); + window.Close(); } Dispatcher.UIThread.RunJobs(); } + // Avalonia's Button subscribes to its flyout's Opened/Closed events when its template is + // applied and unsubscribes only when the Flyout property changes, not when the button leaves + // the tree. Dock's ToolChromeControl theme gives every tool pane's chrome button the same + // MenuFlyout resource, so that one shared flyout would keep the visual tree of every window + // this suite ever showed alive. Clearing the property before the window closes is what + // makes the button let go. + static void DetachFlyouts(Window window) + { + foreach (var button in window.GetVisualDescendants().OfType