diff --git a/ILSpy.Tests/Processes/DiagnosticsIpcClientTests.cs b/ILSpy.Tests/Processes/DiagnosticsIpcClientTests.cs new file mode 100644 index 000000000..925276993 --- /dev/null +++ b/ILSpy.Tests/Processes/DiagnosticsIpcClientTests.cs @@ -0,0 +1,111 @@ +// Copyright (c) 2026 Christoph Wille +// +// 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 System.IO; +using System.Linq; +using System.Reflection; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +using AwesomeAssertions; + +using ICSharpCode.ILSpy.Processes; + +using NUnit.Framework; + +namespace ICSharpCode.ILSpy.Tests.Processes; + +/// +/// Exercises the hand-rolled diagnostics IPC implementation on two levels: golden-byte +/// checks of the wire format (header and string encoding, per dotnet/diagnostics +/// ipc-protocol.md), and live end-to-end checks against the one .NET process guaranteed +/// to exist on every OS this suite runs on - the test host itself, which exposes a +/// diagnostics port like any other CoreCLR process. +/// +[TestFixture] +public class DiagnosticsIpcClientTests +{ + [Test] + public void EncodeRequest_Produces_The_Documented_Header_Layout() + { + byte[] payload = { 1, 2, 3 }; + + byte[] message = DiagnosticsIpcMessage.EncodeRequest(0x04, 0x04, payload); + + message.Should().HaveCount(23, "the header is 20 bytes, followed by the payload"); + Encoding.ASCII.GetString(message, 0, 13).Should().Be("DOTNET_IPC_V1"); + message[13].Should().Be(0, "the magic is null-terminated to 14 bytes"); + BitConverter.ToUInt16(message, 14).Should().Be(23, "the size field covers header plus payload"); + message[16].Should().Be(0x04, "command set"); + message[17].Should().Be(0x04, "command id"); + BitConverter.ToUInt16(message, 18).Should().Be(0, "the reserved field is zero"); + message.Skip(20).Should().Equal(payload); + } + + [Test] + public void Strings_Round_Trip_In_The_Documented_Wire_Format() + { + using var buffer = new MemoryStream(); + using (var writer = new BinaryWriter(buffer, Encoding.Unicode, leaveOpen: true)) + { + DiagnosticsIpcMessage.WriteString(writer, "abc"); + } + + buffer.ToArray().Should().Equal(new byte[] { + 4, 0, 0, 0, // u32 char count, including the null terminator + 0x61, 0, 0x62, 0, 0x63, 0, // "abc" as UTF-16LE + 0, 0, // null terminator + }); + + buffer.Position = 0; + using var reader = new BinaryReader(buffer, Encoding.Unicode, leaveOpen: true); + DiagnosticsIpcMessage.ReadString(reader).Should().Be("abc"); + } + + [Test] + public void A_Zero_Length_String_Reads_As_Null() + { + using var buffer = new MemoryStream(new byte[] { 0, 0, 0, 0 }); + using var reader = new BinaryReader(buffer, Encoding.Unicode); + + DiagnosticsIpcMessage.ReadString(reader).Should().BeNull(); + } + + [Test] + public void The_Port_Scan_Finds_The_Current_Process() + { + DiagnosticsPortScanner.GetProcessIds().Should().Contain(Environment.ProcessId, + "the test host is a CoreCLR process and must expose a diagnostics port"); + } + + [Test] + public async Task ProcessInfo_Of_The_Current_Process_Reports_Its_Entry_Assembly() + { + var info = await DiagnosticsIpcClient.GetProcessInfoAsync( + Environment.ProcessId, CancellationToken.None); + + info.Pid.Should().Be(Environment.ProcessId); + info.RuntimeCookie.Should().NotBe(Guid.Empty); + info.CommandLine.Should().NotBeNullOrWhiteSpace(); + info.EntryAssemblyName.Should().Be(Assembly.GetEntryAssembly()!.GetName().Name, + "ProcessInfo2 reports the managed entry-point assembly, not the native host"); + info.ClrVersion.Should().NotBeNullOrWhiteSpace(); + } +} diff --git a/ILSpy.Tests/Processes/FakeProcessExplorer.cs b/ILSpy.Tests/Processes/FakeProcessExplorer.cs new file mode 100644 index 000000000..3f6d27081 --- /dev/null +++ b/ILSpy.Tests/Processes/FakeProcessExplorer.cs @@ -0,0 +1,79 @@ +// Copyright (c) 2026 Christoph Wille +// +// 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 System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +using ICSharpCode.ILSpy.Processes; + +namespace ICSharpCode.ILSpy.Tests.Processes; + +/// +/// Stands in for the live process explorer so the dialog's view model can be driven without +/// running processes or IPC. Records every call, and can be made to fail or to park a +/// module query on a gate so cancellation and superseded-selection behavior is observable. +/// +sealed class FakeProcessExplorer : IProcessExplorer +{ + public List ProcessesToReturn { get; set; } = new(); + public Dictionary> ModulesByPid { get; } = new(); + + public List ModuleCalls { get; } = new(); + public int ProcessCalls { get; private set; } + + public Exception? ProcessesException { get; set; } + public Exception? ModulesException { get; set; } + + /// When set, module queries wait for it before returning. + public TaskCompletionSource? ModulesGate { get; set; } + + public CancellationToken LastModulesToken { get; private set; } + + public static RunningDotNetProcess Process(int pid, string name, string? entryAssembly = null, + RuntimeKind kind = RuntimeKind.CoreClr, string? commandLine = null) + => new(pid, name, kind, RuntimeVersion: "10.0.0", Architecture: "x64", + CommandLine: commandLine, EntryAssemblyName: entryAssembly); + + public async Task> GetProcessesAsync(CancellationToken cancellationToken) + { + ProcessCalls++; + if (ProcessesException != null) + throw ProcessesException; + await Task.Yield(); + cancellationToken.ThrowIfCancellationRequested(); + return ProcessesToReturn; + } + + public async Task> GetModulesAsync( + RunningDotNetProcess process, CancellationToken cancellationToken) + { + ModuleCalls.Add(process.Pid); + LastModulesToken = cancellationToken; + if (ModulesException != null) + throw ModulesException; + if (ModulesGate != null) + await ModulesGate.Task.WaitAsync(cancellationToken); + await Task.Yield(); + cancellationToken.ThrowIfCancellationRequested(); + return ModulesByPid.TryGetValue(process.Pid, out var modules) + ? modules + : Array.Empty(); + } +} diff --git a/ILSpy.Tests/Processes/NativeRuntimeHost.cs b/ILSpy.Tests/Processes/NativeRuntimeHost.cs new file mode 100644 index 000000000..9327938d3 --- /dev/null +++ b/ILSpy.Tests/Processes/NativeRuntimeHost.cs @@ -0,0 +1,51 @@ +// Copyright (c) 2026 Christoph Wille +// +// 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 System.IO; +using System.Runtime.InteropServices; + +namespace ICSharpCode.ILSpy.Tests.Processes; + +/// +/// The native CoreCLR host that backs the running test process. It serves as the fixture for +/// two things the process explorer must get right: a real file that carries no IL, and a +/// module the managed rundown must never report. +/// +/// +/// It is located through the runtime directory rather than the process' own module list, +/// because is not implemented on macOS - +/// there it reports the main module and nothing else. +/// +static class NativeRuntimeHost +{ + /// + /// File name of the runtime host on the current OS. + /// + public static string FileName { get; } = + OperatingSystem.IsWindows() ? "coreclr.dll" + : OperatingSystem.IsMacOS() ? "libcoreclr.dylib" + : "libcoreclr.so"; + + /// + /// Full path of the runtime host loaded by the current process. It sits next to + /// System.Private.CoreLib, whether the app is framework-dependent or self-contained. + /// + public static string FullPath { get; } = + Path.Combine(RuntimeEnvironment.GetRuntimeDirectory(), FileName); +} diff --git a/ILSpy.Tests/Processes/NettraceRundownReaderTests.cs b/ILSpy.Tests/Processes/NettraceRundownReaderTests.cs new file mode 100644 index 000000000..a4af9397c --- /dev/null +++ b/ILSpy.Tests/Processes/NettraceRundownReaderTests.cs @@ -0,0 +1,135 @@ +// Copyright (c) 2026 Christoph Wille +// +// 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 System.IO; +using System.Linq; +using System.Reflection; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +using AwesomeAssertions; + +using ICSharpCode.ILSpy.Processes; + +using NUnit.Framework; + +namespace ICSharpCode.ILSpy.Tests.Processes; + +/// +/// Covers the loaded-assembly half of the process explorer: an EventPipe rundown session +/// against the test host itself, and the scoped nettrace reader that turns the resulting +/// stream into module entries. The test host is the one process guaranteed to be running +/// a known set of assemblies on every OS, so it doubles as the fixture. +/// +[TestFixture] +public class NettraceRundownReaderTests +{ + static MemoryStream? rundown; + + /// + /// Collecting a rundown takes a moment, so every test in this fixture shares one. + /// + [OneTimeSetUp] + public async Task CollectRundownOfTheTestHost() + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(60)); + rundown = await DiagnosticsIpcClient.CollectModuleRundownAsync(Environment.ProcessId, cts.Token); + } + + [OneTimeTearDown] + public void DisposeRundown() => rundown?.Dispose(); + + static MemoryStream Rundown() + { + rundown.Should().NotBeNull("the rundown collected in OneTimeSetUp is the fixture"); + return new MemoryStream(rundown!.ToArray(), writable: false); + } + + [Test] + public void The_Collected_Stream_Is_A_Nettrace_Stream() + { + var buffer = new byte[8]; + Rundown().ReadExactly(buffer); + + Encoding.ASCII.GetString(buffer).Should().Be("Nettrace", + "CollectTracing2 with format=NetTrace must produce a nettrace container"); + } + + [Test] + public void Rundown_Lists_CoreLib_With_A_Real_Path() + { + var modules = NettraceRundownReader.ReadModules(Rundown()); + + var coreLib = modules.Should().ContainSingle( + m => string.Equals(m.Name, "System.Private.CoreLib.dll", StringComparison.OrdinalIgnoreCase), + "every CoreCLR process has exactly one CoreLib loaded").Subject; + coreLib.IsInMemory.Should().BeFalse(); + File.Exists(coreLib.Path).Should().BeTrue("the rundown reports the module's real IL path"); + } + + [Test] + public void Rundown_Lists_The_Entry_Assembly_Of_The_Process() + { + var modules = NettraceRundownReader.ReadModules(Rundown()); + + string entryAssembly = Assembly.GetEntryAssembly()!.GetName().Name!; + modules.Should().Contain( + m => string.Equals(Path.GetFileNameWithoutExtension(m.Name), entryAssembly, StringComparison.OrdinalIgnoreCase), + "the entry assembly - the dll behind the apphost - is the whole point of the feature"); + } + + [Test] + public void Rundown_Lists_Managed_Assemblies_Only() + { + var modules = NettraceRundownReader.ReadModules(Rundown()); + + // The native side of the same process is dominated by libraries the decompiler has no + // use for - the runtime host itself among them, loaded by every CoreCLR process. + // Asking the runtime yields exactly the managed set, which is why the feature reads + // the rundown rather than enumerating native modules. + File.Exists(NativeRuntimeHost.FullPath).Should().BeTrue( + "the runtime host of the current process is the native module to look for"); + + modules.Select(m => m.Name).Should().NotContain( + name => name.StartsWith("coreclr", StringComparison.OrdinalIgnoreCase) + || name.StartsWith("libcoreclr", StringComparison.OrdinalIgnoreCase) + || name.StartsWith("hostfxr", StringComparison.OrdinalIgnoreCase) + || name.StartsWith("kernel32", StringComparison.OrdinalIgnoreCase)); + } + + [Test] + public void Modules_Are_Reported_Once_Each() + { + var modules = NettraceRundownReader.ReadModules(Rundown()); + + modules.Where(m => !m.IsInMemory).Select(m => m.Path) + .Should().OnlyHaveUniqueItems("a module loaded once must not be listed twice"); + } + + [Test] + public void A_Stream_That_Is_Not_Nettrace_Is_Rejected_Clearly() + { + using var garbage = new MemoryStream(Encoding.ASCII.GetBytes("this is not a trace")); + + var read = () => NettraceRundownReader.ReadModules(garbage); + + read.Should().Throw().WithMessage("*Nettrace*"); + } +} diff --git a/ILSpy.Tests/Processes/OpenFromProcessDialogViewModelTests.cs b/ILSpy.Tests/Processes/OpenFromProcessDialogViewModelTests.cs new file mode 100644 index 000000000..1cc2a6350 --- /dev/null +++ b/ILSpy.Tests/Processes/OpenFromProcessDialogViewModelTests.cs @@ -0,0 +1,278 @@ +// Copyright (c) 2026 Christoph Wille +// +// 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 System.IO; +using System.Linq; +using System.Threading.Tasks; + +using Avalonia.Headless.NUnit; + +using AwesomeAssertions; + +using ICSharpCode.ILSpy.Processes; +using ICSharpCode.ILSpy.ViewModels; + +using NUnit.Framework; + +namespace ICSharpCode.ILSpy.Tests.Processes; + +/// +/// Behavior of the "Open from Running Process" dialog's view model against a fake explorer: +/// which processes are listed and filtered, when a process's assemblies are fetched, and +/// what the dialog hands back to be opened. Assemblies that exist only in a process's +/// memory are listed but cannot be opened, which several of these tests pin down. +/// +[TestFixture] +public class OpenFromProcessDialogViewModelTests +{ + static string TestFile(string name) => Path.Combine(TestContext.CurrentContext.TestDirectory, name); + + static ProcessModuleInfo OnDisk(string name) => new(name, TestFile(name), IsInMemory: false); + + static ProcessModuleInfo InMemory(string name) => new(name, Path: null, IsInMemory: true); + + static (OpenFromProcessDialogViewModel vm, FakeProcessExplorer explorer) CreateViewModel() + { + var explorer = new FakeProcessExplorer(); + explorer.ProcessesToReturn.Add(FakeProcessExplorer.Process(100, "ILSpy", "ILSpy")); + explorer.ProcessesToReturn.Add(FakeProcessExplorer.Process(200, "dotnet", "MyTool")); + explorer.ModulesByPid[100] = new[] { OnDisk("ILSpy.dll"), OnDisk("ICSharpCode.Decompiler.dll") }; + explorer.ModulesByPid[200] = new[] { OnDisk("ILSpy.Tests.dll"), InMemory("Dynamic.Proxies") }; + return (new OpenFromProcessDialogViewModel(explorer), explorer); + } + + [AvaloniaTest] + public async Task Refresh_Lists_The_Running_Processes() + { + var (vm, explorer) = CreateViewModel(); + + vm.RefreshCommand.Execute(null); + await Waiters.WaitForAsync(() => vm.Processes.Count == 2); + + vm.Processes.Select(p => p.Pid).Should().Equal(new[] { 100, 200 }); + vm.Processes[0].ProcessName.Should().Be("ILSpy"); + vm.IsLoadingProcesses.Should().BeFalse(); + explorer.ProcessCalls.Should().Be(1); + } + + [AvaloniaTest] + public async Task The_Filter_Matches_Process_Name_Pid_And_Entry_Assembly() + { + var (vm, _) = CreateViewModel(); + vm.RefreshCommand.Execute(null); + await Waiters.WaitForAsync(() => vm.Processes.Count == 2); + + vm.FilterText = "ilspy"; + vm.Processes.Select(p => p.Pid).Should().Equal(new[] { 100 }, "the name matches case-insensitively"); + + vm.FilterText = "200"; + vm.Processes.Select(p => p.Pid).Should().Equal(new[] { 200 }, "a pid is a natural thing to search for"); + + vm.FilterText = "MyTool"; + vm.Processes.Select(p => p.Pid).Should().Equal(new[] { 200 }, "the entry assembly is the interesting name"); + + vm.FilterText = ""; + vm.Processes.Should().HaveCount(2); + } + + [AvaloniaTest] + public async Task Selecting_A_Process_Loads_Its_Assemblies() + { + var (vm, explorer) = CreateViewModel(); + vm.RefreshCommand.Execute(null); + await Waiters.WaitForAsync(() => vm.Processes.Count == 2); + + vm.SelectedProcess = vm.Processes[0]; + await Waiters.WaitForAsync(() => vm.Modules.Count == 2); + + explorer.ModuleCalls.Should().Equal(new[] { 100 }); + vm.Modules.Select(m => m.Name).Should().Equal(new[] { "ILSpy.dll", "ICSharpCode.Decompiler.dll" }); + vm.IsLoadingModules.Should().BeFalse(); + } + + [AvaloniaTest] + public async Task Clearing_The_Selection_Empties_The_Assembly_List() + { + var (vm, _) = CreateViewModel(); + vm.RefreshCommand.Execute(null); + await Waiters.WaitForAsync(() => vm.Processes.Count == 2); + vm.SelectedProcess = vm.Processes[0]; + await Waiters.WaitForAsync(() => vm.Modules.Count == 2); + + vm.SelectedProcess = null; + await Waiters.WaitForAsync(() => vm.Modules.Count == 0); + } + + [AvaloniaTest] + public async Task Add_Selected_Closes_The_Dialog_With_The_Assembly_Paths() + { + var (vm, _) = CreateViewModel(); + string[]? closedWith = null; + vm.CloseRequested += paths => closedWith = paths; + vm.RefreshCommand.Execute(null); + await Waiters.WaitForAsync(() => vm.Processes.Count == 2); + vm.SelectedProcess = vm.Processes[0]; + await Waiters.WaitForAsync(() => vm.Modules.Count == 2); + + vm.SelectedModules.Add(vm.Modules[0]); + vm.SelectedModules.Add(vm.Modules[1]); + vm.AddSelectedModulesCommand.Execute(null); + + closedWith.Should().Equal(new[] { TestFile("ILSpy.dll"), TestFile("ICSharpCode.Decompiler.dll") }); + } + + [AvaloniaTest] + public async Task Assemblies_That_Exist_Only_In_Memory_Cannot_Be_Added() + { + var (vm, _) = CreateViewModel(); + vm.RefreshCommand.Execute(null); + await Waiters.WaitForAsync(() => vm.Processes.Count == 2); + vm.SelectedProcess = vm.Processes[1]; + await Waiters.WaitForAsync(() => vm.Modules.Count == 2); + + var dynamicModule = vm.Modules.Single(m => m.IsInMemory); + vm.SelectedModules.Add(dynamicModule); + + vm.AddSelectedModulesCommand.CanExecute(null).Should().BeFalse( + "an assembly with no file on disk cannot be opened"); + + vm.SelectedModules.Add(vm.Modules.Single(m => !m.IsInMemory)); + vm.AddSelectedModulesCommand.CanExecute(null).Should().BeTrue(); + + string[]? closedWith = null; + vm.CloseRequested += paths => closedWith = paths; + vm.AddSelectedModulesCommand.Execute(null); + + closedWith.Should().Equal(new[] { TestFile("ILSpy.Tests.dll") }, + "the in-memory row is skipped rather than blocking the rest"); + } + + [AvaloniaTest] + public async Task Add_Entry_Assembly_Closes_With_The_Assembly_Behind_The_Host() + { + var (vm, explorer) = CreateViewModel(); + explorer.ProcessesToReturn.Clear(); + explorer.ProcessesToReturn.Add(FakeProcessExplorer.Process(300, "MyApp", "ILSpy.Tests")); + explorer.ModulesByPid[300] = new[] { OnDisk("ILSpy.Tests.dll"), OnDisk("ILSpy.dll") }; + string[]? closedWith = null; + vm.CloseRequested += paths => closedWith = paths; + + vm.RefreshCommand.Execute(null); + await Waiters.WaitForAsync(() => vm.Processes.Count == 1); + vm.SelectedProcess = vm.Processes[0]; + await Waiters.WaitForAsync(() => vm.AddEntryAssemblyCommand.CanExecute(null)); + + vm.AddEntryAssemblyCommand.Execute(null); + + closedWith.Should().Equal(new[] { TestFile("ILSpy.Tests.dll") }); + } + + [AvaloniaTest] + public async Task A_Process_Whose_Entry_Assembly_Is_Unknown_Offers_Nothing_To_Add() + { + var (vm, explorer) = CreateViewModel(); + explorer.ProcessesToReturn.Clear(); + explorer.ProcessesToReturn.Add(FakeProcessExplorer.Process(400, "ghost", entryAssembly: null)); + explorer.ModulesByPid[400] = Array.Empty(); + + vm.RefreshCommand.Execute(null); + await Waiters.WaitForAsync(() => vm.Processes.Count == 1); + vm.SelectedProcess = vm.Processes[0]; + await Waiters.WaitForAsync(() => !vm.IsLoadingModules); + + vm.AddEntryAssemblyCommand.CanExecute(null).Should().BeFalse(); + vm.AddSelectedModulesCommand.CanExecute(null).Should().BeFalse(); + } + + [AvaloniaTest] + public async Task A_Superseded_Assembly_Load_Does_Not_Overwrite_The_Current_One() + { + var (vm, explorer) = CreateViewModel(); + vm.RefreshCommand.Execute(null); + await Waiters.WaitForAsync(() => vm.Processes.Count == 2); + + var gate = new TaskCompletionSource(); + explorer.ModulesGate = gate; + vm.SelectedProcess = vm.Processes[0]; + await Waiters.WaitForAsync(() => explorer.ModuleCalls.Count == 1); + + // The user moves on before the first process answers. + explorer.ModulesGate = null; + vm.SelectedProcess = vm.Processes[1]; + await Waiters.WaitForAsync(() => vm.Modules.Count == 2); + gate.SetResult(); + await Task.Delay(50); + + vm.Modules.Select(m => m.Name).Should().Equal(new[] { "ILSpy.Tests.dll", "Dynamic.Proxies" }, + "the abandoned query must not paint its result over the current selection"); + } + + [AvaloniaTest] + public async Task A_Failing_Process_Scan_Surfaces_The_Error_And_Clears_On_Retry() + { + var (vm, explorer) = CreateViewModel(); + explorer.ProcessesException = new IOException("the diagnostics port is unreachable"); + + vm.RefreshCommand.Execute(null); + await Waiters.WaitForAsync(() => vm.ErrorMessage != null); + + vm.ErrorMessage.Should().Contain("the diagnostics port is unreachable"); + vm.IsLoadingProcesses.Should().BeFalse(); + + explorer.ProcessesException = null; + vm.RefreshCommand.Execute(null); + await Waiters.WaitForAsync(() => vm.Processes.Count == 2); + vm.ErrorMessage.Should().BeNull("a successful retry must dismiss the stale error"); + } + + [AvaloniaTest] + public async Task A_Failing_Assembly_Query_Surfaces_The_Error_Without_Closing() + { + var (vm, explorer) = CreateViewModel(); + bool closed = false; + vm.CloseRequested += _ => closed = true; + vm.RefreshCommand.Execute(null); + await Waiters.WaitForAsync(() => vm.Processes.Count == 2); + + explorer.ModulesException = new UnauthorizedAccessException("access denied"); + vm.SelectedProcess = vm.Processes[0]; + await Waiters.WaitForAsync(() => vm.ErrorMessage != null); + + vm.ErrorMessage.Should().Contain("access denied"); + vm.Modules.Should().BeEmpty(); + vm.IsLoadingModules.Should().BeFalse(); + closed.Should().BeFalse(); + } + + [AvaloniaTest] + public async Task Closing_The_Dialog_Cancels_Work_Still_In_Flight() + { + var (vm, explorer) = CreateViewModel(); + vm.RefreshCommand.Execute(null); + await Waiters.WaitForAsync(() => vm.Processes.Count == 2); + explorer.ModulesGate = new TaskCompletionSource(); + vm.SelectedProcess = vm.Processes[0]; + await Waiters.WaitForAsync(() => explorer.ModuleCalls.Count == 1); + + vm.CancelAllOperations(); + + explorer.LastModulesToken.IsCancellationRequested.Should().BeTrue( + "a dialog that is gone must not keep a rundown session open"); + } +} diff --git a/ILSpy.Tests/Processes/ProcessExplorerTests.cs b/ILSpy.Tests/Processes/ProcessExplorerTests.cs new file mode 100644 index 000000000..d1838b6a8 --- /dev/null +++ b/ILSpy.Tests/Processes/ProcessExplorerTests.cs @@ -0,0 +1,147 @@ +// Copyright (c) 2026 Christoph Wille +// +// 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 System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Threading; +using System.Threading.Tasks; + +using AwesomeAssertions; + +using ICSharpCode.ILSpy.Processes; + +using NUnit.Framework; + +namespace ICSharpCode.ILSpy.Tests.Processes; + +/// +/// The facade the dialog talks to, verified against the running test host, plus the pure +/// helpers it relies on: telling a managed file from a native one, and working out which +/// assembly is the entry point behind a native apphost. +/// +[TestFixture] +public class ProcessExplorerTests +{ + static readonly ProcessExplorer Explorer = new(); + + [Test] + public async Task The_Current_Process_Is_Listed_As_A_CoreClr_Process() + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(60)); + + var processes = await Explorer.GetProcessesAsync(cts.Token); + + var self = processes.Should().ContainSingle(p => p.Pid == Environment.ProcessId).Subject; + self.Kind.Should().Be(RuntimeKind.CoreClr); + self.ProcessName.Should().NotBeNullOrWhiteSpace(); + self.EntryAssemblyName.Should().Be(Assembly.GetEntryAssembly()!.GetName().Name); + self.RuntimeVersion.Should().NotBeNullOrWhiteSpace(); + } + + [Test] + public async Task Modules_Of_The_Current_Process_Include_Its_Own_Assemblies() + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(60)); + var processes = await Explorer.GetProcessesAsync(cts.Token); + var self = processes.Single(p => p.Pid == Environment.ProcessId); + + var modules = await Explorer.GetModulesAsync(self, cts.Token); + + modules.Should().Contain(m => m.Name.Equals("ILSpy.dll", StringComparison.OrdinalIgnoreCase), + "the assembly under test is loaded in the test host"); + } + + [Test] + public async Task The_Entry_Assembly_Of_The_Current_Process_Resolves_To_A_Real_File() + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(60)); + var processes = await Explorer.GetProcessesAsync(cts.Token); + var self = processes.Single(p => p.Pid == Environment.ProcessId); + var modules = await Explorer.GetModulesAsync(self, cts.Token); + + string? entryPath = self.ResolveEntryAssemblyPath(modules); + + File.Exists(entryPath).Should().BeTrue(); + Path.GetFileNameWithoutExtension(entryPath).Should().Be(Assembly.GetEntryAssembly()!.GetName().Name); + } + + [Test] + public void The_Entry_Assembly_Falls_Back_To_The_Dll_Beside_The_Apphost() + { + // A modern app's process shows an .exe that holds no IL at all; the assembly to + // decompile is the dll of the same name next to it. This is the path taken when the + // module list is unavailable (an old runtime, or a rundown that failed). + string apphost = Path.Combine(TestContext.CurrentContext.TestDirectory, "ILSpy.Tests.exe"); + string expected = Path.Combine(TestContext.CurrentContext.TestDirectory, "ILSpy.Tests.dll"); + var process = new RunningDotNetProcess(1, "ILSpy.Tests", RuntimeKind.CoreClr, + RuntimeVersion: null, Architecture: null, CommandLine: $"\"{apphost}\" --a b", + EntryAssemblyName: null); + + process.ResolveEntryAssemblyPath(Array.Empty()) + .Should().Be(expected); + } + + [Test] + public void The_Entry_Assembly_Is_Taken_From_A_Dotnet_Command_Line() + { + string dll = Path.Combine(TestContext.CurrentContext.TestDirectory, "ILSpy.Tests.dll"); + var process = new RunningDotNetProcess(1, "dotnet", RuntimeKind.CoreClr, + RuntimeVersion: null, Architecture: null, CommandLine: $"/usr/bin/dotnet {dll}", + EntryAssemblyName: null); + + process.ResolveEntryAssemblyPath(Array.Empty()).Should().Be(dll); + } + + [Test] + public void An_Unresolvable_Entry_Assembly_Is_Reported_As_Missing() + { + var process = new RunningDotNetProcess(1, "ghost", RuntimeKind.CoreClr, + RuntimeVersion: null, Architecture: null, CommandLine: "/no/such/path/ghost", + EntryAssemblyName: "ghost"); + + process.ResolveEntryAssemblyPath(Array.Empty()).Should().BeNull(); + } + + [Test] + public void An_In_Memory_Module_Never_Resolves_To_A_Path() + { + var process = new RunningDotNetProcess(1, "host", RuntimeKind.CoreClr, + RuntimeVersion: null, Architecture: null, CommandLine: null, EntryAssemblyName: "Dynamic"); + var modules = new[] { new ProcessModuleInfo("Dynamic", Path: null, IsInMemory: true) }; + + process.ResolveEntryAssemblyPath(modules).Should().BeNull( + "an assembly with no file cannot be opened from a path"); + } + + [Test] + public void Managed_Files_Are_Told_Apart_From_Native_Ones() + { + string managed = typeof(ProcessExplorer).Assembly.Location; + string native = NativeRuntimeHost.FullPath; + File.Exists(native).Should().BeTrue( + "a native file that is merely missing would pass the assertion below for the wrong reason"); + + ProcessExplorer.IsManagedAssembly(managed).Should().BeTrue(); + ProcessExplorer.IsManagedAssembly(native).Should().BeFalse("the runtime host carries no IL"); + ProcessExplorer.IsManagedAssembly(Path.Combine(Path.GetTempPath(), "does-not-exist.dll")) + .Should().BeFalse(); + } +} diff --git a/ILSpy.Tests/Views/MainMenuTests.cs b/ILSpy.Tests/Views/MainMenuTests.cs index c99f1538f..52987b6d1 100644 --- a/ILSpy.Tests/Views/MainMenuTests.cs +++ b/ILSpy.Tests/Views/MainMenuTests.cs @@ -78,6 +78,33 @@ public class MainMenuTests nugetIndex.Should().BeLessThan(reloadIndex, "Reload ends the open group"); } + [AvaloniaTest] + public void OpenFromRunningProcess_item_sits_between_NuGet_feed_and_Reload_and_works_on_every_OS() + { + var window = new Window(); + MainMenu.Attach(window); + + var menu = NativeMenu.GetMenu(window); + menu.Should().NotBeNull(); + + var process = Find(menu!, i => i.Header?.Contains("running", StringComparison.OrdinalIgnoreCase) == true); + process.Should().NotBeNull("the File menu must contain an 'Open from running process' item"); + process!.IsEnabled.Should().BeTrue( + "the runtime's diagnostics endpoint answers on Windows, Linux and macOS alike"); + + var fileSubmenu = menu!.Items.OfType() + .Select(i => i.Menu) + .First(m => m != null && m.Items.OfType() + .Any(i => i.Header?.Contains("GAC", StringComparison.OrdinalIgnoreCase) == true)); + var items = fileSubmenu!.Items.OfType().ToList(); + int nugetIndex = items.FindIndex(i => i.Header?.Contains("NuGet feed", StringComparison.OrdinalIgnoreCase) == true); + int processIndex = items.FindIndex(i => i.Header?.Contains("running", StringComparison.OrdinalIgnoreCase) == true); + int reloadIndex = items.FindIndex(i => i.Header?.Contains("Reload", StringComparison.OrdinalIgnoreCase) == true); + + processIndex.Should().BeGreaterThan(nugetIndex, "it joins the group of open-from sources at its end"); + processIndex.Should().BeLessThan(reloadIndex, "Reload ends the open group"); + } + [AvaloniaTest] public void OpenFromGac_item_enabled_state_follows_the_command() { diff --git a/ILSpy.Tests/Views/OpenFromProcessDialogStructureTests.cs b/ILSpy.Tests/Views/OpenFromProcessDialogStructureTests.cs new file mode 100644 index 000000000..456068aa1 --- /dev/null +++ b/ILSpy.Tests/Views/OpenFromProcessDialogStructureTests.cs @@ -0,0 +1,143 @@ +// Copyright (c) 2026 Christoph Wille +// +// 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.Linq; +using System.Threading.Tasks; + +using Avalonia.Controls; +using Avalonia.Headless.NUnit; +using Avalonia.VisualTree; + +using AwesomeAssertions; + +using ICSharpCode.ILSpy.Properties; +using ICSharpCode.ILSpy.Tests.Processes; +using ICSharpCode.ILSpy.ViewModels; +using ICSharpCode.ILSpy.Views; + +using NUnit.Framework; + +namespace ICSharpCode.ILSpy.Tests.Views; + +/// +/// Pins the process-explorer dialog's shape: a filter box, a process grid above an +/// assembly grid, and the buttons that drive it. Everything is reachable by button - the +/// dialog carries no context menu - and the assembly grid allows multi-select so several +/// assemblies can be added in one go. +/// +[TestFixture] +public class OpenFromProcessDialogStructureTests +{ + static OpenFromProcessDialog CreateDialog(FakeProcessExplorer? explorer = null) + { + explorer ??= new FakeProcessExplorer(); + return new OpenFromProcessDialog(explorer); + } + + [AvaloniaTest] + public void Dialog_Title_And_Captions_Come_From_Localised_Resources() + { + var dialog = CreateDialog(); + + dialog.Title.Should().Be(Resources.OpenFromProcess_Title); + dialog.FindControl