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()
.Any(b => !b.IsEffectivelyEnabled
&& b.GetVisualDescendants().OfType().Any()));
+ TestCapture.Step("before-disabled-icon-check");
// Assert — every disabled toolbar icon is the grayscale-aware variant, not a plain Image.
var disabledIcons = window.GetVisualDescendants()
@@ -124,6 +127,7 @@ public class MainWindowTests
var openButton = window.GetVisualDescendants()
.OfType()
.Single(b => (string?)b.Tag == nameof(ICSharpCode.ILSpy.Properties.Resources.Open));
+ TestCapture.Step("before-open-button-check");
// Assert — Command is wired and CanExecute is true.
openButton.Command.Should().NotBeNull();
@@ -148,6 +152,7 @@ public class MainWindowTests
var node = vm.AssemblyTreeModel.FindNode("System.Linq");
vm.AssemblyTreeModel.SelectNode(node);
await vm.DockWorkspace.WaitForDecompiledTextAsync();
+ TestCapture.Step("linq-decompiled");
service.StateChanged -= Observe;
diff --git a/ILSpy.Tests/MainWindow/WindowMenuOpenDocumentsTests.cs b/ILSpy.Tests/MainWindow/WindowMenuOpenDocumentsTests.cs
index 6315b87b0..bf7bb2643 100644
--- a/ILSpy.Tests/MainWindow/WindowMenuOpenDocumentsTests.cs
+++ b/ILSpy.Tests/MainWindow/WindowMenuOpenDocumentsTests.cs
@@ -52,10 +52,12 @@ public class WindowMenuOpenDocumentsTests
var firstAsm = vm.AssemblyTreeModel.FindNode("System.Linq");
vm.AssemblyTreeModel.SelectNode(firstAsm);
await vm.DockWorkspace.WaitForDecompiledTextAsync();
+ TestCapture.Step("first-assembly-decompiled");
var secondAsm = vm.AssemblyTreeModel.FindNode("System.Private.Uri");
vm.DockWorkspace.OpenNodeInNewTab(secondAsm);
await vm.DockWorkspace.WaitForDecompiledTextAsync();
+ TestCapture.Step("second-assembly-in-new-tab");
var nativeMenu = NativeMenu.GetMenu(window)
?? throw new InvalidOperationException("MainMenu.Attach should have set NativeMenu on the window");
diff --git a/ILSpy.Tests/Metadata/DebugDirectoryChildNodesTests.cs b/ILSpy.Tests/Metadata/DebugDirectoryChildNodesTests.cs
index 8b0445b6b..6d51dd49d 100644
--- a/ILSpy.Tests/Metadata/DebugDirectoryChildNodesTests.cs
+++ b/ILSpy.Tests/Metadata/DebugDirectoryChildNodesTests.cs
@@ -98,6 +98,7 @@ public class DebugDirectoryChildNodesTests
.GetChild()
.GetChild();
debugDir.EnsureLazyChildren();
+ TestCapture.Step("debug-directory-children");
return debugDir;
}
@@ -113,6 +114,7 @@ public class DebugDirectoryChildNodesTests
.GetChild()
.GetChild();
debugDir.EnsureLazyChildren();
+ TestCapture.Step("debug-directory-children");
return (debugDir, rawCount);
}
}
diff --git a/ILSpy.Tests/Metadata/EmbeddedPdbTreeTests.cs b/ILSpy.Tests/Metadata/EmbeddedPdbTreeTests.cs
index 930e33d9a..c43f6cd54 100644
--- a/ILSpy.Tests/Metadata/EmbeddedPdbTreeTests.cs
+++ b/ILSpy.Tests/Metadata/EmbeddedPdbTreeTests.cs
@@ -54,6 +54,7 @@ public class EmbeddedPdbTreeTests
// the test SDK builds with portable + sidecar PDB.
var testDllPath = typeof(ICSharpCode.Decompiler.Metadata.MetadataFile).Assembly.Location;
var loaded = await vm.OpenAssemblyAsync(testDllPath);
+ TestCapture.Step("opened-decompiler-dll");
var assemblyNode = vm.AssemblyTreeModel.FindNode(loaded.ShortName);
// AssemblyTreeNode now surfaces two MetadataTreeNode children for assemblies with an
@@ -63,6 +64,7 @@ public class EmbeddedPdbTreeTests
var debugDirectoryNode = metadataNode.GetChild();
debugDirectoryNode.EnsureLazyChildren();
+ TestCapture.Step("debug-directory-expanded");
// The embedded-PDB sub-tree is a MetadataTreeNode whose own children include a
// MetadataTablesTreeNode listing the debug-only tables.
@@ -89,9 +91,11 @@ public class EmbeddedPdbTreeTests
var testDllPath = typeof(ICSharpCode.Decompiler.Metadata.MetadataFile).Assembly.Location;
var loaded = await vm.OpenAssemblyAsync(testDllPath);
+ TestCapture.Step("opened-decompiler-dll");
var assemblyNode = vm.AssemblyTreeModel.FindNode(loaded.ShortName);
assemblyNode.EnsureLazyChildren();
+ TestCapture.Step("assembly-node-expanded");
var topLevelMetadataNodes = assemblyNode.Children.OfType().ToList();
topLevelMetadataNodes.Should().HaveCount(2,
diff --git a/ILSpy.Tests/Metadata/HeapTreeTests.cs b/ILSpy.Tests/Metadata/HeapTreeTests.cs
index d23ee8d2a..3c66792c1 100644
--- a/ILSpy.Tests/Metadata/HeapTreeTests.cs
+++ b/ILSpy.Tests/Metadata/HeapTreeTests.cs
@@ -45,6 +45,7 @@ public class HeapTreeTests
var metadataNode = vm.AssemblyTreeModel.FindCoreLib().GetChild();
metadataNode.EnsureLazyChildren();
+ TestCapture.Step("metadata-node-expanded");
metadataNode.Children.OfType().Should().ContainSingle();
metadataNode.Children.OfType().Should().ContainSingle();
@@ -67,6 +68,7 @@ public class HeapTreeTests
vm.AssemblyTreeModel.SelectNode(heapNode);
var tab = await vm.DockWorkspace.WaitForMetadataTabAsync();
+ TestCapture.Step("string-heap-grid");
tab.Title.Should().Be("String Heap");
tab.Columns.Select(c => c.Tag).Should().Equal("Offset", "Length", "Value");
@@ -85,6 +87,7 @@ public class HeapTreeTests
vm.AssemblyTreeModel.SelectNode(heapNode);
var tab = await vm.DockWorkspace.WaitForMetadataTabAsync();
+ TestCapture.Step("guid-heap-grid");
tab.Title.Should().Be("Guid Heap");
tab.Columns.Select(c => c.Tag).Should().Equal("Index", "Length", "Value");
@@ -104,6 +107,7 @@ public class HeapTreeTests
vm.AssemblyTreeModel.SelectNode(heapNode);
var tab = await vm.DockWorkspace.WaitForMetadataTabAsync();
+ TestCapture.Step("blob-heap-grid");
tab.Title.Should().Be("Blob Heap");
tab.Columns.Select(c => c.Tag).Should().Equal("Offset", "Length", "Value");
diff --git a/ILSpy.Tests/Metadata/HideEmptyMetadataTablesTests.cs b/ILSpy.Tests/Metadata/HideEmptyMetadataTablesTests.cs
index f99505333..515374076 100644
--- a/ILSpy.Tests/Metadata/HideEmptyMetadataTablesTests.cs
+++ b/ILSpy.Tests/Metadata/HideEmptyMetadataTablesTests.cs
@@ -91,6 +91,7 @@ public class HideEmptyMetadataTablesTests
.GetChild()
.GetChild();
tables.EnsureLazyChildren();
+ TestCapture.Step("tables-tree-expanded");
metadata = assemblyNode.LoadedAssembly.GetMetadataFileOrNull()!.Metadata;
return tables;
}
diff --git a/ILSpy.Tests/Metadata/HideEmptyMetadataTablesUiBindingTests.cs b/ILSpy.Tests/Metadata/HideEmptyMetadataTablesUiBindingTests.cs
index f51193a00..1dbd21211 100644
--- a/ILSpy.Tests/Metadata/HideEmptyMetadataTablesUiBindingTests.cs
+++ b/ILSpy.Tests/Metadata/HideEmptyMetadataTablesUiBindingTests.cs
@@ -54,9 +54,11 @@ public class HideEmptyMetadataTablesUiBindingTests
// Disable via the same property the checkbox writes to.
settings.DisplaySettings.HideEmptyMetadataTables = false;
var withAll = CountVisibleTables(vm, coreLibName);
+ TestCapture.Step("hide-empty-off");
settings.DisplaySettings.HideEmptyMetadataTables = true;
var withoutEmpty = CountVisibleTables(vm, coreLibName);
+ TestCapture.Step("hide-empty-on");
withAll.Should().BeGreaterThan(withoutEmpty,
"flipping the Display-Settings flag must cause additional empty tables to surface in the tree");
diff --git a/ILSpy.Tests/Metadata/MetadataDisablesLanguageSwitchingTests.cs b/ILSpy.Tests/Metadata/MetadataDisablesLanguageSwitchingTests.cs
index 202c3022e..d6a4a4364 100644
--- a/ILSpy.Tests/Metadata/MetadataDisablesLanguageSwitchingTests.cs
+++ b/ILSpy.Tests/Metadata/MetadataDisablesLanguageSwitchingTests.cs
@@ -106,6 +106,7 @@ public class MetadataDisablesLanguageSwitchingTests
await Waiters.WaitForAsync(
() => vm.DockWorkspace.ActiveContentTabPage?.Content is MetadataTablePageModel,
System.TimeSpan.FromSeconds(10));
+ TestCapture.Step("metadata-tab-active");
languageCombo.IsEnabled.Should().BeFalse(
"with a MetadataTablePageModel as the active content, the LanguageComboBox must disable");
@@ -115,6 +116,7 @@ public class MetadataDisablesLanguageSwitchingTests
coreLibName, "System", "System.Object");
vm.AssemblyTreeModel.SelectNode(typeNode);
await vm.DockWorkspace.WaitForDecompiledTextAsync(System.TimeSpan.FromSeconds(30));
+ TestCapture.Step("decompiler-tab-active");
languageCombo.IsEnabled.Should().BeTrue(
"swapping back to a decompiler tab must re-enable the LanguageComboBox");
diff --git a/ILSpy.Tests/Metadata/MetadataFilterTests.cs b/ILSpy.Tests/Metadata/MetadataFilterTests.cs
index 837576e54..2dd354210 100644
--- a/ILSpy.Tests/Metadata/MetadataFilterTests.cs
+++ b/ILSpy.Tests/Metadata/MetadataFilterTests.cs
@@ -302,6 +302,7 @@ public class MetadataFilterTests
vm.AssemblyTreeModel.SelectNode(typeDefNode);
var tab = await vm.DockWorkspace.WaitForMetadataTabAsync();
+ TestCapture.Step("typedef-grid");
// The header layout swaps in the per-column TextBox only on hover or when the
// filter is active (the label and the input share the same slot so the header
@@ -342,6 +343,7 @@ public class MetadataFilterTests
// (TextProperty AvaloniaObject change → builder's PropertyChanged handler →
// ColumnFilter.Text → page.ColumnFilterChanged → view.Refresh()).
headerBox!.Text = "System";
+ TestCapture.Step("filtered-by-system");
nameFilter.Text.Should().Be("System",
"setting the rendered TextBox.Text must propagate to ColumnFilter.Text");
@@ -353,6 +355,7 @@ public class MetadataFilterTests
// Clearing through the rendered TextBox must restore every row.
headerBox.Text = "";
+ TestCapture.Step("filter-cleared");
nameFilter.Text.Should().BeEmpty();
view.Count.Should().Be(totalRows,
"clearing the filter must restore every row in the visible grid view");
diff --git a/ILSpy.Tests/Metadata/MetadataNodeIconParityTests.cs b/ILSpy.Tests/Metadata/MetadataNodeIconParityTests.cs
index 228777a37..7f11ca0fd 100644
--- a/ILSpy.Tests/Metadata/MetadataNodeIconParityTests.cs
+++ b/ILSpy.Tests/Metadata/MetadataNodeIconParityTests.cs
@@ -47,6 +47,7 @@ public class MetadataNodeIconParityTests
var metadataNode = vm.AssemblyTreeModel.FindCoreLib().GetChild();
metadataNode.EnsureLazyChildren();
+ TestCapture.Step("metadata-node-expanded");
var children = metadataNode.Children;
// Assert — root + PE header nodes.
diff --git a/ILSpy.Tests/Metadata/MetadataProtocolHandlerDrillDownTests.cs b/ILSpy.Tests/Metadata/MetadataProtocolHandlerDrillDownTests.cs
index 9a34c4c0a..cf813bc7c 100644
--- a/ILSpy.Tests/Metadata/MetadataProtocolHandlerDrillDownTests.cs
+++ b/ILSpy.Tests/Metadata/MetadataProtocolHandlerDrillDownTests.cs
@@ -122,6 +122,7 @@ public class MetadataProtocolHandlerDrillDownTests
// Drill-down needs the children realised; EnsureLazyChildren cascades only one level.
var tablesNode = metadataNode.GetChild();
tablesNode.EnsureLazyChildren();
+ TestCapture.Step("tables-tree-expanded");
return metadataNode;
}
diff --git a/ILSpy.Tests/Metadata/MetadataRowActivationTests.cs b/ILSpy.Tests/Metadata/MetadataRowActivationTests.cs
index a33aaa4ca..567935cd1 100644
--- a/ILSpy.Tests/Metadata/MetadataRowActivationTests.cs
+++ b/ILSpy.Tests/Metadata/MetadataRowActivationTests.cs
@@ -56,11 +56,13 @@ public class MetadataRowActivationTests
vm.AssemblyTreeModel.SelectNode(typeDefNode);
var tab = await vm.DockWorkspace.WaitForMetadataTabAsync();
+ TestCapture.Step("typedef-grid");
var objectRow = tab.Items.Cast()
.First(e => e.Name == "Object" && e.Namespace == "System");
tab.RaiseRowActivated(objectRow);
+ TestCapture.Step("activated-object-row");
await Waiters.WaitForAsync(
() => vm.AssemblyTreeModel.SelectedItem is TypeTreeNode tn
@@ -86,6 +88,7 @@ public class MetadataRowActivationTests
vm.AssemblyTreeModel.SelectNode(typeDefNode);
var metadataTab = await vm.DockWorkspace.WaitForMetadataTabAsync();
+ TestCapture.Step("typedef-grid");
var objectRow = metadataTab.Items.Cast()
.First(e => e.Name == "Object" && e.Namespace == "System");
@@ -97,6 +100,7 @@ public class MetadataRowActivationTests
await Waiters.WaitForAsync(
() => (documents.VisibleDockables?.Count ?? 0) > initialCount);
var decompiledTab = await vm.DockWorkspace.WaitForDecompiledTextAsync();
+ TestCapture.Step("new-decompiler-tab");
decompiledTab.Text.Should().Contain("System.Object");
}
diff --git a/ILSpy.Tests/Metadata/MetadataTabSessionRestoreTests.cs b/ILSpy.Tests/Metadata/MetadataTabSessionRestoreTests.cs
index dd00e4aba..71eea00b5 100644
--- a/ILSpy.Tests/Metadata/MetadataTabSessionRestoreTests.cs
+++ b/ILSpy.Tests/Metadata/MetadataTabSessionRestoreTests.cs
@@ -51,6 +51,7 @@ public class MetadataTabSessionRestoreTests
.GetChild();
vm.AssemblyTreeModel.SelectNode(typeDefNode);
+ TestCapture.Step("selected-typedef-table");
var settings = AppComposition.Current.GetExport().SessionSettings;
var savedPath = settings.ActiveTreeViewPath;
@@ -76,6 +77,7 @@ public class MetadataTabSessionRestoreTests
var testDllPath = typeof(ICSharpCode.Decompiler.Metadata.MetadataFile).Assembly.Location;
var loaded = await vm.OpenAssemblyAsync(testDllPath);
+ TestCapture.Step("opened-decompiler-dll");
var assemblyNode = vm.AssemblyTreeModel.FindNode(loaded.ShortName);
// AssemblyTreeNode surfaces the embedded PDB's metadata as a second top-level
@@ -89,6 +91,7 @@ public class MetadataTabSessionRestoreTests
.GetChild();
vm.AssemblyTreeModel.SelectNode(documentTable);
+ TestCapture.Step("selected-document-table");
var settings = AppComposition.Current.GetExport().SessionSettings;
var savedPath = settings.ActiveTreeViewPath;
diff --git a/ILSpy.Tests/Metadata/MetadataTablesTreeTests.cs b/ILSpy.Tests/Metadata/MetadataTablesTreeTests.cs
index 8dc5e302e..027b2d197 100644
--- a/ILSpy.Tests/Metadata/MetadataTablesTreeTests.cs
+++ b/ILSpy.Tests/Metadata/MetadataTablesTreeTests.cs
@@ -45,6 +45,7 @@ public class MetadataTablesTreeTests
var metadataNode = vm.AssemblyTreeModel.FindCoreLib().GetChild();
metadataNode.EnsureLazyChildren();
+ TestCapture.Step("metadata-node-expanded");
var tablesNode = metadataNode.Children.OfType().Single();
var children = metadataNode.Children.ToList();
@@ -64,6 +65,7 @@ public class MetadataTablesTreeTests
.GetChild()
.GetChild();
tablesNode.EnsureLazyChildren();
+ TestCapture.Step("tables-tree-expanded");
var tableChildren = tablesNode.Children.OfType().ToList();
// CoreLib has well over 10 non-empty tables (Module, TypeRef, TypeDef, Field, ...).
@@ -85,6 +87,7 @@ public class MetadataTablesTreeTests
vm.AssemblyTreeModel.SelectNode(tablesNode);
var tab = await vm.DockWorkspace.WaitForDecompiledTextAsync();
+ TestCapture.Step("tables-summary-text");
tab.Text.Should().Contain("Tables");
tab.Text.Should().MatchRegex(@"\bTypeDef\s*[:|]\s*\d+");
diff --git a/ILSpy.Tests/Metadata/MetadataTokenNavigationTests.cs b/ILSpy.Tests/Metadata/MetadataTokenNavigationTests.cs
index 59cbdace1..89641d4c0 100644
--- a/ILSpy.Tests/Metadata/MetadataTokenNavigationTests.cs
+++ b/ILSpy.Tests/Metadata/MetadataTokenNavigationTests.cs
@@ -52,6 +52,7 @@ public class MetadataTokenNavigationTests
vm.AssemblyTreeModel.SelectNode(typeDefNode);
var tab = await vm.DockWorkspace.WaitForMetadataTabAsync();
+ TestCapture.Step("typedef-grid");
// Pick a row whose BaseType is non-zero (skip ) and resolve the expected
// target table from the handle's runtime kind.
@@ -65,6 +66,7 @@ public class MetadataTokenNavigationTests
await Waiters.WaitForAsync(
() => vm.AssemblyTreeModel.SelectedItem is MetadataTableTreeNode m
&& m.Kind == expectedTableIndex);
+ TestCapture.Step("navigated-to-token-table");
// Assert — host swapped the tree selection + Content to the table matching the
// handle's kind. The actual scroll-into-view runs through MetadataTablePage's
@@ -94,6 +96,7 @@ public class MetadataTokenNavigationTests
vm.AssemblyTreeModel.SelectNode(typeDefNode);
var tab = await vm.DockWorkspace.WaitForMetadataTabAsync();
+ TestCapture.Step("typedef-grid");
var rowWithBase = tab.Items.Cast()
.First(e => e.BaseType != 0);
@@ -114,6 +117,7 @@ public class MetadataTokenNavigationTests
await Waiters.WaitForAsync(
() => vm.AssemblyTreeModel.SelectedItem is MetadataTableTreeNode m
&& m.Kind == expectedTableIndex);
+ TestCapture.Step("navigated-to-token-table");
}
}
diff --git a/ILSpy.Tests/Metadata/MetadataTreeShapeTests.cs b/ILSpy.Tests/Metadata/MetadataTreeShapeTests.cs
index 8ce187311..e0e0079ba 100644
--- a/ILSpy.Tests/Metadata/MetadataTreeShapeTests.cs
+++ b/ILSpy.Tests/Metadata/MetadataTreeShapeTests.cs
@@ -49,6 +49,7 @@ public class MetadataTreeShapeTests
// Assert — exactly one MetadataTreeNode child and it precedes the References folder.
var metadataNode = assemblyNode.GetChild();
var children = assemblyNode.Children.ToList();
+ TestCapture.Step("corelib-children");
children.IndexOf(metadataNode).Should().BeLessThan(
children.IndexOf(assemblyNode.Children.OfType().Single()),
"Metadata folder should sit above References, mirroring WPF AssemblyTreeNode ordering");
@@ -71,6 +72,7 @@ public class MetadataTreeShapeTests
// SelectedItem), wait for the decompile to finish.
vm.AssemblyTreeModel.SelectNode(metadataNode);
var tab = await vm.DockWorkspace.WaitForDecompiledTextAsync();
+ TestCapture.Step("metadata-summary-text");
// Assert — text contains the metadata kind line and at least one table row count.
tab.Text.Should().Contain("MetadataKind:");
diff --git a/ILSpy.Tests/Metadata/PEHeaderTreeTests.cs b/ILSpy.Tests/Metadata/PEHeaderTreeTests.cs
index 4c3cc4bc2..168979871 100644
--- a/ILSpy.Tests/Metadata/PEHeaderTreeTests.cs
+++ b/ILSpy.Tests/Metadata/PEHeaderTreeTests.cs
@@ -44,6 +44,7 @@ public class PEHeaderTreeTests
var metadataNode = vm.AssemblyTreeModel.FindCoreLib().GetChild();
metadataNode.EnsureLazyChildren();
+ TestCapture.Step("metadata-node-expanded");
metadataNode.Children.OfType().Should().ContainSingle();
metadataNode.Children.OfType().Should().ContainSingle();
@@ -76,6 +77,7 @@ public class PEHeaderTreeTests
vm.AssemblyTreeModel.SelectNode(dosNode);
var tab = await vm.DockWorkspace.WaitForMetadataTabAsync();
+ TestCapture.Step("dos-header-grid");
tab.Title.Should().Be("DOS Header");
tab.Items.Should().HaveCount(31);
@@ -108,14 +110,17 @@ public class PEHeaderTreeTests
// AssemblyDef metadata via the decompiler text path). Decompiler tab should be active.
vm.AssemblyTreeModel.SelectNode(assemblyNode);
await vm.DockWorkspace.WaitForDecompiledTextAsync();
+ TestCapture.Step("decompiler-tab");
// Step 2 — pick the DOS-header metadata node. Metadata tab takes the active slot.
vm.AssemblyTreeModel.SelectNode(dosNode);
await vm.DockWorkspace.WaitForMetadataTabAsync();
+ TestCapture.Step("dos-header-grid");
// Step 3 — back to the entity node. The decompiler tab must come back into view.
vm.AssemblyTreeModel.SelectNode(assemblyNode);
var decompilerTab = await vm.DockWorkspace.WaitForDecompiledTextAsync();
+ TestCapture.Step("decompiler-tab-restored");
decompilerTab.Should().NotBeNull();
var mainTab = ((global::ILSpy.Docking.ILSpyDockFactory)vm.DockWorkspace.Factory).MainTab!;
@@ -139,9 +144,11 @@ public class PEHeaderTreeTests
vm.AssemblyTreeModel.SelectNode(dosNode);
await vm.DockWorkspace.WaitForMetadataTabAsync();
+ TestCapture.Step("dos-header-grid");
vm.AssemblyTreeModel.SelectNode(assemblyNode);
await vm.DockWorkspace.WaitForDecompiledTextAsync();
+ TestCapture.Step("decompiler-tab");
var mainTab = ((global::ILSpy.Docking.ILSpyDockFactory)vm.DockWorkspace.Factory).MainTab!;
mainTab.Content.Should().BeOfType();
@@ -169,16 +176,19 @@ public class PEHeaderTreeTests
vm.AssemblyTreeModel.SelectNode(dosNode);
await vm.DockWorkspace.WaitForMetadataTabAsync();
+ TestCapture.Step("dos-header-grid");
documents.VisibleDockables!.Should().HaveCount(1).And.Contain(mainTab);
mainTab.Content.Should().BeOfType();
vm.AssemblyTreeModel.SelectNode(assemblyNode);
await vm.DockWorkspace.WaitForDecompiledTextAsync();
+ TestCapture.Step("decompiler-tab");
documents.VisibleDockables!.Should().HaveCount(1).And.Contain(mainTab);
mainTab.Content.Should().BeOfType();
vm.AssemblyTreeModel.SelectNode(coffNode);
await vm.DockWorkspace.WaitForMetadataTabAsync();
+ TestCapture.Step("coff-header-grid");
documents.VisibleDockables!.Should().HaveCount(1).And.Contain(mainTab);
mainTab.Content.Should().BeOfType();
@@ -205,15 +215,18 @@ public class PEHeaderTreeTests
vm.AssemblyTreeModel.SelectNode(dosNode);
var firstTab = await vm.DockWorkspace.WaitForMetadataTabAsync();
+ TestCapture.Step("dos-header-grid");
firstTab.Title.Should().Be("DOS Header");
vm.AssemblyTreeModel.SelectNode(coffNode);
var secondTab = await vm.DockWorkspace.WaitForMetadataTabAsync();
+ TestCapture.Step("coff-header-grid");
secondTab.Should().BeSameAs(firstTab, "metadata clicks reuse the existing grid tab");
secondTab.Title.Should().Be("COFF Header");
vm.AssemblyTreeModel.SelectNode(dosNode);
var thirdTab = await vm.DockWorkspace.WaitForMetadataTabAsync();
+ TestCapture.Step("dos-header-grid-again");
thirdTab.Should().BeSameAs(firstTab);
thirdTab.Title.Should().Be("DOS Header");
@@ -232,6 +245,7 @@ public class PEHeaderTreeTests
vm.AssemblyTreeModel.SelectNode(coffNode);
var tab = await vm.DockWorkspace.WaitForMetadataTabAsync();
+ TestCapture.Step("coff-header-grid");
tab.Title.Should().Be("COFF Header");
var members = tab.Items.Cast().Select(e => e.Member).ToList();
diff --git a/ILSpy.Tests/Metadata/ShowInMetadataContextMenuTests.cs b/ILSpy.Tests/Metadata/ShowInMetadataContextMenuTests.cs
index bfe503d28..340fa47d8 100644
--- a/ILSpy.Tests/Metadata/ShowInMetadataContextMenuTests.cs
+++ b/ILSpy.Tests/Metadata/ShowInMetadataContextMenuTests.cs
@@ -72,6 +72,7 @@ public class ShowInMetadataContextMenuTests
await Waiters.WaitForAsync(
() => vm.AssemblyTreeModel.SelectedItem is MetadataTableTreeNode m
&& m.Kind == expectedTable);
+ TestCapture.Step("show-in-metadata-table");
((MetadataTableTreeNode)vm.AssemblyTreeModel.SelectedItem!).Kind.Should().Be(expectedTable);
}
diff --git a/ILSpy.Tests/Metadata/TypedMetadataTableTreeTests.cs b/ILSpy.Tests/Metadata/TypedMetadataTableTreeTests.cs
index ca70ec5a4..3a95b9961 100644
--- a/ILSpy.Tests/Metadata/TypedMetadataTableTreeTests.cs
+++ b/ILSpy.Tests/Metadata/TypedMetadataTableTreeTests.cs
@@ -55,6 +55,7 @@ public class TypedMetadataTableTreeTests
vm.AssemblyTreeModel.SelectNode(assemblyRefNode);
var tab = await vm.DockWorkspace.WaitForMetadataTabAsync();
+ TestCapture.Step("assemblyref-grid");
tab.Title.Should().StartWith("AssemblyRef");
tab.Items.Should().HaveCount(assemblyRefNode.RowCount);
@@ -79,6 +80,7 @@ public class TypedMetadataTableTreeTests
vm.AssemblyTreeModel.SelectNode(typeDefNode);
var tab = await vm.DockWorkspace.WaitForMetadataTabAsync();
+ TestCapture.Step("typedef-grid");
tab.Title.Should().StartWith("TypeDef");
tab.Items.Cast()
@@ -97,6 +99,7 @@ public class TypedMetadataTableTreeTests
.GetChild()
.GetChild();
tablesNode.EnsureLazyChildren();
+ TestCapture.Step("tables-tree-expanded");
tablesNode.Children.OfType().Should().ContainSingle();
tablesNode.Children.OfType().Should().ContainSingle();
@@ -108,6 +111,7 @@ public class TypedMetadataTableTreeTests
var methodNode = tablesNode.Children.OfType().Single();
vm.AssemblyTreeModel.SelectNode(methodNode);
var tab = await vm.DockWorkspace.WaitForMetadataTabAsync();
+ TestCapture.Step("methoddef-grid");
tab.Title.Should().StartWith("MethodDef");
tab.Columns.Select(c => c.Tag).Should().Contain("RID");
}
@@ -126,6 +130,7 @@ public class TypedMetadataTableTreeTests
vm.AssemblyTreeModel.SelectNode(moduleNode);
var tab = await vm.DockWorkspace.WaitForMetadataTabAsync();
+ TestCapture.Step("module-grid");
tab.Title.Should().StartWith("Module");
tab.Items.Should().HaveCount(1);
diff --git a/ILSpy.Tests/Navigation/BrowseBackForwardCommandTests.cs b/ILSpy.Tests/Navigation/BrowseBackForwardCommandTests.cs
index 178e5d44d..cee572550 100644
--- a/ILSpy.Tests/Navigation/BrowseBackForwardCommandTests.cs
+++ b/ILSpy.Tests/Navigation/BrowseBackForwardCommandTests.cs
@@ -104,13 +104,16 @@ public class BrowseBackForwardCommandTests
// Build history: select two methods with a delay so they record as separate entries.
vm.AssemblyTreeModel.SelectNode(firstMethod);
await vm.DockWorkspace.WaitForDecompiledTextAsync();
+ TestCapture.Step("first-method-decompiled");
await Task.Delay(600);
vm.AssemblyTreeModel.SelectNode(secondMethod);
await vm.DockWorkspace.WaitForDecompiledTextAsync();
+ TestCapture.Step("second-method-decompiled");
// Act — fire the menu command (mirrors clicking View → Back).
backItem.Command.CanExecute(null).Should().BeTrue();
backItem.Command.Execute(null);
+ TestCapture.Step("navigated-back");
// Assert — selection rewinds to the first method (NavigateBack walked one step).
await Waiters.WaitForAsync(() => ReferenceEquals(vm.AssemblyTreeModel.SelectedItem, firstMethod));
diff --git a/ILSpy.Tests/Navigation/NavigationTests.cs b/ILSpy.Tests/Navigation/NavigationTests.cs
index 17b19af2a..2dae4f85c 100644
--- a/ILSpy.Tests/Navigation/NavigationTests.cs
+++ b/ILSpy.Tests/Navigation/NavigationTests.cs
@@ -57,6 +57,7 @@ public class NavigationTests
vm.AssemblyTreeModel.SelectNode(firstMethod);
var firstTab = await vm.DockWorkspace.WaitForDecompiledTextAsync();
firstTab.Text.Should().Contain("AsEnumerable");
+ TestCapture.Step("as-enumerable-decompiled");
// NavigationHistory collapses selections that happen within 0.5s into one entry. Wait
// past that window so the second selection records a real back-history entry.
@@ -67,11 +68,13 @@ public class NavigationTests
await Waiters.WaitForAsync(() => ReferenceEquals(vm.AssemblyTreeModel.SelectedItem, secondMethod));
var secondTab = await vm.DockWorkspace.WaitForDecompiledTextAsync();
secondTab.Text.Should().Contain("Empty");
+ TestCapture.Step("empty-decompiled");
// Act 3 — fire NavigateBack.
vm.DockWorkspace.NavigateBackCommand.CanExecute(null).Should().BeTrue();
// execute vm.DockWorkspace.NavigateBackCommand
vm.DockWorkspace.NavigateBackCommand.Execute(null);
+ TestCapture.Step("navigated-back");
// Assert — selection restores to AsEnumerable, the document re-decompiles to its body,
// and the row is centred back into view.
@@ -114,6 +117,7 @@ public class NavigationTests
await Task.Delay(600);
vm.AssemblyTreeModel.SelectNode(methodC);
await vm.DockWorkspace.WaitForDecompiledTextAsync();
+ TestCapture.Step("range-decompiled");
// Act 2 — open the Back SplitButton's flyout. The Opening handler populates the menu
// from the current back history.
@@ -123,6 +127,7 @@ public class NavigationTests
// open flyout on backSplit
flyout.ShowAt(backSplit);
await Waiters.WaitForAsync(() => flyout.Items.OfType().Count() >= 2);
+ TestCapture.Step("back-history-flyout-open");
// Assert 1 — newest-first ordering: index 0 is the immediate previous selection
// (methodB), index 1 is the one before that (methodA). Each menu item carries a
@@ -141,6 +146,7 @@ public class NavigationTests
// Act 3 — multi-step jump: clicking methodA pops two entries off the back stack in one go.
items[1].Command!.Execute(items[1].CommandParameter);
await Waiters.WaitForAsync(() => ReferenceEquals(vm.AssemblyTreeModel.SelectedItem, methodA));
+ TestCapture.Step("jumped-to-as-enumerable");
// Assert 2 — the two displaced entries (methodC, methodB) are now on the forward stack,
// so Forward becomes available.
@@ -173,6 +179,7 @@ public class NavigationTests
await Task.Delay(600);
vm.AssemblyTreeModel.SelectNode(typeNode);
await vm.DockWorkspace.WaitForDecompiledTextAsync();
+ TestCapture.Step("exception-type-decompiled");
// Open the back-history flyout and read the entry header.
var backSplit = window.GetVisualDescendants().OfType()
@@ -180,6 +187,7 @@ public class NavigationTests
var flyout = (MenuFlyout)backSplit.Flyout!;
flyout.ShowAt(backSplit);
await Waiters.WaitForAsync(() => flyout.Items.OfType().Any());
+ TestCapture.Step("back-history-flyout-open");
// Assert — the entry shows the richer "Base Types (System.Exception)" form, NOT the
// bare "Base Types" Text.
diff --git a/ILSpy.Tests/Navigation/ViewStateRoundTripTests.cs b/ILSpy.Tests/Navigation/ViewStateRoundTripTests.cs
index 8b8d7b731..d3721ebef 100644
--- a/ILSpy.Tests/Navigation/ViewStateRoundTripTests.cs
+++ b/ILSpy.Tests/Navigation/ViewStateRoundTripTests.cs
@@ -57,6 +57,7 @@ public class ViewStateRoundTripTests
vm.AssemblyTreeModel.SelectNode(objectNode);
await dockWorkspace.WaitForDecompiledTextAsync();
var tab = dockWorkspace.ActiveDecompilerTab!;
+ TestCapture.Step("object-decompiled");
// State is pulled from the editor on demand (CaptureViewState) when DockWorkspace records
// a navigation away -- not pushed per caret/scroll event. Headless has no laid-out editor,
@@ -67,6 +68,7 @@ public class ViewStateRoundTripTests
// entry and records B as the new current.
vm.AssemblyTreeModel.SelectNode(stringNode);
await dockWorkspace.WaitForDecompiledTextAsync();
+ TestCapture.Step("string-decompiled");
// Verify the capture: the back stack's most recent entry should be A's, with the pulled
// caret + scroll values.
@@ -85,6 +87,7 @@ public class ViewStateRoundTripTests
// view consume it -- applying caret/scroll to the live editor is the view's job (covered by
// the real UI; headless has no rendered viewport).
dockWorkspace.NavigateBackCommand.Execute(null);
+ TestCapture.Step("navigated-back");
tab.PendingViewState.Should().NotBeNull("Back must stash the recorded view state for the editor to apply");
tab.PendingViewState!.Value.CaretOffset.Should().Be(500,
"Back must restore the caret to where the user left it before navigating to B");
@@ -115,6 +118,7 @@ public class ViewStateRoundTripTests
vm.AssemblyTreeModel.SelectNode(objectNode);
await dockWorkspace.WaitForDecompiledTextAsync();
var tab = dockWorkspace.ActiveDecompilerTab!;
+ TestCapture.Step("object-decompiled");
// Deterministic foldings snapshot — two expanded regions over a four-folding layout.
// Compute via the helper to keep the checksum honest. (The snapshot itself is exercised
@@ -131,6 +135,7 @@ public class ViewStateRoundTripTests
// onto the OUTGOING entry on the back stack.
vm.AssemblyTreeModel.SelectNode(stringNode);
await dockWorkspace.WaitForDecompiledTextAsync();
+ TestCapture.Step("string-decompiled");
// Verify the capture: the back-stack entry for node A carries the snapshot.
var captured = dockWorkspace.BackHistory.OfType().Last();
@@ -144,6 +149,7 @@ public class ViewStateRoundTripTests
// for the view to consume on the next ApplyDocument. The assignment is synchronous, so we
// can observe it before the await lets the editor consume it.
dockWorkspace.NavigateBackCommand.Execute(null);
+ TestCapture.Step("navigated-back");
tab.PendingViewState.Should().NotBeNull("Back must propagate the captured state to the destination tab");
tab.PendingViewState!.Value.Foldings.Should().NotBeNull();
tab.PendingViewState!.Value.Foldings!.Value.Checksum.Should().Be(seeded.Checksum);
@@ -169,14 +175,17 @@ public class ViewStateRoundTripTests
await vm.DockWorkspace.WaitForDecompiledTextAsync();
vm.AssemblyTreeModel.SelectNode(stringNode);
await vm.DockWorkspace.WaitForDecompiledTextAsync();
+ TestCapture.Step("string-decompiled");
vm.DockWorkspace.NavigateBackCommand.Execute(null);
await vm.DockWorkspace.WaitForDecompiledTextAsync();
+ TestCapture.Step("navigated-back");
vm.DockWorkspace.NavigateForwardCommand.CanExecute(null).Should().BeTrue(
"Forward must be enabled after Back leaves an entry on the forward stack");
vm.DockWorkspace.NavigateForwardCommand.Execute(null);
await vm.DockWorkspace.WaitForDecompiledTextAsync();
+ TestCapture.Step("navigated-forward");
// Back through Forward should land on B again.
ReferenceEquals(vm.AssemblyTreeModel.SelectedItem, stringNode).Should().BeTrue(
diff --git a/ILSpy.Tests/Options/ApiVisibilityFilterTests.cs b/ILSpy.Tests/Options/ApiVisibilityFilterTests.cs
index 1baa83a20..c39103bec 100644
--- a/ILSpy.Tests/Options/ApiVisibilityFilterTests.cs
+++ b/ILSpy.Tests/Options/ApiVisibilityFilterTests.cs
@@ -57,6 +57,7 @@ public class ApiVisibilityFilterTests
var (vm, enumerableNode) = await BootAndExpandEnumerableAsync();
var publicMethod = enumerableNode.Children.OfType()
.Single(m => m.MethodDefinition.Name == "Empty");
+ TestCapture.Step("before-public-api-checks");
// Act + Assert — Empty is public static.
publicMethod.IsPublicAPI.Should().BeTrue("Enumerable.Empty is public");
@@ -159,6 +160,7 @@ public class ApiVisibilityFilterTests
var stringType = vm.AssemblyTreeModel.FindNode(coreLibName, "System", "System.String");
stringType.IsExpanded = true;
await Waiters.WaitForAsync(() => stringType.Children.OfType().Any());
+ TestCapture.Step("string-type-expanded");
var settings = AppComposition.Current.GetExport().SessionSettings.LanguageSettings;
settings.ShowApiLevel = ApiVisibility.All;
@@ -167,6 +169,7 @@ public class ApiVisibilityFilterTests
// Act — switch to PublicOnly.
settings.ShowApiLevel = ApiVisibility.PublicOnly;
var publicCount = CountVisibleMethods(stringType, settings);
+ TestCapture.Step("api-visibility-public-only");
// Assert — strictly fewer methods at PublicOnly than at All (String has internal/private
// helpers we expect to disappear).
@@ -199,6 +202,7 @@ public class ApiVisibilityFilterTests
settings.ShowApiLevel = settings.ShowApiLevel == ApiVisibility.PublicOnly
? ApiVisibility.All
: ApiVisibility.PublicOnly;
+ TestCapture.Step("api-visibility-flipped");
// Assert — model reference changed, indicating BindTree ran again with new filter state.
grid.HierarchicalModel.Should().NotBeSameAs(modelBefore,
@@ -222,6 +226,7 @@ public class ApiVisibilityFilterTests
var stringType = vm.AssemblyTreeModel.FindNode(coreLibName, "System", "System.String");
stringType.IsExpanded = true;
await Waiters.WaitForAsync(() => stringType.Children.OfType().Any());
+ TestCapture.Step("string-type-expanded");
var publicMethod = stringType.Children.OfType().First(m => m.IsPublicAPI);
var nonPublicMethod = stringType.Children.OfType().First(m => !m.IsPublicAPI);
@@ -238,6 +243,7 @@ public class ApiVisibilityFilterTests
vm.AssemblyTreeModel.SelectNode(nonPublicMethod);
await Waiters.WaitForAsync(() => FindRowTextBlock(grid, (string)nonPublicMethod.Text!) != null);
var nonPublicLabel = FindRowTextBlock(grid, (string)nonPublicMethod.Text!);
+ TestCapture.Step("non-public-method-selected");
// Assert — non-public row's TextBlock carries the class; public row does not.
publicLabel.Should().NotBeNull();
@@ -267,6 +273,7 @@ public class ApiVisibilityFilterTests
// Arrange — boot.
var window = AppComposition.Current.GetExport();
window.Show();
+ TestCapture.Step("booted");
var toggles = window.GetVisualDescendants().OfType()
.Where(t => t.Name is "ShowPublicOnlyButton" or "ShowPrivateInternalButton" or "ShowAllButton")
.ToDictionary(t => t.Name!);
@@ -281,6 +288,7 @@ public class ApiVisibilityFilterTests
toggles["ShowPrivateInternalButton"].IsChecked.Should().BeTrue("baseline before click");
toggles["ShowPublicOnlyButton"].IsChecked = true;
+ TestCapture.Step("show-public-only-checked");
settings.ShowApiLevel.Should().Be(ApiVisibility.PublicOnly);
toggles["ShowPrivateInternalButton"].IsChecked.Should().BeFalse(
"flipping ShowPublicOnly must propagate-cancel ShowPrivateInternal via OnPropertyChanged");
@@ -288,6 +296,7 @@ public class ApiVisibilityFilterTests
// Act + Assert — same but flipping the All button.
toggles["ShowAllButton"].IsChecked = true;
+ TestCapture.Step("show-all-checked");
settings.ShowApiLevel.Should().Be(ApiVisibility.All);
toggles["ShowPublicOnlyButton"].IsChecked.Should().BeFalse();
toggles["ShowPrivateInternalButton"].IsChecked.Should().BeFalse();
diff --git a/ILSpy.Tests/Options/OptionsPageScrollReachTests.cs b/ILSpy.Tests/Options/OptionsPageScrollReachTests.cs
index eee17488c..e94261724 100644
--- a/ILSpy.Tests/Options/OptionsPageScrollReachTests.cs
+++ b/ILSpy.Tests/Options/OptionsPageScrollReachTests.cs
@@ -57,11 +57,13 @@ public class OptionsPageScrollReachTests
window.Height = 600;
window.Show();
Dispatcher.UIThread.RunJobs();
+ TestCapture.Step("booted");
var command = AppComposition.Current.GetExport()
.GetCommand(nameof(Resources._Options));
command.Execute(null);
Dispatcher.UIThread.RunJobs();
+ TestCapture.Step("options-opened");
var view = window.GetVisualDescendants().OfType().Single();
var model = (OptionsPageModel)((ContentTabPage)((MainWindowViewModel)window.DataContext!)
@@ -69,6 +71,7 @@ public class OptionsPageScrollReachTests
.OfType().Single(t => t.Content is OptionsPageModel)).Content!;
model.SelectedPage = model.Pages.OfType().Single();
Dispatcher.UIThread.RunJobs();
+ TestCapture.Step("display-page-selected");
// Each panel now declares its own ScrollViewer (so per-tab scroll offset is
// independent), so the visual tree may contain multiple ScrollViewer instances
@@ -77,6 +80,7 @@ public class OptionsPageScrollReachTests
.First(sv => sv.IsEffectivelyVisible && sv.Bounds.Height > 0);
scrollViewer.Offset = new Vector(0, scrollViewer.Extent.Height);
Dispatcher.UIThread.RunJobs();
+ TestCapture.Step("scrolled-to-max");
// The "Sort results by fitness" checkbox sits inside the last HeaderedContentControl
// of the Display panel. After scrolling to max, its rendered bottom edge in
diff --git a/ILSpy.Tests/Options/OptionsTabTests.cs b/ILSpy.Tests/Options/OptionsTabTests.cs
index 7527abb3e..b1b337a01 100644
--- a/ILSpy.Tests/Options/OptionsTabTests.cs
+++ b/ILSpy.Tests/Options/OptionsTabTests.cs
@@ -70,6 +70,7 @@ public class OptionsTabTests
.GetCommand(nameof(Resources._Options));
command.Execute(null);
+ TestCapture.Step("options-tab-opened");
var docs = vm.DockWorkspace.Documents?.VisibleDockables;
((object?)docs).Should().NotBeNull();
@@ -95,6 +96,7 @@ public class OptionsTabTests
// Open Options and confirm it's the active document.
AppComposition.Current.GetExport()
.GetCommand(nameof(Resources._Options)).Execute(null);
+ TestCapture.Step("options-active");
var documents = vm.DockWorkspace.Documents!;
var optionsTab = documents.VisibleDockables!.OfType()
@@ -106,6 +108,7 @@ public class OptionsTabTests
var typeNode = vm.AssemblyTreeModel.FindNode(
"System.Linq", "System.Linq", "System.Linq.Enumerable");
vm.AssemblyTreeModel.SelectNode(typeNode);
+ TestCapture.Step("tree-node-selected");
// MainTab now carries the new content. Without the fix, ActiveDockable still
// points at Options and the user sees nothing change.
@@ -129,6 +132,7 @@ public class OptionsTabTests
window.Show();
AppComposition.Current.GetExport()
.GetCommand(nameof(Resources._Options)).Execute(null);
+ TestCapture.Step("options-opened");
var vm = (MainWindowViewModel)window.DataContext!;
var model = (OptionsPageModel)vm.DockWorkspace.Documents!.VisibleDockables!
@@ -153,6 +157,7 @@ public class OptionsTabTests
var command = AppComposition.Current.GetExport()
.GetCommand(nameof(Resources._Options));
command.Execute(null);
+ TestCapture.Step("options-opened");
var vm = (MainWindowViewModel)window.DataContext!;
var model = (OptionsPageModel)vm.DockWorkspace.Documents!.VisibleDockables!
@@ -177,6 +182,7 @@ public class OptionsTabTests
command.Execute(null);
command.Execute(null);
+ TestCapture.Step("options-reinvoked");
var optionsTabs = vm.DockWorkspace.Documents!.VisibleDockables!
.OfType().Where(t => t.Content is OptionsPageModel).ToList();
@@ -199,6 +205,7 @@ public class OptionsTabTests
AppComposition.Current.GetExport()
.GetCommand(nameof(Resources._Options)).Execute(null);
+ TestCapture.Step("options-opened");
var vm = (MainWindowViewModel)window.DataContext!;
var model = (OptionsPageModel)vm.DockWorkspace.Documents!.VisibleDockables!
@@ -210,6 +217,7 @@ public class OptionsTabTests
.SelectMany(g => g.Settings)
.First(s => s.Property.Name == nameof(global::ICSharpCode.Decompiler.DecompilerSettings.UsingDeclarations));
usingDecl.IsEnabled = !liveBefore;
+ TestCapture.Step("using-declarations-toggled");
// Live service must see the change immediately — no Apply needed.
settings.DecompilerSettings.UsingDeclarations.Should().Be(!liveBefore);
@@ -234,6 +242,7 @@ public class OptionsTabTests
.OfType().First(t => t.Content is OptionsPageModel).Content!;
var decompilerPage = (DecompilerSettingsViewModel)model.Pages[0];
model.SelectedPage = decompilerPage;
+ TestCapture.Step("decompiler-page-selected");
// Flip a known-default-true setting to false.
var item = decompilerPage.Settings
@@ -243,8 +252,10 @@ public class OptionsTabTests
item.IsEnabled = !defaultValue;
// Sanity: precondition flip took effect.
item.IsEnabled.Should().Be(!defaultValue);
+ TestCapture.Step("setting-flipped");
model.ResetCurrentPageCommand.Execute(null);
+ TestCapture.Step("page-reset");
// After reset, the reflection-rebuilt item list has the snapshot's defaults.
var refreshedItem = decompilerPage.Settings
@@ -266,6 +277,7 @@ public class OptionsTabTests
var settings = AppComposition.Current.GetExport();
var vm = (MainWindowViewModel)window.DataContext!;
await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 1);
+ TestCapture.Step("booted");
// Materialise a DecompilerTextView by selecting a node.
var typeNode = vm.AssemblyTreeModel.FindNode(
@@ -273,12 +285,14 @@ public class OptionsTabTests
vm.AssemblyTreeModel.SelectNode(typeNode);
var view = await window.WaitForComponent();
var editor = await view.WaitForComponent();
+ TestCapture.Step("enumerable-decompiled");
var originalSize = settings.DisplaySettings.SelectedFontSize;
var newSize = originalSize + 5;
// Act — change the live setting; subscriber should write through to Editor.FontSize.
settings.DisplaySettings.SelectedFontSize = newSize;
+ TestCapture.Step("font-size-increased");
editor.FontSize.Should().Be(newSize);
diff --git a/ILSpy.Tests/Search/ScopeSearchToTests.cs b/ILSpy.Tests/Search/ScopeSearchToTests.cs
index 6fe26966d..c5375f25e 100644
--- a/ILSpy.Tests/Search/ScopeSearchToTests.cs
+++ b/ILSpy.Tests/Search/ScopeSearchToTests.cs
@@ -55,6 +55,7 @@ public class ScopeSearchToTests
entry.IsVisible(new TextViewContext { SelectedTreeNodes = new SharpTreeNode[] { asm } })
.Should().BeTrue("the entry must surface on AssemblyTreeNode selections");
entry.Execute(new TextViewContext { SelectedTreeNodes = new SharpTreeNode[] { asm } });
+ TestCapture.Step("scoped-to-assembly");
search.SearchTerm.Should().Contain("inassembly:",
"Execute must inject the inassembly: prefix into the live search term");
@@ -78,6 +79,7 @@ public class ScopeSearchToTests
entry.IsVisible(new TextViewContext { SelectedTreeNodes = new SharpTreeNode[] { ns } })
.Should().BeTrue("the entry must surface on NamespaceTreeNode selections");
entry.Execute(new TextViewContext { SelectedTreeNodes = new SharpTreeNode[] { ns } });
+ TestCapture.Step("scoped-to-namespace");
search.SearchTerm.Should().Contain("innamespace:");
search.SearchTerm.Should().Contain("System.Linq");
diff --git a/ILSpy.Tests/Search/SearchInputFocusTests.cs b/ILSpy.Tests/Search/SearchInputFocusTests.cs
index d11f137de..35ac99a4b 100644
--- a/ILSpy.Tests/Search/SearchInputFocusTests.cs
+++ b/ILSpy.Tests/Search/SearchInputFocusTests.cs
@@ -53,6 +53,7 @@ public class SearchInputFocusTests
search.FocusRequested += () => fired++;
dockWorkspace.ShowSearchCommand.Execute(null);
+ TestCapture.Step("show-search-executed");
fired.Should().BeGreaterThan(0,
"ShowSearchCommand must request focus on the search input, not just activate the pane");
@@ -71,6 +72,7 @@ public class SearchInputFocusTests
// Focus shifts via a Dispatcher.UIThread.Post so the view has a frame for the
// pane to surface in the layout — pump the dispatcher before asserting.
Dispatcher.UIThread.RunJobs();
+ TestCapture.Step("search-input-focused");
var input = pane.FindControl("SearchInput");
((object?)input).Should().NotBeNull();
diff --git a/ILSpy.Tests/Search/SearchPaneAssemblyListChangedTests.cs b/ILSpy.Tests/Search/SearchPaneAssemblyListChangedTests.cs
index ea0a89ecc..61a5a974d 100644
--- a/ILSpy.Tests/Search/SearchPaneAssemblyListChangedTests.cs
+++ b/ILSpy.Tests/Search/SearchPaneAssemblyListChangedTests.cs
@@ -68,6 +68,7 @@ public class SearchPaneAssemblyListChangedTests
MessageBus.Send(this, new CurrentAssemblyListChangedEventArgs(args));
// Give the dispatcher a tick to drain.
await Waiters.WaitForAsync(() => true, System.TimeSpan.FromMilliseconds(50));
+ TestCapture.Step("search-restarted-on-user-load");
// If we got here without throwing, the handler ran. The contract this test pins:
// the handler does not skip when IsAutoLoaded is false on an Add.
@@ -93,6 +94,7 @@ public class SearchPaneAssemblyListChangedTests
// (A real regression would manifest as the search-pane endlessly restarting on
// every auto-load event.)
var act = () => MessageBus.Send(this, new CurrentAssemblyListChangedEventArgs(args));
+ TestCapture.Step("before-autoloaded-add");
act.Should().NotThrow();
}
}
diff --git a/ILSpy.Tests/Search/SearchPaneStreamingTests.cs b/ILSpy.Tests/Search/SearchPaneStreamingTests.cs
index f80be5820..26b056926 100644
--- a/ILSpy.Tests/Search/SearchPaneStreamingTests.cs
+++ b/ILSpy.Tests/Search/SearchPaneStreamingTests.cs
@@ -67,6 +67,7 @@ public class SearchPaneStreamingTests
ICSharpCode.ILSpyX.Search.MemberSearchKind.Type);
strategy.Search(module, default);
+ TestCapture.Step("before-strategy-results");
queue.Count.Should().BeGreaterThan(0,
"if the strategy can't find Enumerable in the fixture, the orchestrator certainly won't either");
}
@@ -89,6 +90,7 @@ public class SearchPaneStreamingTests
() => search.Results.Any(r => r.Name.Contains("Enumerable", StringComparison.OrdinalIgnoreCase)),
timeout: TimeSpan.FromSeconds(30));
+ TestCapture.Step("search-results-for-enumerable");
search.Results.Should().NotBeEmpty(
"the Type search strategy must have surfaced at least one match within the timeout");
search.Results.Should().Contain(r => r.Name.Contains("Enumerable", StringComparison.OrdinalIgnoreCase));
@@ -108,9 +110,11 @@ public class SearchPaneStreamingTests
search.SearchTerm = "Enumerable";
await Waiters.WaitForAsync(() => search.Results.Count > 0, timeout: TimeSpan.FromSeconds(30));
+ TestCapture.Step("search-results-populated");
search.SearchTerm = string.Empty;
await Waiters.WaitForAsync(() => search.Results.Count == 0, timeout: TimeSpan.FromSeconds(5));
+ TestCapture.Step("results-cleared");
search.Results.Should().BeEmpty("an empty search term must clear stale results");
}
@@ -132,6 +136,7 @@ public class SearchPaneStreamingTests
() => search.Results.Any(r => r.Name.Contains("String", StringComparison.OrdinalIgnoreCase)),
timeout: TimeSpan.FromSeconds(30));
+ TestCapture.Step("string-minus-builder-results");
search.Results.Should().NotBeEmpty();
search.Results.Should().OnlyContain(
r => r.Name.Contains("String", StringComparison.OrdinalIgnoreCase)
@@ -157,6 +162,7 @@ public class SearchPaneStreamingTests
() => search.Results.Any(r => r.Name.Equals("String", StringComparison.OrdinalIgnoreCase)),
timeout: TimeSpan.FromSeconds(30));
+ TestCapture.Step("exact-string-results");
search.Results.Should().Contain(r => r.Name.Equals("String", StringComparison.OrdinalIgnoreCase));
search.Results.Should().NotContain(
r => r.Name.Contains("Builder", StringComparison.OrdinalIgnoreCase),
diff --git a/ILSpy.Tests/Search/SearchPaneViewTests.cs b/ILSpy.Tests/Search/SearchPaneViewTests.cs
index 024c63164..d8a0067a8 100644
--- a/ILSpy.Tests/Search/SearchPaneViewTests.cs
+++ b/ILSpy.Tests/Search/SearchPaneViewTests.cs
@@ -46,6 +46,7 @@ public class SearchPaneViewTests
var window = AppComposition.Current.GetExport();
window.Show();
var pane = await window.WaitForComponent();
+ TestCapture.Step("booted");
var input = pane.FindControl("SearchInput");
((object?)input).Should().NotBeNull("the query TextBox is what the user types into");
@@ -65,10 +66,12 @@ public class SearchPaneViewTests
var window = AppComposition.Current.GetExport();
window.Show();
var pane = await window.WaitForComponent();
+ TestCapture.Step("booted");
var vm = (SearchPaneModel)pane.DataContext!;
var input = pane.FindControl("SearchInput");
input!.Text = "Enumerable";
+ TestCapture.Step("typed-enumerable");
vm.SearchTerm.Should().Be("Enumerable",
"the TextBox is bound two-way to SearchPaneModel.SearchTerm");
@@ -85,6 +88,7 @@ public class SearchPaneViewTests
var window = AppComposition.Current.GetExport();
window.Show();
var pane = await window.WaitForComponent();
+ TestCapture.Step("booted");
var vm = (SearchPaneModel)pane.DataContext!;
var input = pane.FindControl("SearchInput");
@@ -98,6 +102,7 @@ public class SearchPaneViewTests
RoutedEvent = global::Avalonia.Input.InputElement.KeyDownEvent,
Source = input,
});
+ TestCapture.Step("escape-cleared-term");
vm.SearchTerm.Should().BeEmpty("Escape in the search input must clear the term");
}
diff --git a/ILSpy.Tests/Search/SearchProgressTests.cs b/ILSpy.Tests/Search/SearchProgressTests.cs
index c567cde45..0b43e3885 100644
--- a/ILSpy.Tests/Search/SearchProgressTests.cs
+++ b/ILSpy.Tests/Search/SearchProgressTests.cs
@@ -69,6 +69,7 @@ public class SearchProgressTests
"the spinner must light up while the orchestrator is running");
await Waiters.WaitForAsync(() => !search.IsSearching, timeout: TimeSpan.FromSeconds(30));
+ TestCapture.Step("search-completed");
search.IsSearching.Should().BeFalse("the spinner must turn off once the run completes");
}
finally
@@ -90,6 +91,7 @@ public class SearchProgressTests
await Waiters.WaitForAsync(() => search.Results.Any(), timeout: TimeSpan.FromSeconds(30));
+ TestCapture.Step("search-results-with-icons");
var first = search.Results.First();
((object?)first.Image).Should().NotBeNull(
"every search result needs a glyph in the Name column");
@@ -105,6 +107,7 @@ public class SearchProgressTests
var window = AppComposition.Current.GetExport();
window.Show();
var pane = await window.WaitForComponent();
+ TestCapture.Step("booted");
var progress = pane.FindControl("SearchProgress");
((object?)progress).Should().NotBeNull(
diff --git a/ILSpy.Tests/Search/SearchResultNavigationTests.cs b/ILSpy.Tests/Search/SearchResultNavigationTests.cs
index 6ebd86706..4f5375562 100644
--- a/ILSpy.Tests/Search/SearchResultNavigationTests.cs
+++ b/ILSpy.Tests/Search/SearchResultNavigationTests.cs
@@ -55,10 +55,12 @@ public class SearchResultNavigationTests
await Waiters.WaitForAsync(() => search.Results.Any(r => r is MemberSearchResult),
timeout: TimeSpan.FromSeconds(30));
+ TestCapture.Step("search-results-for-enumerable");
var hit = (MemberSearchResult)search.Results.First(r => r is MemberSearchResult);
search.Activate(hit);
await Waiters.WaitForAsync(() => vm.AssemblyTreeModel.SelectedItem is TypeTreeNode);
+ TestCapture.Step("navigated-to-result");
((object?)vm.AssemblyTreeModel.SelectedItem).Should().NotBeNull(
"Activate must move the assembly-tree selection to the result's underlying entity");
(vm.AssemblyTreeModel.SelectedItem is TypeTreeNode).Should().BeTrue(
diff --git a/ILSpy.Tests/Search/SearchResultSortOrderTests.cs b/ILSpy.Tests/Search/SearchResultSortOrderTests.cs
index e589c126c..da056567d 100644
--- a/ILSpy.Tests/Search/SearchResultSortOrderTests.cs
+++ b/ILSpy.Tests/Search/SearchResultSortOrderTests.cs
@@ -65,6 +65,7 @@ public class SearchResultSortOrderTests
() => !search.IsSearching && search.Results.Count >= 2,
timeout: TimeSpan.FromSeconds(30));
+ TestCapture.Step("search-results-name-sorted");
// Drop assembly/namespace results — they share a unit Fitness with all peers in
// their bucket, so any tie-break is ambiguous between fitness- and name-sort.
// Member results are where Fitness varies (1/Name.Length), so their order is
diff --git a/ILSpy.Tests/Search/ShowSearchCommandTests.cs b/ILSpy.Tests/Search/ShowSearchCommandTests.cs
index 5db6884e0..7bc0d4b1d 100644
--- a/ILSpy.Tests/Search/ShowSearchCommandTests.cs
+++ b/ILSpy.Tests/Search/ShowSearchCommandTests.cs
@@ -49,6 +49,7 @@ public class ShowSearchCommandTests
dockWorkspace.ShowSearchCommand.CanExecute(null).Should().BeTrue();
dockWorkspace.ShowSearchCommand.Execute(null);
+ TestCapture.Step("search-pane-activated");
// The dock factory's ActiveDockable should now be the search pane. We can't
// assert against the factory's deep state easily (IDockable identity), so check
diff --git a/ILSpy.Tests/TestAppBuilder.cs b/ILSpy.Tests/TestAppBuilder.cs
index 8353a793a..df88bce8c 100644
--- a/ILSpy.Tests/TestAppBuilder.cs
+++ b/ILSpy.Tests/TestAppBuilder.cs
@@ -27,6 +27,7 @@ using ICSharpCode.ILSpy.Tests;
[assembly: AvaloniaTestApplication(typeof(TestAppBuilder))]
[assembly: AvaloniaTestIsolation(AvaloniaTestIsolationLevel.PerAssembly)]
[assembly: ResetAppState]
+[assembly: CaptureContext]
namespace ICSharpCode.ILSpy.Tests;
diff --git a/ILSpy.Tests/TestCaptureExtensions.cs b/ILSpy.Tests/TestCaptureExtensions.cs
index 990338478..a80dd5761 100644
--- a/ILSpy.Tests/TestCaptureExtensions.cs
+++ b/ILSpy.Tests/TestCaptureExtensions.cs
@@ -17,47 +17,124 @@
// DEALINGS IN THE SOFTWARE.
using System;
+using System.Diagnostics;
using System.IO;
using System.Linq;
-using System.Runtime.CompilerServices;
using Avalonia.Controls;
using Avalonia.Headless;
using Avalonia.Threading;
+using global::ILSpy.AppEnv;
+using global::ILSpy.Views;
+
namespace ICSharpCode.ILSpy.Tests;
///
-/// Deterministic-path screenshot helpers for the test-review workflow. Unlike
-/// these don't open the OS image viewer —
-/// they write to %TEMP%/ilspy-test-captures/ with a filename predictable from
-/// the (testName, step, label) triple so the audit workflow can read them back for
-/// in-chat review. The ILSPY_TEST_CAPTURES environment variable overrides the
-/// directory when a specific machine wants captures elsewhere. Only renders when
-/// ILSPY_TESTS_VISIBLE=1 (otherwise the headless platform returns a null frame
-/// and the call is a no-op).
+/// Visual breakpoints for the headless suite. A call snapshots the
+/// live UI and writes it to
+/// <captures>/<TestFixtureName>/<TestName>_<NN>_<ShortDescription>.png ,
+/// where the fixture and test names come from NUnit's and NN is a
+/// per-test step counter that auto-increments (so inserting a breakpoint never renumbers the rest).
+///
+/// Drop TestCapture.Step("selected-the-row") after each action and before each assertion to
+/// build a frame-by-frame filmstrip of a test, then flip ILSPY_TESTS_VISIBLE=1 to render them
+/// and eyeball whether the test exercises what it claims. Without that variable the headless platform
+/// returns a null frame and every call is a no-op, so instrumented tests cost nothing in CI.
+///
+///
+/// The capture root is %TEMP%/ilspy-test-captures/ , overridable via the
+/// ILSPY_TEST_CAPTURES environment variable.
+///
///
-public static class TestCaptureExtensions
+public static class TestCapture
{
static readonly string OutputDirectory = Environment.GetEnvironmentVariable("ILSPY_TEST_CAPTURES")
?? Path.Combine(Path.GetTempPath(), "ilspy-test-captures");
- public static void CaptureForReview(
- this Window window,
- int step,
- string label,
- [CallerMemberName] string? testName = null)
+ // Rendering is only on when a debugger is attached or ILSPY_TESTS_VISIBLE=1 (see TestAppBuilder).
+ // When it's off, a breakpoint must be a TRUE no-op -- not even Dispatcher.RunJobs() -- so that
+ // instrumenting a test cannot perturb the timing of navigation/tab state it asserts on.
+ static readonly bool Enabled = Debugger.IsAttached
+ || string.Equals(Environment.GetEnvironmentVariable("ILSPY_TESTS_VISIBLE"), "1", StringComparison.Ordinal);
+
+ // The active test's identity, recorded by CaptureContextAttribute.BeforeTest from the real
+ // ITest. We can't read TestContext.CurrentContext live during the test body: the NUnit
+ // execution context doesn't reliably flow onto async continuations, so captures after an await
+ // would otherwise fall back to NUnit's ad-hoc context and collide under one filename.
+ // The dispatcher thread is single under AvaloniaTestIsolationLevel.PerAssembly, so the suite
+ // runs tests serially on it -- plain static fields are safe (no cross-test races).
+ static string fixtureName = "UnknownFixture";
+ static string testName = "UnknownTest";
+ static int step;
+
+ ///
+ /// Records the running test's identity and resets the step counter. Called by
+ /// before each test, off the real ITest .
+ ///
+ internal static void BeginTest(string? className, string? name)
+ {
+ fixtureName = ShortName(className);
+ testName = Sanitize(name);
+ step = 0;
+ }
+
+ ///
+ /// Snapshots the shared as the next numbered step of the running test.
+ /// The window is resolved from the MEF container, so callers that discarded their window handle
+ /// (e.g. var (_, vm) = await TestHarness.BootAsync() ) can still drop a breakpoint.
+ ///
+ public static void Step(string description)
+ {
+ if (!Enabled)
+ return;
+ var window = TryGetMainWindow();
+ if (window is not null)
+ window.Capture(description);
+ }
+
+ ///
+ /// Snapshots a specific (use when the frame of interest is a dialog or
+ /// secondary window rather than the main one) as the next numbered step of the running test.
+ ///
+ public static void Capture(this Window window, string description)
{
ArgumentNullException.ThrowIfNull(window);
+ if (!Enabled)
+ return;
Dispatcher.UIThread.RunJobs();
var frame = window.CaptureRenderedFrame();
if (frame is null)
- return;
+ return; // headless platform isn't rendering (ILSPY_TESTS_VISIBLE != 1) -> no-op.
+
+ var directory = Path.Combine(OutputDirectory, fixtureName);
+ Directory.CreateDirectory(directory);
+ var fileName = $"{testName}_{step:D2}_{Sanitize(description)}.png";
+ step++;
+ frame.Save(Path.Combine(directory, fileName));
+ }
- Directory.CreateDirectory(OutputDirectory);
- var fileName = $"{Sanitize(testName)}-{step:D2}-{Sanitize(label)}.png";
- var path = Path.Combine(OutputDirectory, fileName);
- frame.Save(path);
+ static Window? TryGetMainWindow()
+ {
+ // ApplicationLifetime is null in headless; the [Shared] MainWindow export is the same
+ // instance the test resolved.
+ try
+ {
+ return AppComposition.Current.GetExport();
+ }
+ catch
+ {
+ return null;
+ }
+ }
+
+ static string ShortName(string? className)
+ {
+ if (string.IsNullOrWhiteSpace(className))
+ return "UnknownFixture";
+ var lastDot = className.LastIndexOf('.');
+ var shortName = lastDot >= 0 ? className[(lastDot + 1)..] : className;
+ return Sanitize(shortName);
}
static string Sanitize(string? label)
diff --git a/ILSpy.Tests/TestHarness.cs b/ILSpy.Tests/TestHarness.cs
index 244df091d..f0728cf8e 100644
--- a/ILSpy.Tests/TestHarness.cs
+++ b/ILSpy.Tests/TestHarness.cs
@@ -55,6 +55,8 @@ public static class TestHarness
window.Show();
var vm = (MainWindowViewModel)window.DataContext!;
await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumAssemblies, timeout);
+ // First visual breakpoint of every UI test: the booted window with its assembly list loaded.
+ window.Capture("booted");
return (window, vm);
}