Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 4 additions & 5 deletions .github/workflows/build-ilspy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -362,12 +362,11 @@ jobs:
-c Release
/p:Platform="Any CPU" /m

# ILSpy.Tests is serial per test host (one headless Avalonia session), so the script runs
# it as two concurrent hosts with disjoint filters; two because the heavier shard peaks
# at ~3 GB and these runners have 3-4 cores. Each shard writes its own TRX file here.
- name: Execute UI tests
run: >
dotnet test --project ILSpy.Tests/ILSpy.Tests.csproj
--configuration Release
--no-build --report-trx
--results-directory test-results
run: ./runuitests.ps1 -Shards 2 -Configuration Release -ResultsDirectory test-results

- name: Execute decompiler tests
id: decompiler-tests
Expand Down
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -25,4 +25,6 @@ DecompilerTests.config.json
report/
# Claude Code local session/skills directory
/.claude/
/.understand-anything
/.understand-anything
# Written by runuitests.ps1 and the CI test steps.
test-results/
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ Never put more than one C# language feature on one branch.
## Test discipline

- Always run the test suite with `--report-trx` so failures survive: `dotnet test --solution ILSpy.sln --report-trx` (the repo pins Microsoft.Testing.Platform in `global.json`; the bare `dotnet test <sln>` form is the old VSTest syntax). Don't dismiss failures as flaky without first reproducing in isolation, then running repeatedly.
- **`ILSpy.Tests` is serial by design** (one headless Avalonia session and one process-wide app composition per test host), so it cannot use more than one core in-process. The local optimization is **sharding across processes**: `./runuitests.ps1` runs the built Debug suite as 4 concurrent `dotnet test` hosts with disjoint filters (about 2.5x faster than one host); CI runs the same script with 2 shards on Linux/macOS. For one fixture use `dotnet test --project ILSpy.Tests/ILSpy.Tests.csproj --no-build --filter "FullyQualifiedName~MyFixture"` (the `--treenode-filter` / `--filter-class` forms are rejected by the NUnit bridge).
- The decompiler test suite (test kinds, fixture structure, how to write tests, the compiler-matrix model) is documented in [ICSharpCode.Decompiler.Tests/AGENTS.md](ICSharpCode.Decompiler.Tests/AGENTS.md).
- After matcher / rewriter edits, **run the relevant tests, not just the build.** `dotnet build` green ≠ behaviour correct.
- **To see what a transform did, dump the ILAst:** `ilspycmd <assembly> -m <doc-id> --ilast` prints the IL transform pipeline's result, and `--after-transform <name-or-index>` stops the pipeline early so two stages can be diffed. Debug builds only (like the UI's Debug Steps pane), so run it from a local build, not the installed tool.
Expand Down
82 changes: 4 additions & 78 deletions ILSpy.Tests/Analyzers/AnalyzeContextMenuTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -48,17 +48,7 @@ namespace ICSharpCode.ILSpy.Tests.Analyzers;
public class AnalyzeContextMenuTests
{
[AvaloniaTest]
public async Task Analyze_Entry_Is_Registered_With_The_Localised_Header()
{
await TestHarness.BootAsync();

var registry = AppComposition.Current.GetExport<ContextMenuEntryRegistry>();
registry.Entries.Should().Contain(e => e.Metadata.Header == nameof(Resources.Analyze),
"the analyze context-menu entry must reach the right-click menu through the MEF registry");
}

[AvaloniaTest]
public async Task Analyze_Entry_Is_Visible_For_Member_Tree_Nodes_And_Hidden_For_AssemblyTreeNode()
public async Task Analyze_Entry_Is_Visible_And_Enabled_Only_For_Member_Tree_Nodes()
{
// AnalyzeContextMenuEntry visibility contract: visible when every selected node is
// an IMemberTreeNode (types, methods, fields, properties, events), hidden otherwise.
Expand All @@ -81,25 +71,12 @@ public async Task Analyze_Entry_Is_Visible_For_Member_Tree_Nodes_And_Hidden_For_
.Should().BeFalse("no selection means no entity to analyse");
entry.IsVisible(new TextViewContext { SelectedTreeNodes = System.Array.Empty<SharpTreeNode>() })
.Should().BeFalse("an empty selection must hide the entry");
}

[AvaloniaTest]
public async Task Analyze_Entry_Is_Disabled_For_A_Selection_That_Mixes_Members_And_Other_Nodes()
{
// IsEnabled must judge the same node shape as IsVisible: a selection that contains a
// non-member node is hidden, so it must not report itself as enabled either (a filter over
// the member subset would call a type-plus-assembly selection analysable).
var (_, vm) = await TestHarness.BootAsync();

var entry = AppComposition.Current.GetExport<ContextMenuEntryRegistry>()
.GetEntry(nameof(Resources.Analyze));

var typeNode = vm.AssemblyTreeModel.FindNode<TypeTreeNode>(
"System.Linq", "System.Linq", "System.Linq.Enumerable");
var assemblyNode = vm.AssemblyTreeModel.FindNode<AssemblyTreeNode>("System.Linq");
var mixed = new TextViewContext { SelectedTreeNodes = new SharpTreeNode[] { typeNode, assemblyNode } };

entry.IsVisible(mixed).Should().BeFalse("precondition: a mixed selection hides the entry");
entry.IsVisible(mixed).Should().BeFalse("a mixed selection hides the entry");
entry.IsEnabled(mixed).Should().BeFalse("a mixed selection must be disabled, not enabled-but-hidden");
entry.IsEnabled(new TextViewContext { SelectedTreeNodes = new SharpTreeNode[] { assemblyNode } })
.Should().BeFalse("a selection without any member node has nothing to analyse");
Expand Down Expand Up @@ -129,33 +106,6 @@ public async Task Analyze_Is_Visible_And_Works_For_A_Clicked_Code_Reference()
"analyzing a clicked code reference must add it to the analyzer pane");
}

[AvaloniaTest]
public async Task Executing_Analyze_Pushes_The_Selected_Entity_Into_The_Analyzer_Pane()
{
// Execute calls AnalyzerTreeViewModel.Analyze for every IMemberTreeNode in the
// selection. The pane's Root.Children must hold one AnalyzedTypeTreeNode wrapping
// the same type definition after the call.

var (_, vm) = await TestHarness.BootAsync();

var entry = AppComposition.Current.GetExport<ContextMenuEntryRegistry>()
.GetEntry(nameof(Resources.Analyze));

var typeNode = vm.AssemblyTreeModel.FindNode<TypeTreeNode>(
"System.Linq", "System.Linq", "System.Linq.Enumerable");

var analyzerVm = AppComposition.Current.GetExport<AnalyzerTreeViewModel>();
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");
(analyzerVm.Root.Children.Last() is AnalyzerEntityTreeNode).Should().BeTrue(
"the new pane row must be an entity wrapper that exposes the analysed member");
}

[AvaloniaTest]
public async Task Analyze_Command_CanExecute_Tracks_The_Assembly_Tree_Selection()
{
Expand All @@ -174,32 +124,6 @@ public async Task Analyze_Command_CanExecute_Tracks_The_Assembly_Tree_Selection(
command.CanExecute(null).Should().BeFalse("assembly nodes are not analyzable members");
}

[AvaloniaTest]
public async Task Pressing_Ctrl_R_On_The_Assembly_Tree_Analyses_The_Selected_Member()
{
// Ctrl+R while a member is selected on the assembly tree pane must surface the
// member in the analyzer pane — the same end-state as right-click + Analyze.

var (window, vm) = await TestHarness.BootAsync();

var typeNode = vm.AssemblyTreeModel.FindNode<TypeTreeNode>(
"System.Linq", "System.Linq", "System.Linq.Enumerable");
vm.AssemblyTreeModel.SelectNode(typeNode);
TestCapture.Step("type-selected");

var analyzerVm = AppComposition.Current.GetExport<AnalyzerTreeViewModel>();
var beforeCount = analyzerVm.Root.Children.Count;

window.KeyPress(Key.R,
RawInputModifiers.Control,
PhysicalKey.R,
keySymbol: null);
TestCapture.Step("ctrl-r-pressed");

analyzerVm.Root.Children.Count.Should().Be(beforeCount + 1,
"Ctrl+R with a type selected must analyse it");
}

[AvaloniaTest]
public async Task Execute_Surfaces_The_Analyzer_Pane_So_Hidden_Panes_Become_Visible()
{
Expand Down Expand Up @@ -296,6 +220,8 @@ public async Task Analyze_Promotes_An_Analyzer_Result_Row_To_A_Top_Level_Entry()
.First(m => m.MethodDefinition.Name == "Empty").MethodDefinition;

entry.Execute(new TextViewContext { SelectedTreeNodes = new SharpTreeNode[] { typeNode } });
analyzerVm.Root.Children.Should().ContainSingle(
"Execute on a tree node must add exactly one entity row to the pane root");
var rootRow = analyzerVm.Root.Children.OfType<AnalyzerEntityTreeNode>().Last();
rootRow.EnsureLazyChildren();
// A result row lives underneath an analyzer-search header, never directly under the root.
Expand Down
20 changes: 0 additions & 20 deletions ILSpy.Tests/Analyzers/AnalyzerNavigationTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -44,26 +44,6 @@ sealed class StubRoutedEventArgs : IPlatformRoutedEventArgs
public bool Handled { get; set; }
}

[AvaloniaTest]
public async Task ActivateItem_On_An_AnalyzedTypeTreeNode_Selects_That_TypeTreeNode_In_The_Assembly_Tree()
{
// Double-click on an analyser result must navigate back to the entity's home in
// the assembly tree — same UX as clicking a hyperlink in the decompiler view.

var (_, vm) = await TestHarness.BootAsync();

var typeNode = vm.AssemblyTreeModel.FindNode<TypeTreeNode>(
"System.Linq", "System.Linq", "System.Linq.Enumerable");
var entity = (ICSharpCode.Decompiler.TypeSystem.ITypeDefinition)typeNode.Member!;

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");
}

[AvaloniaTest]
public async Task ActivateItem_On_An_AnalyzedMethodTreeNode_Selects_The_MethodTreeNode_Sibling()
{
Expand Down
44 changes: 17 additions & 27 deletions ILSpy.Tests/Analyzers/AnalyzerResultHighlightTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -59,29 +59,24 @@ public async Task ActivateItem_On_Analyzer_Result_Sets_HighlightedReference_To_S

var (_, vm) = await TestHarness.BootAsync();

var coreLibName = typeof(object).Assembly.GetName().Name!;
var stringNode = vm.AssemblyTreeModel.FindNode<TypeTreeNode>(
coreLibName, "System", "System.String");
stringNode.IsExpanded = true;
var toStringNode = stringNode.Children.OfType<MethodTreeNode>()
.First(m => m.MethodDefinition.Name == "ToString");
var concatNode = stringNode.Children.OfType<MethodTreeNode>()
.First(m => m.MethodDefinition.Name == "Concat");

// Materialise the decompiler tab by driving a real navigation first; without this
// DockWorkspace.ActiveDecompilerTab is null and the highlight path has no sink.
vm.AssemblyTreeModel.SelectNode(stringNode);
await vm.DockWorkspace.WaitForDecompiledTextAsync();
TestCapture.Step("string-decompiled");
var (sampleNode, _) = await vm.ShowSampleAsync(typeof(DecompileSample));
sampleNode.IsExpanded = true;
var describeNode = sampleNode.Children.OfType<MethodTreeNode>()
.First(m => m.MethodDefinition.Name == nameof(DecompileSample.Describe));
var twiceNode = sampleNode.Children.OfType<MethodTreeNode>()
.First(m => m.MethodDefinition.Name == nameof(DecompileSample.Twice));
TestCapture.Step("sample-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);
var resultNode = new AnalyzedMethodTreeNode(describeNode.MethodDefinition, source: twiceNode.MethodDefinition);
resultNode.ActivateItem(new StubArgs());
TestCapture.Step("highlighted-source-member");

((object?)decompTab.HighlightedReference).Should().BeSameAs(concatNode.MethodDefinition,
((object?)decompTab.HighlightedReference).Should().BeSameAs(twiceNode.MethodDefinition,
"NavigateToReferenceEventArgs.Source (= SourceMember) must land in HighlightedReference so the editor view can paint local-reference marks");
}

Expand All @@ -95,27 +90,22 @@ public async Task ActivateItem_Without_SourceMember_Leaves_HighlightedReference_

var (_, vm) = await TestHarness.BootAsync();

var coreLibName = typeof(object).Assembly.GetName().Name!;
var stringNode = vm.AssemblyTreeModel.FindNode<TypeTreeNode>(
coreLibName, "System", "System.String");
stringNode.IsExpanded = true;
var lengthProp = stringNode.Children.OfType<PropertyTreeNode>()
.First(p => p.PropertyDefinition.Name == "Length");

// Same materialisation as the first test.
vm.AssemblyTreeModel.SelectNode(stringNode);
await vm.DockWorkspace.WaitForDecompiledTextAsync();
TestCapture.Step("string-decompiled");
var (sampleNode, _) = await vm.ShowSampleAsync(typeof(DecompileSample));
sampleNode.IsExpanded = true;
var nameProp = sampleNode.Children.OfType<PropertyTreeNode>()
.First(p => p.PropertyDefinition.Name == nameof(DecompileSample.Name));
TestCapture.Step("sample-decompiled");
var decompTab = vm.DockWorkspace.ActiveDecompilerTab!;
decompTab.HighlightedReference = lengthProp.PropertyDefinition;
decompTab.HighlightedReference = nameProp.PropertyDefinition;

var topLevel = new AnalyzedMethodTreeNode(
stringNode.Children.OfType<MethodTreeNode>().First().MethodDefinition,
sampleNode.Children.OfType<MethodTreeNode>().First().MethodDefinition,
source: null);
topLevel.ActivateItem(new StubArgs());
TestCapture.Step("activated-top-level-entity");

((object?)decompTab.HighlightedReference).Should().BeSameAs(lengthProp.PropertyDefinition,
((object?)decompTab.HighlightedReference).Should().BeSameAs(nameProp.PropertyDefinition,
"top-level analysed-entity rows have no SourceMember and must not overwrite the active highlight");
}

Expand Down
18 changes: 7 additions & 11 deletions ILSpy.Tests/AssemblyList/AssemblyListResetTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -40,14 +40,13 @@ public class AssemblyListResetTests
{
static async Task<MethodTreeNode> DecompileAMethodAsync(ViewModels.MainWindowViewModel vm)
{
var typeNode = vm.AssemblyTreeModel.FindNode<TypeTreeNode>(
"System.Linq", "System.Linq", "System.Linq.Enumerable");
var typeNode = await vm.OpenSampleAsync(typeof(DecompileSample));
typeNode.IsExpanded = true;
var method = typeNode.Children.OfType<MethodTreeNode>()
.Single(m => m.MethodDefinition.Name == "AsEnumerable");
.Single(m => m.MethodDefinition.Name == nameof(DecompileSample.Describe));
vm.AssemblyTreeModel.SelectNode(method);
var tab = await vm.DockWorkspace.WaitForDecompiledTextAsync();
tab.Text.Should().Contain("AsEnumerable", "the pane must hold something before it can go stale");
tab.Text.Should().Contain(nameof(DecompileSample.Describe), "the pane must hold something before it can go stale");
return method;
}

Expand All @@ -70,7 +69,7 @@ public async Task Clearing_The_Assembly_List_Empties_The_Decompiled_View()
vm.AssemblyTreeModel.AssemblyList!.GetAssemblies().Should().BeEmpty();
var tab = vm.DockWorkspace.ActiveDecompilerTab;
if (tab != null)
tab.Text.Should().NotContain("AsEnumerable", "the assembly it came from is gone");
tab.Text.Should().NotContain(nameof(DecompileSample.Describe), "the assembly it came from is gone");
}

[AvaloniaTest]
Expand All @@ -82,17 +81,14 @@ public async Task Sorting_The_Assembly_List_Keeps_The_Navigation_History()

// Arrange - boot, decompile two members so there is a back entry to lose.
var (_, vm) = await TestHarness.BootAsync(3);
var typeNode = vm.AssemblyTreeModel.FindNode<TypeTreeNode>(
"System.Linq", "System.Linq", "System.Linq.Enumerable");
var typeNode = await vm.OpenSampleAsync(typeof(DecompileSample));
typeNode.IsExpanded = true;
var first = typeNode.Children.OfType<MethodTreeNode>()
.Single(m => m.MethodDefinition.Name == "AsEnumerable");
.Single(m => m.MethodDefinition.Name == nameof(DecompileSample.Describe));
var second = typeNode.Children.OfType<MethodTreeNode>()
.First(m => m.MethodDefinition.Name == "Empty");
.First(m => m.MethodDefinition.Name == nameof(DecompileSample.Twice));
vm.AssemblyTreeModel.SelectNode(first);
await vm.DockWorkspace.WaitForDecompiledTextAsync();
// NavigationHistory collapses selections inside 0.5s into one entry.
await Task.Delay(600);
vm.AssemblyTreeModel.SelectNode(second);
await Waiters.WaitForAsync(() => ReferenceEquals(vm.AssemblyTreeModel.SelectedItem, second));
await vm.DockWorkspace.WaitForDecompiledTextAsync();
Expand Down
Loading
Loading