From f466b2399b2f0a17442158c1509c8aa093564e55 Mon Sep 17 00:00:00 2001 From: Siegfried Pammer Date: Tue, 2 Jun 2026 06:48:09 +0200 Subject: [PATCH] Add per-step visual breakpoints to the headless UI tests To audit what each UI test actually exercises, every step now snapshots the live window to /__.png: a booted frame (emitted automatically by TestHarness.BootAsync), one after each state-changing action, and one before each assertion. Flip ILSPY_TESTS_VISIBLE=1 to render the filmstrip; it lands under %TEMP%/ilspy-test-captures (overridable via ILSPY_TEST_CAPTURES). The step number and fixture/test name are derived automatically so inserting a breakpoint never renumbers the rest. The identity is recorded up front from the real ITest in an ITestAction hook rather than read live: NUnit's TestContext.CurrentContext does not flow onto async continuations, so a capture after an await would otherwise collide under the ad-hoc context. And when rendering is off the whole call is a true no-op -- not even a dispatcher pump -- so instrumenting a test can never perturb the navigation/tab timing it asserts on. Full headless suite stays green. --- .../ILSpy.Tests.Windows.csproj | 11 +- .../ImageListResourceEntryNodeTests.cs | 2 +- ILSpy.Tests.Windows/TestAppConfiguration.cs | 31 +++++ .../Analyzers/AnalyzeContextMenuTests.cs | 5 + .../Analyzers/AnalyzedMethodTreeNodeTests.cs | 2 + .../Analyzers/AnalyzedTypeTreeNodeTests.cs | 2 + .../Analyzers/AnalyzerConstructorUsesTests.cs | 3 + .../Analyzers/AnalyzerNavigationTests.cs | 2 + .../Analyzers/AnalyzerPaneCopyResultsTests.cs | 1 + .../Analyzers/AnalyzerPaneRemoveTests.cs | 2 + .../Analyzers/AnalyzerResultHighlightTests.cs | 4 + .../Analyzers/AnalyzerSearchTreeNodeTests.cs | 2 + .../Analyzers/AnalyzerTreeNodeTests.cs | 1 + .../AssemblyTreeContextMenuTests.cs | 3 + .../AssemblyTreeDragReorderTests.cs | 3 + .../AssemblyTreeExpanderHitboxTests.cs | 2 + .../AssemblyList/AssemblyTreeFileDropTests.cs | 5 + ILSpy.Tests/AssemblyList/AssemblyTreeTests.cs | 16 +++ .../ReloadAssemblyContextMenuTests.cs | 7 +- .../RemoveAssemblyContextMenuTests.cs | 9 +- ...UseNestedNamespaceNodesGridVerification.cs | 2 + ILSpy.Tests/CaptureContextAttribute.cs | 53 ++++++++ .../CopyFullyQualifiedNameTests.cs | 2 + .../CreateDiagramContextMenuTests.cs | 3 + .../ContextMenus/DecompileInNewViewTests.cs | 12 +- .../ContextMenus/OpenContainingFolderTests.cs | 6 +- ILSpy.Tests/ContextMenus/SearchMsdnTests.cs | 6 +- ILSpy.Tests/Docking/LayoutPersistenceTests.cs | 1 + .../Docking/PreviewTabPromotionTests.cs | 21 ++++ .../Docking/SingletonDocumentTabTests.cs | 8 ++ .../MainWindow/MainToolBarLayoutTests.cs | 3 + ILSpy.Tests/MainWindow/MainWindowTests.cs | 5 + .../WindowMenuOpenDocumentsTests.cs | 2 + .../Metadata/DebugDirectoryChildNodesTests.cs | 2 + ILSpy.Tests/Metadata/EmbeddedPdbTreeTests.cs | 4 + ILSpy.Tests/Metadata/HeapTreeTests.cs | 4 + .../Metadata/HideEmptyMetadataTablesTests.cs | 1 + .../HideEmptyMetadataTablesUiBindingTests.cs | 2 + .../MetadataDisablesLanguageSwitchingTests.cs | 2 + ILSpy.Tests/Metadata/MetadataFilterTests.cs | 3 + .../Metadata/MetadataNodeIconParityTests.cs | 1 + .../MetadataProtocolHandlerDrillDownTests.cs | 1 + .../Metadata/MetadataRowActivationTests.cs | 4 + .../MetadataTabSessionRestoreTests.cs | 3 + .../Metadata/MetadataTablesTreeTests.cs | 3 + .../Metadata/MetadataTokenNavigationTests.cs | 4 + .../Metadata/MetadataTreeShapeTests.cs | 2 + ILSpy.Tests/Metadata/PEHeaderTreeTests.cs | 14 +++ .../ShowInMetadataContextMenuTests.cs | 1 + .../Metadata/TypedMetadataTableTreeTests.cs | 5 + .../BrowseBackForwardCommandTests.cs | 3 + ILSpy.Tests/Navigation/NavigationTests.cs | 8 ++ .../Navigation/ViewStateRoundTripTests.cs | 9 ++ .../Options/ApiVisibilityFilterTests.cs | 9 ++ .../Options/OptionsPageScrollReachTests.cs | 4 + ILSpy.Tests/Options/OptionsTabTests.cs | 14 +++ ILSpy.Tests/Search/ScopeSearchToTests.cs | 2 + ILSpy.Tests/Search/SearchInputFocusTests.cs | 2 + .../SearchPaneAssemblyListChangedTests.cs | 2 + .../Search/SearchPaneStreamingTests.cs | 6 + ILSpy.Tests/Search/SearchPaneViewTests.cs | 5 + ILSpy.Tests/Search/SearchProgressTests.cs | 3 + .../Search/SearchResultNavigationTests.cs | 2 + .../Search/SearchResultSortOrderTests.cs | 1 + ILSpy.Tests/Search/ShowSearchCommandTests.cs | 1 + ILSpy.Tests/TestAppBuilder.cs | 1 + ILSpy.Tests/TestCaptureExtensions.cs | 117 +++++++++++++++--- ILSpy.Tests/TestHarness.cs | 2 + 68 files changed, 437 insertions(+), 47 deletions(-) create mode 100644 ILSpy.Tests.Windows/TestAppConfiguration.cs create mode 100644 ILSpy.Tests/CaptureContextAttribute.cs diff --git a/ILSpy.Tests.Windows/ILSpy.Tests.Windows.csproj b/ILSpy.Tests.Windows/ILSpy.Tests.Windows.csproj index 35a3d7360..30e3a3b54 100644 --- a/ILSpy.Tests.Windows/ILSpy.Tests.Windows.csproj +++ b/ILSpy.Tests.Windows/ILSpy.Tests.Windows.csproj @@ -59,17 +59,8 @@ + - - - - - - - - diff --git a/ILSpy.Tests.Windows/ImageList/ImageListResourceEntryNodeTests.cs b/ILSpy.Tests.Windows/ImageList/ImageListResourceEntryNodeTests.cs index 0ab48ad34..5e91aa958 100644 --- a/ILSpy.Tests.Windows/ImageList/ImageListResourceEntryNodeTests.cs +++ b/ILSpy.Tests.Windows/ImageList/ImageListResourceEntryNodeTests.cs @@ -70,7 +70,7 @@ public class ImageListResourceEntryNodeTests node.EnsureLazyChildren(); node.Children.Should().HaveCount(1, "the .resources file held exactly one entry"); - node.Children[0].Should().BeOfType( + ((object?)node.Children[0]).Should().BeOfType( "an ImageListStreamer typed entry should be claimed by ImageListResourceNodeFactory"); } diff --git a/ILSpy.Tests.Windows/TestAppConfiguration.cs b/ILSpy.Tests.Windows/TestAppConfiguration.cs new file mode 100644 index 000000000..a099e0b64 --- /dev/null +++ b/ILSpy.Tests.Windows/TestAppConfiguration.cs @@ -0,0 +1,31 @@ +// Copyright (c) 2026 AlphaSierraPapa for the SharpDevelop Team +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of this +// software and associated documentation files (the "Software"), to deal in the Software +// without restriction, including without limitation the rights to use, copy, modify, merge, +// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons +// to whom the Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all copies or +// substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, +// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR +// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE +// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +using Avalonia.Headless; + +using ICSharpCode.ILSpy.Tests; + +// ILSpy.Tests.Windows reuses the headless app + builder from ILSpy.Tests so its [AvaloniaTest] +// methods boot the same MEF-composed app (which runs AppComposition.Initialize). These attributes +// are assembly-scoped and do NOT carry across the ILSpy.Tests project reference, so they must be +// declared here too -- without them [AvaloniaTest] runs without an app and AppComposition.Current +// throws "Composition host is not yet initialized." +[assembly: AvaloniaTestApplication(typeof(TestAppBuilder))] +[assembly: AvaloniaTestIsolation(AvaloniaTestIsolationLevel.PerAssembly)] +[assembly: ResetAppState] +[assembly: CaptureContext] diff --git a/ILSpy.Tests/Analyzers/AnalyzeContextMenuTests.cs b/ILSpy.Tests/Analyzers/AnalyzeContextMenuTests.cs index bcd2468ff..6644423f7 100644 --- a/ILSpy.Tests/Analyzers/AnalyzeContextMenuTests.cs +++ b/ILSpy.Tests/Analyzers/AnalyzeContextMenuTests.cs @@ -95,6 +95,7 @@ public class AnalyzeContextMenuTests var beforeCount = analyzerVm.Root.Children.Count; entry.Execute(new TextViewContext { SelectedTreeNodes = new[] { (SharpTreeNode)typeNode } }); + TestCapture.Step("analyze-executed"); analyzerVm.Root.Children.Count.Should().Be(beforeCount + 1, "Execute on the analyze entry must add exactly one new row to the pane root"); @@ -114,6 +115,7 @@ public class AnalyzeContextMenuTests var typeNode = vm.AssemblyTreeModel.FindNode( "System.Linq", "System.Linq", "System.Linq.Enumerable"); vm.AssemblyTreeModel.SelectNode(typeNode); + TestCapture.Step("type-selected"); var analyzerVm = AppComposition.Current.GetExport(); var beforeCount = analyzerVm.Root.Children.Count; @@ -125,6 +127,7 @@ public class AnalyzeContextMenuTests RoutedEvent = global::Avalonia.Input.InputElement.KeyDownEvent, Source = grid, }); + TestCapture.Step("ctrl-r-pressed"); analyzerVm.Root.Children.Count.Should().Be(beforeCount + 1, "Ctrl+R with a type selected must analyse it"); @@ -171,6 +174,7 @@ public class AnalyzeContextMenuTests entry.Execute(new TextViewContext { SelectedTreeNodes = new ICSharpCode.ILSpyX.TreeView.SharpTreeNode[] { methodNode } }); + TestCapture.Step("analyzer-pane-surfaced"); // After Execute, the active dockable in the analyzer pane's owning dock should be // the analyzer pane itself — that's what ShowToolPane does. @@ -204,6 +208,7 @@ public class AnalyzeContextMenuTests entry.Execute(new TextViewContext { SelectedTreeNodes = new ICSharpCode.ILSpyX.TreeView.SharpTreeNode[] { methodNode } }); + TestCapture.Step("analyzer-results-populated"); // Walk every materialised node under the analyzer root and assert each has Icon. var entityRow = analyzerVm.Root.Children.OfType().Last(); diff --git a/ILSpy.Tests/Analyzers/AnalyzedMethodTreeNodeTests.cs b/ILSpy.Tests/Analyzers/AnalyzedMethodTreeNodeTests.cs index 365c193fe..ca5856a40 100644 --- a/ILSpy.Tests/Analyzers/AnalyzedMethodTreeNodeTests.cs +++ b/ILSpy.Tests/Analyzers/AnalyzedMethodTreeNodeTests.cs @@ -56,6 +56,7 @@ public class AnalyzedMethodTreeNodeTests .First(m => m.MethodDefinition.Name == "Empty").Member!; var node = new AnalyzedMethodTreeNode(emptyMethod, source: null); + TestCapture.Step("before-method-text"); node.Member.Should().BeSameAs(emptyMethod); node.Text.ToString().Should().Contain("Empty"); node.Text.ToString().Should().Contain("Enumerable", @@ -86,6 +87,7 @@ public class AnalyzedMethodTreeNodeTests .Select(c => c.AnalyzerHeader) .ToArray(); + TestCapture.Step("before-uses-and-used-by-headers"); headers.Should().Contain("Used By", "MethodUsedByAnalyzer is the headline reverse-lookup for any concrete method"); headers.Should().Contain("Uses", diff --git a/ILSpy.Tests/Analyzers/AnalyzedTypeTreeNodeTests.cs b/ILSpy.Tests/Analyzers/AnalyzedTypeTreeNodeTests.cs index 4fc7a9c70..f9eb0a634 100644 --- a/ILSpy.Tests/Analyzers/AnalyzedTypeTreeNodeTests.cs +++ b/ILSpy.Tests/Analyzers/AnalyzedTypeTreeNodeTests.cs @@ -59,6 +59,7 @@ public class AnalyzedTypeTreeNodeTests .Single(t => t.FullName == "System.Object"); var node = new AnalyzedTypeTreeNode(objectType, source: null); + TestCapture.Step("before-type-text"); node.Text.ToString().Should().Contain("Object"); node.Member.Should().BeSameAs(objectType); node.Icon.Should().NotBeNull("every entity node must surface a non-null icon"); @@ -90,6 +91,7 @@ public class AnalyzedTypeTreeNodeTests .Select(c => c.AnalyzerHeader) .ToArray(); + TestCapture.Step("before-type-analyzer-headers"); headers.Should().Contain("Used By", "TypeUsedByAnalyzer applies to every TypeDefinition"); headers.Should().Contain("Exposed By", "TypeExposedByAnalyzer applies to public types"); headers.Should().Contain("Extension Methods", diff --git a/ILSpy.Tests/Analyzers/AnalyzerConstructorUsesTests.cs b/ILSpy.Tests/Analyzers/AnalyzerConstructorUsesTests.cs index 1989c68e2..9755dbc62 100644 --- a/ILSpy.Tests/Analyzers/AnalyzerConstructorUsesTests.cs +++ b/ILSpy.Tests/Analyzers/AnalyzerConstructorUsesTests.cs @@ -80,6 +80,7 @@ public class AnalyzerConstructorUsesTests AssemblyList = assemblyList, }; var results = analyzer.Analyze(ctor, context).Take(20).ToList(); + TestCapture.Step("before-used-by-results"); results.Should().NotBeEmpty("constructors of public LINQ types must have at least one in-tree caller"); } @@ -110,6 +111,7 @@ public class AnalyzerConstructorUsesTests AssemblyList = assemblyList, }; var results = analyzer.Analyze(ctor, context).Take(20).ToList(); + TestCapture.Step("before-uses-results"); results.Should().NotBeEmpty(); } @@ -129,6 +131,7 @@ public class AnalyzerConstructorUsesTests // This was the user-reported throw point. analyzerVm.Analyze(ctor); + TestCapture.Step("constructor-pushed-to-pane"); analyzerVm.Root.Children.Count.Should().Be(beforeCount + 1); } diff --git a/ILSpy.Tests/Analyzers/AnalyzerNavigationTests.cs b/ILSpy.Tests/Analyzers/AnalyzerNavigationTests.cs index 7368568ad..97fdbacc9 100644 --- a/ILSpy.Tests/Analyzers/AnalyzerNavigationTests.cs +++ b/ILSpy.Tests/Analyzers/AnalyzerNavigationTests.cs @@ -58,6 +58,7 @@ public class AnalyzerNavigationTests var analyzed = new AnalyzedTypeTreeNode(entity, source: null); analyzed.ActivateItem(new StubRoutedEventArgs()); + TestCapture.Step("navigated-to-type-node"); ((object?)vm.AssemblyTreeModel.SelectedItem).Should().BeSameAs(typeNode, "ActivateItem must move the assembly-tree selection to the entity's tree node"); @@ -77,6 +78,7 @@ public class AnalyzerNavigationTests var analyzed = new AnalyzedMethodTreeNode(method, source: null); analyzed.ActivateItem(new StubRoutedEventArgs()); + TestCapture.Step("navigated-to-method-node"); ((object?)vm.AssemblyTreeModel.SelectedItem).Should().BeSameAs(methodTreeNode, "ActivateItem must walk down to the right method tree-node under its declaring type"); diff --git a/ILSpy.Tests/Analyzers/AnalyzerPaneCopyResultsTests.cs b/ILSpy.Tests/Analyzers/AnalyzerPaneCopyResultsTests.cs index 68e80ba38..b422f2f5d 100644 --- a/ILSpy.Tests/Analyzers/AnalyzerPaneCopyResultsTests.cs +++ b/ILSpy.Tests/Analyzers/AnalyzerPaneCopyResultsTests.cs @@ -85,6 +85,7 @@ public class AnalyzerPaneCopyResultsTests search.Children.Add(new StubResultNode("beta")); entry.Execute(new TextViewContext { SelectedTreeNodes = new SharpTreeNode[] { search } }); + TestCapture.Step("copy-results-executed"); // We can't inspect the clipboard contract in a headless test without a window with // a real lifetime — the test asserts the entry handled execution without throwing diff --git a/ILSpy.Tests/Analyzers/AnalyzerPaneRemoveTests.cs b/ILSpy.Tests/Analyzers/AnalyzerPaneRemoveTests.cs index 9a40eebbf..db53e1bac 100644 --- a/ILSpy.Tests/Analyzers/AnalyzerPaneRemoveTests.cs +++ b/ILSpy.Tests/Analyzers/AnalyzerPaneRemoveTests.cs @@ -54,6 +54,7 @@ public class AnalyzerPaneRemoveTests "System.Linq", "System.Linq", "System.Linq.Enumerable"); var analyzerVm = AppComposition.Current.GetExport(); var added = analyzerVm.Analyze((ICSharpCode.Decompiler.TypeSystem.IEntity)typeNode.Member!); + TestCapture.Step("entity-analysed"); entry.IsVisible(new TextViewContext { SelectedTreeNodes = new SharpTreeNode[] { added } }) .Should().BeTrue("Remove applies to top-level analysed entities"); @@ -80,6 +81,7 @@ public class AnalyzerPaneRemoveTests var beforeCount = analyzerVm.Root.Children.Count; entry.Execute(new TextViewContext { SelectedTreeNodes = new SharpTreeNode[] { added } }); + TestCapture.Step("entity-removed-from-pane"); analyzerVm.Root.Children.Should().NotContain(added, "Execute on Remove must drop the row from the pane root"); diff --git a/ILSpy.Tests/Analyzers/AnalyzerResultHighlightTests.cs b/ILSpy.Tests/Analyzers/AnalyzerResultHighlightTests.cs index 47439f2b6..06826921f 100644 --- a/ILSpy.Tests/Analyzers/AnalyzerResultHighlightTests.cs +++ b/ILSpy.Tests/Analyzers/AnalyzerResultHighlightTests.cs @@ -72,12 +72,14 @@ public class AnalyzerResultHighlightTests // DockWorkspace.ActiveDecompilerTab is null and the highlight path has no sink. vm.AssemblyTreeModel.SelectNode(stringNode); await vm.DockWorkspace.WaitForDecompiledTextAsync(); + TestCapture.Step("string-decompiled"); var decompTab = vm.DockWorkspace.ActiveDecompilerTab; Assert.That(decompTab, Is.Not.Null, "selecting a type must materialise the decompiler tab"); decompTab!.HighlightedReference = null; var resultNode = new AnalyzedMethodTreeNode(toStringNode.MethodDefinition, source: concatNode.MethodDefinition); resultNode.ActivateItem(new StubArgs()); + TestCapture.Step("highlighted-source-member"); ((object?)decompTab.HighlightedReference).Should().BeSameAs(concatNode.MethodDefinition, "NavigateToReferenceEventArgs.Source (= SourceMember) must land in HighlightedReference so the editor view can paint local-reference marks"); @@ -103,6 +105,7 @@ public class AnalyzerResultHighlightTests // Same materialisation as the first test. vm.AssemblyTreeModel.SelectNode(stringNode); await vm.DockWorkspace.WaitForDecompiledTextAsync(); + TestCapture.Step("string-decompiled"); var decompTab = vm.DockWorkspace.ActiveDecompilerTab!; decompTab.HighlightedReference = lengthProp.PropertyDefinition; @@ -110,6 +113,7 @@ public class AnalyzerResultHighlightTests stringNode.Children.OfType().First().MethodDefinition, source: null); topLevel.ActivateItem(new StubArgs()); + TestCapture.Step("activated-top-level-entity"); ((object?)decompTab.HighlightedReference).Should().BeSameAs(lengthProp.PropertyDefinition, "top-level analysed-entity rows have no SourceMember and must not overwrite the active highlight"); diff --git a/ILSpy.Tests/Analyzers/AnalyzerSearchTreeNodeTests.cs b/ILSpy.Tests/Analyzers/AnalyzerSearchTreeNodeTests.cs index 0b315296d..6cabadf1b 100644 --- a/ILSpy.Tests/Analyzers/AnalyzerSearchTreeNodeTests.cs +++ b/ILSpy.Tests/Analyzers/AnalyzerSearchTreeNodeTests.cs @@ -74,6 +74,7 @@ public class AnalyzerSearchTreeNodeTests () => search.Children.Count > 0 && !search.IsLoading, timeout: TimeSpan.FromSeconds(30)); + TestCapture.Step("before-search-results"); search.Children.Should().NotBeEmpty( "the Used By analyzer must surface at least one entity that references Enumerable"); search.Children.Should().AllBeAssignableTo( @@ -115,6 +116,7 @@ public class AnalyzerSearchTreeNodeTests // Collapse before completion to exercise the cancellation path. search.IsExpanded = false; + TestCapture.Step("before-collapse-cancelled"); search.IsLoading.Should().BeFalse( "cancellation must stop the background task synchronously from the collapse handler"); search.Children.Should().BeEmpty( diff --git a/ILSpy.Tests/Analyzers/AnalyzerTreeNodeTests.cs b/ILSpy.Tests/Analyzers/AnalyzerTreeNodeTests.cs index 06cb413f3..0a9255caf 100644 --- a/ILSpy.Tests/Analyzers/AnalyzerTreeNodeTests.cs +++ b/ILSpy.Tests/Analyzers/AnalyzerTreeNodeTests.cs @@ -94,6 +94,7 @@ public class AnalyzerTreeNodeTests var snapshot = assemblyList.GetAssemblies(); // Clearing then re-adding the assemblies emits a Reset on the underlying ObservableCollection. assemblyList.Clear(); + TestCapture.Step("assembly-list-cleared"); root.Children.Should().BeEmpty( "AnalyzerRootNode.CurrentAssemblyList_Changed must wipe children on Reset"); diff --git a/ILSpy.Tests/AssemblyList/AssemblyTreeContextMenuTests.cs b/ILSpy.Tests/AssemblyList/AssemblyTreeContextMenuTests.cs index 3ea4f296d..eec34a957 100644 --- a/ILSpy.Tests/AssemblyList/AssemblyTreeContextMenuTests.cs +++ b/ILSpy.Tests/AssemblyList/AssemblyTreeContextMenuTests.cs @@ -69,6 +69,7 @@ public class AssemblyTreeContextMenuTests var pane = await window.WaitForComponent(); var assemblyNode = vm.AssemblyTreeModel.FindNode("System.Linq"); vm.AssemblyTreeModel.SelectNode(assemblyNode); + TestCapture.Step("system-linq-selected"); TextViewContext? executionContext = null; var entry = new RecordingEntry(c => executionContext = c); @@ -81,6 +82,7 @@ public class AssemblyTreeContextMenuTests // Trigger the build path the live Opening event would take. var built = pane.BuildContextMenuForCurrentState(new IContextMenuEntryExport[] { export }); + TestCapture.Step("context-menu-built"); // Assert 1 — menu carries our entry. built.Should().NotBeNull(); @@ -89,6 +91,7 @@ public class AssemblyTreeContextMenuTests // Act 2 — invoke the click handler that the Build attached. item.RaiseEvent(new global::Avalonia.Interactivity.RoutedEventArgs(MenuItem.ClickEvent)); + TestCapture.Step("probe-entry-clicked"); // Assert 2 — entry's Execute saw the right context: the tree grid + the selection. executionContext.Should().NotBeNull(); diff --git a/ILSpy.Tests/AssemblyList/AssemblyTreeDragReorderTests.cs b/ILSpy.Tests/AssemblyList/AssemblyTreeDragReorderTests.cs index af900c5d9..bf596357c 100644 --- a/ILSpy.Tests/AssemblyList/AssemblyTreeDragReorderTests.cs +++ b/ILSpy.Tests/AssemblyList/AssemblyTreeDragReorderTests.cs @@ -96,6 +96,7 @@ public class AssemblyTreeDragReorderTests position: DataGridRowDropPosition.After); handler.Validate(args).Should().BeTrue(); handler.Execute(args).Should().BeTrue(); + TestCapture.Step("after-reorder-first-after-second"); var after = list.GetAssemblies(); after[0].Should().BeSameAs(before[1]); @@ -134,6 +135,7 @@ public class AssemblyTreeDragReorderTests var topLevel = vm.AssemblyTreeModel.Root!.Children.OfType().First(); topLevel.IsExpanded = true; + TestCapture.Step("top-level-expanded"); var childNode = topLevel.Children.First(); var handler = (AssemblyRowDropHandler)grid.RowDropHandler; @@ -153,6 +155,7 @@ public class AssemblyTreeDragReorderTests var topLevel = vm.AssemblyTreeModel.Root!.Children.OfType().ToArray(); topLevel[0].IsExpanded = true; + TestCapture.Step("first-assembly-expanded"); var childOfFirst = topLevel[0].Children.First(); var handler = (AssemblyRowDropHandler)grid.RowDropHandler; diff --git a/ILSpy.Tests/AssemblyList/AssemblyTreeExpanderHitboxTests.cs b/ILSpy.Tests/AssemblyList/AssemblyTreeExpanderHitboxTests.cs index 82b5d7828..082d6a1dc 100644 --- a/ILSpy.Tests/AssemblyList/AssemblyTreeExpanderHitboxTests.cs +++ b/ILSpy.Tests/AssemblyList/AssemblyTreeExpanderHitboxTests.cs @@ -58,6 +58,7 @@ public class AssemblyTreeExpanderHitboxTests var assemblyNode = vm.AssemblyTreeModel.FindNode("System.Linq"); assemblyNode.Expand(); vm.AssemblyTreeModel.SelectNode(assemblyNode); + TestCapture.Step("system-linq-expanded-and-selected"); var pane = await window.WaitForComponent(); var grid = await pane.WaitForComponent(); @@ -103,6 +104,7 @@ public class AssemblyTreeExpanderHitboxTests hitPoint.Should().NotBeNull(); HeadlessWindowExtensions.MouseDown(window, hitPoint!.Value, MouseButton.Left); HeadlessWindowExtensions.MouseUp(window, hitPoint.Value, MouseButton.Left); + TestCapture.Step("clicked-enlarged-expander-area"); await Waiters.WaitForAsync(() => !assemblyNode.IsExpanded, description: "clicking the enlarged expander area (below the glyph) must toggle the node"); diff --git a/ILSpy.Tests/AssemblyList/AssemblyTreeFileDropTests.cs b/ILSpy.Tests/AssemblyList/AssemblyTreeFileDropTests.cs index 4b0543c95..d19c1d920 100644 --- a/ILSpy.Tests/AssemblyList/AssemblyTreeFileDropTests.cs +++ b/ILSpy.Tests/AssemblyList/AssemblyTreeFileDropTests.cs @@ -66,6 +66,7 @@ public class AssemblyTreeFileDropTests try { pane.HandleFileDrop(new[] { tempPath }, target: null, DataGridRowDropPosition.After); + TestCapture.Step("file-dropped-no-target"); var after = list.GetAssemblies(); after.Should().HaveCount(beforeCount + 1); @@ -97,6 +98,7 @@ public class AssemblyTreeFileDropTests { pane.HandleFileDrop(new[] { tempPath }, target: firstNode, DataGridRowDropPosition.Before); + TestCapture.Step("file-dropped-before-first-row"); var after = list.GetAssemblies(); after.Should().HaveCount(beforeCount + 1); @@ -125,6 +127,7 @@ public class AssemblyTreeFileDropTests { pane.HandleFileDrop(new[] { tempPath }, target: firstNode, DataGridRowDropPosition.After); + TestCapture.Step("file-dropped-after-first-row"); var after = list.GetAssemblies(); after[1].FileName.Should().Be(tempPath); @@ -152,6 +155,7 @@ public class AssemblyTreeFileDropTests { pane.HandleFileDrop(new[] { tempPath }, target: null, DataGridRowDropPosition.After); + TestCapture.Step("file-dropped-new-node-selected"); var newAsm = list.GetAssemblies() .First(a => string.Equals(a.FileName, tempPath, StringComparison.OrdinalIgnoreCase)); @@ -187,6 +191,7 @@ public class AssemblyTreeFileDropTests pane.HandleFileDrop(new[] { bogusPath }, target: null, DataGridRowDropPosition.After); + TestCapture.Step("bogus-path-dropped"); // OpenAssembly does add the entry (it lazy-loads), so the count may grow. The contract // we exercise here is "the call returns without throwing" — verified by reaching this diff --git a/ILSpy.Tests/AssemblyList/AssemblyTreeTests.cs b/ILSpy.Tests/AssemblyList/AssemblyTreeTests.cs index 3a027735c..85ddb085c 100644 --- a/ILSpy.Tests/AssemblyList/AssemblyTreeTests.cs +++ b/ILSpy.Tests/AssemblyList/AssemblyTreeTests.cs @@ -514,6 +514,7 @@ public class AssemblyTreeTests vm.AssemblyTreeModel.SelectNode(methodNode); await vm.DockWorkspace.WaitForDecompiledTextAsync(); + TestCapture.Step("method-decompiled"); await Waiters.WaitForAsync(() => vm.AssemblyTreeModel.AssemblyList!.GetAssemblies().Any(a => !initialFiles.Contains(a.FileName))); @@ -649,6 +650,7 @@ public class AssemblyTreeTests Dispatcher.UIThread.RunJobs(); scrollViewer.Offset.Y.Should().BeGreaterThan(5, "the test scenario requires the viewport be parked mid-list"); + TestCapture.Step("viewport-parked-mid-list"); // Pick the bottom-most visible non-selected row — that's the strictest probe. If the // bug regressed, CenterRowInView would scroll it up to the middle and offset would @@ -670,6 +672,7 @@ public class AssemblyTreeTests window)!.Value; global::Avalonia.Headless.HeadlessWindowExtensions.MouseDown(window, rowCentre, global::Avalonia.Input.MouseButton.Left); global::Avalonia.Headless.HeadlessWindowExtensions.MouseUp(window, rowCentre, global::Avalonia.Input.MouseButton.Left); + TestCapture.Step("visible-row-clicked"); for (int i = 0; i < 8; i++) { @@ -731,6 +734,7 @@ public class AssemblyTreeTests .First(m => m.MethodDefinition.Name == "AsEnumerable"); vm.AssemblyTreeModel.SelectNode(method); await vm.DockWorkspace.WaitForDecompiledTextAsync(); + TestCapture.Step("asenumerable-decompiled"); // Act — invoke SaveCodeAsync with a temp path (bypassing the file picker so the test // is deterministic). @@ -777,6 +781,7 @@ public class AssemblyTreeTests var sacrificialNode = vm.AssemblyTreeModel.FindNode(sacrificialName); vm.AssemblyTreeModel.SelectNode(sacrificialNode); await Waiters.WaitForAsync(() => ReferenceEquals(vm.AssemblyTreeModel.SelectedItem, sacrificialNode)); + TestCapture.Step("sacrificial-selected"); var pane = await window.WaitForComponent(); var grid = await pane.WaitForComponent(); @@ -793,6 +798,7 @@ public class AssemblyTreeTests global::Avalonia.Input.RawInputModifiers.None, global::Avalonia.Input.PhysicalKey.Delete, null); + TestCapture.Step("delete-key-pressed"); // Assert — the sacrificial assembly is gone from the data list AND the grid's bound // ItemsSource drops by one. Both halves matter: a passing AssemblyList assertion alone @@ -915,6 +921,7 @@ public class AssemblyTreeTests Dispatcher.UIThread.RunJobs(); await Task.Delay(20); } + TestCapture.Step("assembly-row-expanded"); // Assert — the grid wrapper now reports IsExpanded == true. hm.FindNode(assemblyNode)!.IsExpanded.Should().BeTrue( @@ -939,6 +946,7 @@ public class AssemblyTreeTests .First(m => m.MethodDefinition.Name == "Empty"); vm.AssemblyTreeModel.SelectNode(pinned); var firstTab = await vm.DockWorkspace.WaitForDecompiledTextAsync(); + TestCapture.Step("asenumerable-in-first-tab"); await Waiters.WaitForAsync(() => window.GetVisualDescendants().OfType().Any()); var pane = await window.WaitForComponent(); @@ -951,6 +959,7 @@ public class AssemblyTreeTests await Waiters.WaitForAsync( () => (documents.VisibleDockables?.Count ?? 0) > initialCount); var newTab = await vm.DockWorkspace.WaitForDecompiledTextAsync(); + TestCapture.Step("empty-spawned-in-new-tab"); ReferenceEquals(newTab, firstTab).Should().BeFalse( "a fresh decompiler tab must be created instead of reusing the existing one"); newTab.Text.Should().Contain("Empty"); @@ -986,12 +995,14 @@ public class AssemblyTreeTests vm.DockWorkspace.OpenNodeInNewTab(second); await Waiters.WaitForAsync(() => ReferenceEquals(vm.AssemblyTreeModel.SelectedItem, second)); + TestCapture.Step("second-tab-active"); // Re-activate the first tab — selection must swing back. vm.DockWorkspace.Factory.SetActiveDockable(firstTab!); await Waiters.WaitForAsync(() => ReferenceEquals(vm.AssemblyTreeModel.SelectedItem, first)); + TestCapture.Step("switched-back-to-first-tab"); ReferenceEquals(vm.AssemblyTreeModel.SelectedItem, first).Should().BeTrue( "clicking back to the previous tab must pull the tree selection with it"); } @@ -1014,6 +1025,7 @@ public class AssemblyTreeTests // First metadata view via tree-selection (active tab gets MetadataTablePageModel content). vm.AssemblyTreeModel.SelectNode(typeDefNode); var firstMeta = await vm.DockWorkspace.WaitForMetadataTabAsync(); + TestCapture.Step("typedef-metadata-tab"); var documents = ((ILSpyDockFactory)vm.DockWorkspace.Factory).Documents!; var initialCount = documents.VisibleDockables?.Count ?? 0; @@ -1027,6 +1039,7 @@ public class AssemblyTreeTests await Waiters.WaitForAsync( () => (documents.VisibleDockables?.Count ?? 0) > initialCount); var secondMeta = await vm.DockWorkspace.WaitForMetadataTabAsync(); + TestCapture.Step("method-table-second-metadata-tab"); ReferenceEquals(secondMeta, firstMeta).Should().BeFalse( "a fresh metadata tab must be created — the previous one keeps its TypeDef state"); } @@ -1370,6 +1383,7 @@ public class AssemblyTreeTests // SelectedItems still holds all of them — so assert on the grid, not the model. int gridAfterFirst = grid.SelectedItems.Count; int modelAfterFirst = vm.AssemblyTreeModel.SelectedItems.Count; + TestCapture.Step("after-first-ctrl-a"); // Second press. window.KeyPress(Key.A, RawInputModifiers.Control, PhysicalKey.A, keySymbol: "a"); @@ -1405,6 +1419,7 @@ public class AssemblyTreeTests window.KeyPress(Key.A, RawInputModifiers.Control, PhysicalKey.A, keySymbol: "a"); Dispatcher.UIThread.RunJobs(); await Waiters.WaitForAsync(() => grid.SelectedItems.Count == topLevelCount); + TestCapture.Step("all-rows-selected"); // Act — plain left-click on the second visible row. var targetRow = grid.GetVisualDescendants().OfType() @@ -1419,6 +1434,7 @@ public class AssemblyTreeTests Dispatcher.UIThread.RunJobs(); await Task.Delay(50); Dispatcher.UIThread.RunJobs(); + TestCapture.Step("plain-click-collapsed-selection"); // Assert — selection collapsed to exactly the clicked row, in both grid and model. grid.SelectedItems.Count.Should().Be(1, diff --git a/ILSpy.Tests/AssemblyList/ReloadAssemblyContextMenuTests.cs b/ILSpy.Tests/AssemblyList/ReloadAssemblyContextMenuTests.cs index 59cd1b8bf..35b57774c 100644 --- a/ILSpy.Tests/AssemblyList/ReloadAssemblyContextMenuTests.cs +++ b/ILSpy.Tests/AssemblyList/ReloadAssemblyContextMenuTests.cs @@ -108,23 +108,24 @@ public class ReloadAssemblyContextMenuTests var originalAssembly = node.LoadedAssembly; var fileName = originalAssembly.FileName; - window.CaptureForReview(1, "initial-tree"); + window.Capture("initial-tree"); // Act — select the row, build the live context menu, click Reload. vm.AssemblyTreeModel.SelectNode(node); - window.CaptureForReview(2, "system-linq-selected"); + window.Capture("system-linq-selected"); var menu = pane.BuildContextMenuForCurrentState(registry.Entries); menu.Should().NotBeNull(); var reloadItem = menu!.Items.OfType() .Single(i => (string?)i.Header == Resources._Reload); reloadItem.RaiseEvent(new RoutedEventArgs(MenuItem.ClickEvent)); + window.Capture("reload-clicked"); // Assert — the LoadedAssembly for that file name is a different instance now. await Waiters.WaitForAsync(() => vm.AssemblyTreeModel.AssemblyList!.GetAssemblies().Any(a => a.FileName == fileName && !ReferenceEquals(a, originalAssembly))); - window.CaptureForReview(3, "after-reload-same-file-fresh-instance"); + window.Capture("after-reload-same-file-fresh-instance"); } } diff --git a/ILSpy.Tests/AssemblyList/RemoveAssemblyContextMenuTests.cs b/ILSpy.Tests/AssemblyList/RemoveAssemblyContextMenuTests.cs index 921de9a12..f9193e90e 100644 --- a/ILSpy.Tests/AssemblyList/RemoveAssemblyContextMenuTests.cs +++ b/ILSpy.Tests/AssemblyList/RemoveAssemblyContextMenuTests.cs @@ -112,18 +112,19 @@ public class RemoveAssemblyContextMenuTests var survivorName = typeof(object).Assembly.GetName().Name!; var sacrificial = vm.AssemblyTreeModel.FindNode(sacrificialName); - window.CaptureForReview(1, "initial-tree-three-assemblies"); + window.Capture("initial-tree-three-assemblies"); // Act — select the row, build the menu the way the live Opening event would, find // the Remove item, and click it. vm.AssemblyTreeModel.SelectNode(sacrificial); - window.CaptureForReview(2, "sacrificial-selected"); + window.Capture("sacrificial-selected"); var menu = pane.BuildContextMenuForCurrentState(registry.Entries); menu.Should().NotBeNull(); var removeItem = menu!.Items.OfType() .Single(i => (string?)i.Header == Resources._Remove); removeItem.RaiseEvent(new RoutedEventArgs(MenuItem.ClickEvent)); + window.Capture("remove-clicked"); // Assert — the assembly is gone from the list; the survivor is still there. await Waiters.WaitForAsync(() => @@ -132,7 +133,7 @@ public class RemoveAssemblyContextMenuTests vm.AssemblyTreeModel.AssemblyList!.GetAssemblies() .Should().Contain(a => a.ShortName == survivorName); - window.CaptureForReview(3, "after-remove-sacrificial-gone"); + window.Capture("after-remove-sacrificial-gone"); } [AvaloniaTest] @@ -149,11 +150,13 @@ public class RemoveAssemblyContextMenuTests var pane = await window.WaitForComponent(); var assemblyNode = vm.AssemblyTreeModel.FindNode("System.Linq"); vm.AssemblyTreeModel.SelectNode(assemblyNode); + window.Capture("system-linq-selected"); var registry = AppComposition.Current.GetExport(); // Act — drive the same build path the live Opening event uses. var built = pane.BuildContextMenuForCurrentState(registry.Entries); + window.Capture("context-menu-built"); // Assert — the live menu contains a "Remove" item (resolved to its localized form by // the menu builder). diff --git a/ILSpy.Tests/AssemblyList/UseNestedNamespaceNodesGridVerification.cs b/ILSpy.Tests/AssemblyList/UseNestedNamespaceNodesGridVerification.cs index 682735bc5..2acc630ab 100644 --- a/ILSpy.Tests/AssemblyList/UseNestedNamespaceNodesGridVerification.cs +++ b/ILSpy.Tests/AssemblyList/UseNestedNamespaceNodesGridVerification.cs @@ -55,6 +55,7 @@ public class UseNestedNamespaceNodesGridVerification var assemblyNode = vm.AssemblyTreeModel.FindNode("System.Linq"); assemblyNode.IsExpanded = true; await Waiters.WaitForAsync(() => grid.HierarchicalModel != null); + TestCapture.Step("system-linq-expanded-flat"); try { @@ -66,6 +67,7 @@ public class UseNestedNamespaceNodesGridVerification await Waiters.WaitForAsync( () => !ReferenceEquals(grid.HierarchicalModel, modelBefore), System.TimeSpan.FromSeconds(5)); + TestCapture.Step("nested-namespaces-rebound"); grid.HierarchicalModel.Should().NotBeSameAs(modelBefore, "toggling UseNestedNamespaceNodes must force AssemblyListPane.BindTree, " diff --git a/ILSpy.Tests/CaptureContextAttribute.cs b/ILSpy.Tests/CaptureContextAttribute.cs new file mode 100644 index 000000000..a8a0ab0bb --- /dev/null +++ b/ILSpy.Tests/CaptureContextAttribute.cs @@ -0,0 +1,53 @@ +// Copyright (c) 2026 AlphaSierraPapa for the SharpDevelop Team +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of this +// software and associated documentation files (the "Software"), to deal in the Software +// without restriction, including without limitation the rights to use, copy, modify, merge, +// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons +// to whom the Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all copies or +// substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, +// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR +// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE +// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +using System; + +using NUnit.Framework; +using NUnit.Framework.Interfaces; + +namespace ICSharpCode.ILSpy.Tests; + +/// +/// Feeds the running test's fixture and method name to before each test. +/// +/// Visual-breakpoint captures fire from inside the async test body, often after an await. +/// NUnit's TestContext.CurrentContext is unreliable there -- its execution context does not +/// flow onto every async continuation, so a live lookup can fall back to the ad-hoc context and +/// dump frames from unrelated tests under one colliding filename. +/// instead hands us the real up front, on the same dispatcher thread the test +/// body runs on, so we record the identity once and the captures read it from a plain static. +/// +/// Kept separate from ResetAppStateAttribute so that attribute stays self-contained for the +/// build sweep -- capture instrumentation must not ride along into older commits. +/// +[AttributeUsage(AttributeTargets.Assembly)] +public sealed class CaptureContextAttribute : Attribute, ITestAction +{ + public ActionTargets Targets => ActionTargets.Test; + + public void BeforeTest(ITest test) + { + ArgumentNullException.ThrowIfNull(test); + TestCapture.BeginTest(test.ClassName, test.Name); + } + + public void AfterTest(ITest test) + { + } +} diff --git a/ILSpy.Tests/ContextMenus/CopyFullyQualifiedNameTests.cs b/ILSpy.Tests/ContextMenus/CopyFullyQualifiedNameTests.cs index daa974799..9356cd2df 100644 --- a/ILSpy.Tests/ContextMenus/CopyFullyQualifiedNameTests.cs +++ b/ILSpy.Tests/ContextMenus/CopyFullyQualifiedNameTests.cs @@ -68,6 +68,7 @@ public class CopyFullyQualifiedNameTests window.Show(); var vm = (MainWindowViewModel)window.DataContext!; await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 3); + TestCapture.Step("booted"); var registry = AppComposition.Current.GetExport(); var entry = registry.Entries .Single(e => e.Metadata.Header == nameof(Resources.CopyName)) @@ -101,6 +102,7 @@ public class CopyFullyQualifiedNameTests window.Show(); var vm = (MainWindowViewModel)window.DataContext!; await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 3); + TestCapture.Step("booted"); var registry = AppComposition.Current.GetExport(); var entry = (CopyFullyQualifiedNameContextMenuEntry)registry.Entries .Single(e => e.Metadata.Header == nameof(Resources.CopyName)) diff --git a/ILSpy.Tests/ContextMenus/CreateDiagramContextMenuTests.cs b/ILSpy.Tests/ContextMenus/CreateDiagramContextMenuTests.cs index fd11a6b51..a89c50afa 100644 --- a/ILSpy.Tests/ContextMenus/CreateDiagramContextMenuTests.cs +++ b/ILSpy.Tests/ContextMenus/CreateDiagramContextMenuTests.cs @@ -50,6 +50,7 @@ public class CreateDiagramContextMenuTests window.Show(); var vm = (MainWindowViewModel)window.DataContext!; await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 1); + TestCapture.Step("booted"); var entry = AppComposition.Current.GetExport().Entries .Select(e => e.Value).OfType().Single(); @@ -67,6 +68,7 @@ public class CreateDiagramContextMenuTests window.Show(); var vm = (MainWindowViewModel)window.DataContext!; await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 1); + TestCapture.Step("booted"); var entry = AppComposition.Current.GetExport().Entries .Select(e => e.Value).OfType().Single(); @@ -85,6 +87,7 @@ public class CreateDiagramContextMenuTests window.Show(); var vm = (MainWindowViewModel)window.DataContext!; await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 1); + TestCapture.Step("booted"); var entry = AppComposition.Current.GetExport().Entries .Select(e => e.Value).OfType().Single(); diff --git a/ILSpy.Tests/ContextMenus/DecompileInNewViewTests.cs b/ILSpy.Tests/ContextMenus/DecompileInNewViewTests.cs index a4e91a134..2eebe4374 100644 --- a/ILSpy.Tests/ContextMenus/DecompileInNewViewTests.cs +++ b/ILSpy.Tests/ContextMenus/DecompileInNewViewTests.cs @@ -79,11 +79,11 @@ public class DecompileInNewViewTests var asm = vm.AssemblyTreeModel.FindNode("System.Linq"); var header = ICSharpCode.ILSpy.Properties.Resources.DecompileToNewPanel; - window.CaptureForReview(1, "initial-no-selection"); + window.Capture("initial-no-selection"); // With an assembly node selected → the menu surfaces "Decompile in new tab". vm.AssemblyTreeModel.SelectNode(asm); - window.CaptureForReview(2, "assembly-selected"); + window.Capture("assembly-selected"); var withSelection = pane.BuildContextMenuForCurrentState(registry.Entries); withSelection.Should().NotBeNull(); withSelection!.Items.OfType().Select(i => (string?)i.Header) @@ -91,7 +91,7 @@ public class DecompileInNewViewTests // With no selection → the entry is filtered out of the built menu. vm.AssemblyTreeModel.SelectedItems.Clear(); - window.CaptureForReview(3, "selection-cleared"); + window.Capture("selection-cleared"); var withoutSelection = pane.BuildContextMenuForCurrentState(registry.Entries); // `Build` returns null when no entry would be visible; otherwise it may still build a // menu (other entries don't gate on selection). Either way, the header must not appear. @@ -126,7 +126,7 @@ public class DecompileInNewViewTests // addition to the existing one and shows the dispatched method". vm.AssemblyTreeModel.SelectNode(firstMethod); var firstTab = await vm.DockWorkspace.WaitForDecompiledTextAsync(); - window.CaptureForReview(1, "first-method-decompiled-into-active-tab"); + window.Capture("first-method-decompiled-into-active-tab"); var pane = await window.WaitForComponent(); var registry = AppComposition.Current.GetExport(); @@ -136,7 +136,7 @@ public class DecompileInNewViewTests // Select the second method and click the menu entry through the live context-menu path. vm.AssemblyTreeModel.SelectNode(secondMethod); await vm.DockWorkspace.WaitForDecompiledTextAsync(); - window.CaptureForReview(2, "second-method-replaced-active-tab"); + window.Capture("second-method-replaced-active-tab"); var menu = pane.BuildContextMenuForCurrentState(registry.Entries); menu.Should().NotBeNull(); @@ -154,6 +154,6 @@ public class DecompileInNewViewTests // of firstTab. (The right-click selection move into secondMethod re-uses firstTab; the // menu-click then creates an additional tab. Net effect: +1 dockable.) documents.VisibleDockables!.Count.Should().Be(initialCount + 1); - window.CaptureForReview(3, "after-click-new-tab-spawned"); + window.Capture("after-click-new-tab-spawned"); } } diff --git a/ILSpy.Tests/ContextMenus/OpenContainingFolderTests.cs b/ILSpy.Tests/ContextMenus/OpenContainingFolderTests.cs index 8fcbd9d83..27dd16882 100644 --- a/ILSpy.Tests/ContextMenus/OpenContainingFolderTests.cs +++ b/ILSpy.Tests/ContextMenus/OpenContainingFolderTests.cs @@ -79,14 +79,15 @@ public class OpenContainingFolderTests var assemblyNode = vm.AssemblyTreeModel.FindNode("System.Linq"); var expectedPath = assemblyNode.LoadedAssembly.FileName; - window.CaptureForReview(1, "initial-tree"); + window.Capture("initial-tree"); // Selecting the deep TypeTreeNode must still surface "Open Containing Folder" in the // live menu (the entry's IsVisible walks up to the assembly). vm.AssemblyTreeModel.SelectNode(typeNode); - window.CaptureForReview(2, "enumerable-type-selected-deep-node"); + window.Capture("enumerable-type-selected-deep-node"); var menu = pane.BuildContextMenuForCurrentState(registry.Entries); + TestCapture.Step("context-menu-built"); menu.Should().NotBeNull(); menu!.Items.OfType().Select(i => (string?)i.Header) .Should().Contain(Resources._OpenContainingFolder); @@ -112,6 +113,7 @@ public class OpenContainingFolderTests window.Show(); var vm = (MainWindowViewModel)window.DataContext!; await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 1); + TestCapture.Step("booted"); var registry = AppComposition.Current.GetExport(); var entry = registry.Entries .Single(e => e.Metadata.Header == nameof(Resources._OpenContainingFolder)) diff --git a/ILSpy.Tests/ContextMenus/SearchMsdnTests.cs b/ILSpy.Tests/ContextMenus/SearchMsdnTests.cs index bdb0a15a8..5f91d2eb9 100644 --- a/ILSpy.Tests/ContextMenus/SearchMsdnTests.cs +++ b/ILSpy.Tests/ContextMenus/SearchMsdnTests.cs @@ -69,6 +69,7 @@ public class SearchMsdnTests window.Show(); var vm = (MainWindowViewModel)window.DataContext!; await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 3); + TestCapture.Step("booted"); var registry = AppComposition.Current.GetExport(); var entry = (SearchMsdnContextMenuEntry)registry.Entries .Single(e => e.Metadata.Header == nameof(Resources.SearchMSDN)) @@ -106,13 +107,14 @@ public class SearchMsdnTests var typeNode = vm.AssemblyTreeModel.FindNode( "System.Linq", "System.Linq", "System.Linq.Enumerable"); - window.CaptureForReview(1, "initial-tree"); + window.Capture("initial-tree"); // Selecting the type and building the live menu must surface "Search Microsoft Docs…". vm.AssemblyTreeModel.SelectNode(typeNode); - window.CaptureForReview(2, "enumerable-type-selected"); + window.Capture("enumerable-type-selected"); var menu = pane.BuildContextMenuForCurrentState(registry.Entries); + TestCapture.Step("context-menu-built"); menu.Should().NotBeNull(); menu!.Items.OfType().Select(i => (string?)i.Header) .Should().Contain(Resources.SearchMSDN); diff --git a/ILSpy.Tests/Docking/LayoutPersistenceTests.cs b/ILSpy.Tests/Docking/LayoutPersistenceTests.cs index a03740c8c..dae004f0a 100644 --- a/ILSpy.Tests/Docking/LayoutPersistenceTests.cs +++ b/ILSpy.Tests/Docking/LayoutPersistenceTests.cs @@ -186,6 +186,7 @@ public class LayoutPersistenceTests // being persisted at all. dockWorkspace.OpenNewTab(new object()); dockWorkspace.OpenNewTab(new object()); + TestCapture.Step("three-document-tabs-open"); var sourceDocs = dockWorkspace.Documents!.VisibleDockables! .OfType().Count(); sourceDocs.Should().Be(3, "test must open 3 tabs (MainTab + 2 extras) before saving"); diff --git a/ILSpy.Tests/Docking/PreviewTabPromotionTests.cs b/ILSpy.Tests/Docking/PreviewTabPromotionTests.cs index 9849102f1..d0662bd83 100644 --- a/ILSpy.Tests/Docking/PreviewTabPromotionTests.cs +++ b/ILSpy.Tests/Docking/PreviewTabPromotionTests.cs @@ -48,6 +48,7 @@ public class PreviewTabPromotionTests var window = AppComposition.Current.GetExport(); window.Show(); var vm = (MainWindowViewModel)window.DataContext!; + TestCapture.Step("booted"); var mainTab = ((ILSpyDockFactory)vm.DockWorkspace.Factory).MainTab!; mainTab.IsPreview.Should().BeTrue( "the freshly-created MainTab is the preview slot until the user pins it"); @@ -63,6 +64,7 @@ public class PreviewTabPromotionTests var typeNode = vm.AssemblyTreeModel.FindNode( "System.Linq", "System.Linq", "System.Linq.Enumerable"); vm.DockWorkspace.OpenNodeInNewTab(typeNode); + TestCapture.Step("carve-out-tab-opened"); var carveOut = vm.DockWorkspace.Documents!.VisibleDockables! .OfType() @@ -89,6 +91,7 @@ public class PreviewTabPromotionTests "System.Linq", "System.Linq", "System.Linq.Enumerable"); vm.AssemblyTreeModel.SelectNode(typeNode); vm.DockWorkspace.SettleSelection(); + TestCapture.Step("enumerable-selected"); ReferenceEquals(previousMainTab.SourceNode, typeNode).Should().BeTrue( "baseline: tree selection populated MainTab with the chosen node"); @@ -96,6 +99,7 @@ public class PreviewTabPromotionTests // Pin. vm.DockWorkspace.PinCurrentTab(); + TestCapture.Step("tab-pinned"); // Tab is now pinned, content preserved. previousMainTab.IsPreview.Should().BeFalse( @@ -127,6 +131,7 @@ public class PreviewTabPromotionTests "System.Linq", "System.Linq", "System.Linq.Enumerable"); vm.AssemblyTreeModel.SelectNode(typeA); vm.DockWorkspace.SettleSelection(); + TestCapture.Step("type-a-selected"); var pinnedTab = factory.MainTab!; var pinnedContent = pinnedTab.Content; @@ -134,6 +139,7 @@ public class PreviewTabPromotionTests // no spawn yet (covered by PinCurrentTab_Flips_IsPreview_Without_Spawning_A_New_Tab). var tabCountBeforeSelection = vm.DockWorkspace.Documents!.VisibleDockables!.Count; vm.DockWorkspace.PinCurrentTab(); + TestCapture.Step("tab-pinned"); factory.MainTab.Should().BeSameAs(pinnedTab, "pin alone keeps the slot"); // Phase 3: select a different type — NOW a new preview tab should spawn AND @@ -142,6 +148,7 @@ public class PreviewTabPromotionTests "System.Private.Uri", "System", "System.Uri"); vm.AssemblyTreeModel.SelectNode(typeB); vm.DockWorkspace.SettleSelection(); + TestCapture.Step("type-b-opens-new-preview"); // New tab spawned beside the pinned one. vm.DockWorkspace.Documents!.VisibleDockables!.Count.Should().Be(tabCountBeforeSelection + 1, @@ -175,6 +182,7 @@ public class PreviewTabPromotionTests var factory = (ILSpyDockFactory)vm.DockWorkspace.Factory; var mainTabItem = window.GetVisualDescendants().OfType() .Single(item => ReferenceEquals(item.DataContext, factory.MainTab)); + TestCapture.Step("before-italic-header-check"); mainTabItem.FontStyle.Should().Be(FontStyle.Italic, "the App.axaml Style for DocumentTabStripItem must apply, italicising the tab title via FontStyle inheritance"); @@ -188,6 +196,7 @@ public class PreviewTabPromotionTests var window = AppComposition.Current.GetExport(); window.Show(); await ((MainWindowViewModel)window.DataContext!).AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 1); + TestCapture.Step("booted"); await Waiters.WaitForAsync(() => window.GetVisualDescendants().OfType().Any(), System.TimeSpan.FromSeconds(10)); @@ -195,6 +204,7 @@ public class PreviewTabPromotionTests var factory = (ILSpyDockFactory)((MainWindowViewModel)window.DataContext!).DockWorkspace.Factory; var mainTabItem = window.GetVisualDescendants().OfType() .Single(item => ReferenceEquals(item.DataContext, factory.MainTab)); + TestCapture.Step("before-accent-stripe-check"); ((object?)mainTabItem.BorderBrush).Should().NotBeNull( "the preview MainTab must carry the accent BorderBrush"); @@ -217,6 +227,7 @@ public class PreviewTabPromotionTests var typeNode = vm.AssemblyTreeModel.FindNode( "System.Linq", "System.Linq", "System.Linq.Enumerable"); vm.DockWorkspace.OpenNodeInNewTab(typeNode); + TestCapture.Step("carve-out-tab-opened"); await Waiters.WaitForAsync(() => window.GetVisualDescendants().OfType().Count() >= 2, System.TimeSpan.FromSeconds(10)); @@ -288,6 +299,7 @@ public class PreviewTabPromotionTests var typeNode = vm.AssemblyTreeModel.FindNode( "System.Linq", "System.Linq", "System.Linq.Enumerable"); vm.DockWorkspace.OpenNodeInNewTab(typeNode); + TestCapture.Step("carve-out-tab-opened"); await Waiters.WaitForAsync(() => window.GetVisualDescendants().OfType().Count() >= 2, System.TimeSpan.FromSeconds(10)); @@ -298,6 +310,7 @@ public class PreviewTabPromotionTests mainTab.Title = "This.Is.An.Extremely.Long.Tab.Title.That.Would.Normally.Overflow"; global::Avalonia.Threading.Dispatcher.UIThread.RunJobs(); + TestCapture.Step("long-title-applied"); var mainTabItem = window.GetVisualDescendants().OfType() .Single(item => ReferenceEquals(item.DataContext, mainTab)); // Constrain the tab to 200px (mimics production where the document strip shares @@ -373,6 +386,7 @@ public class PreviewTabPromotionTests // Simulate a user click on the pin button. pinButton.RaiseEvent(new global::Avalonia.Interactivity.RoutedEventArgs( global::Avalonia.Controls.Button.ClickEvent)); + TestCapture.Step("pin-button-clicked"); // New semantics: clicking pin flips IsPreview, doesn't spawn a new tab. previousMainTab.IsPreview.Should().BeFalse( @@ -392,6 +406,7 @@ public class PreviewTabPromotionTests var typeNode = vm.AssemblyTreeModel.FindNode( "System.Linq", "System.Linq", "System.Linq.Enumerable"); vm.DockWorkspace.OpenNodeInNewTab(typeNode); + TestCapture.Step("carve-out-tab-opened"); await Waiters.WaitForAsync(() => window.GetVisualDescendants().OfType().Count() >= 2, System.TimeSpan.FromSeconds(10)); @@ -424,6 +439,7 @@ public class PreviewTabPromotionTests var typeNode = vm.AssemblyTreeModel.FindNode( "System.Linq", "System.Linq", "System.Linq.Enumerable"); vm.DockWorkspace.OpenNodeInNewTab(typeNode); + TestCapture.Step("carve-out-tab-opened"); await Waiters.WaitForAsync(() => window.GetVisualDescendants().OfType().Any(), System.TimeSpan.FromSeconds(10)); @@ -450,12 +466,14 @@ public class PreviewTabPromotionTests window.Show(); var vm = (MainWindowViewModel)window.DataContext!; var factory = (ILSpyDockFactory)vm.DockWorkspace.Factory; + TestCapture.Step("booted"); // Manually flip the current MainTab to pinned, simulating the post-pin state. factory.MainTab!.IsPreview = false; var before = factory.MainTab; vm.DockWorkspace.PinCurrentTab(); + TestCapture.Step("pin-noop"); factory.MainTab.Should().BeSameAs(before, "with no preview MainTab to flip, PinCurrentTab must leave the factory state untouched"); @@ -496,6 +514,7 @@ public class PreviewTabPromotionTests var typeNode = vm.AssemblyTreeModel.FindNode( "System.Linq", "System.Linq", "System.Linq.Enumerable"); vm.DockWorkspace.OpenNodeInNewTab(typeNode); + TestCapture.Step("carve-out-tab-opened"); await Waiters.WaitForAsync(() => window.GetVisualDescendants().OfType().Count() >= 2, System.TimeSpan.FromSeconds(10)); @@ -540,6 +559,7 @@ public class PreviewTabPromotionTests .OfType() .Last(t => t.SourceNode == typeA); factory.SetActiveDockable(carveOut); + TestCapture.Step("carve-out-active"); carveOut.IsPreview.Should().BeFalse("baseline: carve-out tab is frozen"); ReferenceEquals(vm.DockWorkspace.Documents.ActiveDockable, carveOut).Should().BeTrue( "baseline: the carve-out is the active document"); @@ -552,6 +572,7 @@ public class PreviewTabPromotionTests "System.Private.Uri", "System", "System.Uri"); vm.AssemblyTreeModel.SelectNode(typeB); vm.DockWorkspace.SettleSelection(); + TestCapture.Step("type-b-opens-new-preview"); vm.DockWorkspace.Documents!.VisibleDockables!.Count.Should().Be(tabCountBefore + 1, "selecting a tree node while a frozen tab is active must spawn a new preview tab"); diff --git a/ILSpy.Tests/Docking/SingletonDocumentTabTests.cs b/ILSpy.Tests/Docking/SingletonDocumentTabTests.cs index 7deed3367..4903d9efd 100644 --- a/ILSpy.Tests/Docking/SingletonDocumentTabTests.cs +++ b/ILSpy.Tests/Docking/SingletonDocumentTabTests.cs @@ -56,18 +56,22 @@ public class SingletonDocumentTabTests window.Show(); var vm = (MainWindowViewModel)window.DataContext!; var dock = vm.DockWorkspace; + TestCapture.Step("booted"); Invoke(window, nameof(Resources._Options)); + TestCapture.Step("options-opened"); var first = dock.Documents!.VisibleDockables!.OfType() .Single(t => t.Content is OptionsPageModel); var firstContent = first.Content; dock.Factory.CloseDockable(first); Avalonia.Threading.Dispatcher.UIThread.RunJobs(); + TestCapture.Step("options-closed"); dock.Documents!.VisibleDockables!.OfType() .Any(t => t.Content is OptionsPageModel).Should().BeFalse("closing must remove the Options tab"); Invoke(window, nameof(Resources._Options)); + TestCapture.Step("options-reopened"); var second = dock.Documents!.VisibleDockables!.OfType() .Single(t => t.Content is OptionsPageModel); @@ -84,16 +88,20 @@ public class SingletonDocumentTabTests window.Show(); var vm = (MainWindowViewModel)window.DataContext!; var dock = vm.DockWorkspace; + TestCapture.Step("booted"); bool IsAbout(ContentTabPage t) => t.Content is DecompilerTabPageModel { Title: var title } && title == Resources.About; Invoke(window, nameof(Resources._About)); + TestCapture.Step("about-opened"); var first = dock.Documents!.VisibleDockables!.OfType().Single(IsAbout); dock.Factory.CloseDockable(first); Avalonia.Threading.Dispatcher.UIThread.RunJobs(); + TestCapture.Step("about-closed"); Invoke(window, nameof(Resources._About)); + TestCapture.Step("about-reopened"); var second = dock.Documents!.VisibleDockables!.OfType().Single(IsAbout); second.Should().BeSameAs(first, diff --git a/ILSpy.Tests/MainWindow/MainToolBarLayoutTests.cs b/ILSpy.Tests/MainWindow/MainToolBarLayoutTests.cs index 6acb01864..b97b148f9 100644 --- a/ILSpy.Tests/MainWindow/MainToolBarLayoutTests.cs +++ b/ILSpy.Tests/MainWindow/MainToolBarLayoutTests.cs @@ -59,6 +59,7 @@ public class MainToolBarLayoutTests var window = AppComposition.Current.GetExport(); window.Show(); + TestCapture.Step("booted"); var toolbar = await window.WaitForComponent(); var combo = toolbar.GetVisualDescendants().OfType() @@ -107,6 +108,7 @@ public class MainToolBarLayoutTests var search = AppComposition.Current.GetExport(); searchButton!.Command!.CanExecute(null).Should().BeTrue(); searchButton.Command.Execute(null); + TestCapture.Step("search-pane-activated"); search.IsActive.Should().BeTrue( "clicking the Show-Search toolbar button must activate the search tool pane"); @@ -162,6 +164,7 @@ public class MainToolBarLayoutTests var rootPanel = toolbar.GetVisualDescendants().OfType() .Single(s => s.Name == "ToolbarRoot"); var labels = rootPanel.Children.Select(Label).ToList(); + TestCapture.Step("before-toolbar-order-check"); labels.Should().Equal( "BackSplitButton", diff --git a/ILSpy.Tests/MainWindow/MainWindowTests.cs b/ILSpy.Tests/MainWindow/MainWindowTests.cs index 55cf164c4..bd382f96b 100644 --- a/ILSpy.Tests/MainWindow/MainWindowTests.cs +++ b/ILSpy.Tests/MainWindow/MainWindowTests.cs @@ -50,6 +50,7 @@ public class MainWindowTests // Arrange + Act — resolve and show. var window = AppComposition.Current.GetExport(); window.Show(); + TestCapture.Step("booted"); // Assert — visible, titled, with the correct DataContext. window.IsVisible.Should().BeTrue(); @@ -71,6 +72,7 @@ public class MainWindowTests await Waiters.WaitForAsync(() => window.GetVisualDescendants().OfType().Any()); var pane = await window.WaitForComponent(); + TestCapture.Step("before-assembly-pane-check"); // Assert — visible with positive width and height. pane.IsVisible.Should().BeTrue(); @@ -95,6 +97,7 @@ public class MainWindowTests .OfType