From e584744891e0ac16ec0cd71ad38bafdbdbce47b7 Mon Sep 17 00:00:00 2001 From: Christoph Wille Date: Wed, 19 Aug 2026 12:41:41 +0200 Subject: [PATCH] Render a frame while the headless UI tests wait Hit testing for synthesized input is answered from the rendered scene, not from the visual tree, and Dispatcher.UIThread.RunJobs() does not render one. The context-menu gesture helpers pumped dispatcher jobs alone, so after Escape closed a menu the next right-click could still be routed to the light-dismiss overlay of the frame that was on screen: no ContextRequested was raised, no row became the context target, and the assertion two lines later reported an unhighlighted row. The macOS CI runner lost that race roughly once in forty gestures; a probe build repeating the gesture caught a right-click that produced neither a pointer-over nor a menu, and the fix survived 120 gestures on the same runner. Avalonia's own headless input helpers pump the dispatcher and the render timer together for this reason. Assisted-by: Claude:claude-opus-5[1m]:Claude Code --- .../ContextMenus/DecompileInNewViewTests.cs | 22 +++++++++++-------- ILSpy.Tests/Waiters.cs | 19 +++++++++++++++- 2 files changed, 31 insertions(+), 10 deletions(-) diff --git a/ILSpy.Tests/ContextMenus/DecompileInNewViewTests.cs b/ILSpy.Tests/ContextMenus/DecompileInNewViewTests.cs index 0607a7527c..d1ca446d8f 100644 --- a/ILSpy.Tests/ContextMenus/DecompileInNewViewTests.cs +++ b/ILSpy.Tests/ContextMenus/DecompileInNewViewTests.cs @@ -250,6 +250,8 @@ ICSharpCode.ILSpy.Controls.TreeView.SharpTreeViewItem Row(SharpTreeNode node) => .OfType() .First(r => RowNodeEquals(r, node)); + var menu = grid.ContextMenu!; + async Task RightClick(SharpTreeNode node) { var row = Row(node); @@ -257,21 +259,23 @@ async Task RightClick(SharpTreeNode node) var pt = row.TranslatePoint(new Point(clickX, row.Bounds.Height / 2), window); HeadlessWindowExtensions.MouseDown(window, pt!.Value, MouseButton.Right); HeadlessWindowExtensions.MouseUp(window, pt.Value, MouseButton.Right); - for (int i = 0; i < 4; i++) - { - Dispatcher.UIThread.RunJobs(); - await Task.Delay(20); - } + // The highlight is scoped to the popup - set while the menu is being requested, dropped + // again when it closes - so the popup is the point at which the gesture is finished and + // the row's classes are worth reading. + await Waiters.WaitForAsync(() => menu.IsOpen, description: "the right-clicked row's context menu to open"); } async Task Dismiss() { window.KeyPress(Key.Escape, RawInputModifiers.None, PhysicalKey.Escape, keySymbol: null); + await Waiters.WaitForAsync(() => !menu.IsOpen, description: "the context menu to close"); + // IsOpen flips the moment the popup is torn down, but the frame that still shows its + // light-dismiss overlay is what answers hit tests until the scene is rendered again. + // Right-clicking into that frame delivers press and release to the vanishing overlay + // instead of the row: no ContextRequested is raised at all, so nothing becomes the + // context target and the next assertion sees a row with no classes on it. for (int i = 0; i < 4; i++) - { - Dispatcher.UIThread.RunJobs(); - await Task.Delay(20); - } + Waiters.PumpUI(); } await RightClick(nodeB); diff --git a/ILSpy.Tests/Waiters.cs b/ILSpy.Tests/Waiters.cs index aaa63b1b5f..7b4393b000 100644 --- a/ILSpy.Tests/Waiters.cs +++ b/ILSpy.Tests/Waiters.cs @@ -22,6 +22,7 @@ using System.Threading.Tasks; using Avalonia; +using Avalonia.Headless; using Avalonia.Threading; using Avalonia.VisualTree; @@ -51,13 +52,29 @@ public static async Task WaitForAsync( { if (predicate()) return; - Dispatcher.UIThread.RunJobs(); + PumpUI(); await Task.Delay(PollInterval); } throw new TimeoutException( $"Timed out after {(timeout ?? DefaultTimeout).TotalSeconds:0.#}s waiting for: {description}"); } + /// + /// Advances the UI by one step: runs the queued dispatcher jobs and renders a frame. + /// + /// + /// Both halves matter. Hit testing for synthesized input is answered from the rendered scene, + /// not from the visual tree, so a loop that only runs dispatcher jobs leaves input routing a + /// frame behind: a click can still be delivered to a control the last frame shows but the tree + /// no longer has - a closed popup's light-dismiss overlay, for one - and never reach what is + /// underneath. Avalonia's own headless input helpers pump both for exactly this reason. + /// + public static void PumpUI() + { + Dispatcher.UIThread.RunJobs(); + AvaloniaHeadlessPlatform.ForceRenderTimerTick(); + } + public static async Task WaitForAssembliesAsync( this AssemblyTreeModel atm, int minimumCount = 1,