// Copyright (c) 2026 Siegfried Pammer // // 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.IO; using System.Linq; using System.Threading.Tasks; using Avalonia.Headless.NUnit; using AwesomeAssertions; using ICSharpCode.ILSpy; using ICSharpCode.ILSpy.AppEnv; using ICSharpCode.ILSpy.Commands; using ICSharpCode.ILSpy.Languages; using ICSharpCode.ILSpy.TextView; using ICSharpCode.ILSpy.TreeNodes; using ICSharpCode.ILSpy.ViewModels; using ICSharpCode.ILSpyX.TreeView; using NUnit.Framework; namespace ICSharpCode.ILSpy.Tests.Commands; /// /// Every File -> Save Code path for an assembly runs behind a cancellable progress overlay, never a /// bare Task.Run, and every one that decompiles a whole assembly shares the Export Project command's /// frozen determinate-progress tab: for a /// .csproj, for several assemblies as a .sln. The /// single-file save goes through (the /// same overlay normal decompilation uses). These assert each path writes its output and surfaces a /// report/breadcrumb in a tab. /// [TestFixture] public class SaveCodeProjectExportTests { [AvaloniaTest] public async Task Save_As_Project_Runs_Through_The_Export_Project_Tab() { var (_, vm) = await TestHarness.BootAsync(); var assembly = await vm.OpenFixtureAsync(); var dock = vm.DockWorkspace; var language = AppComposition.Current.GetExport() .Languages.OfType().First(); var settings = AppComposition.Current.GetExport().CreateEffectiveDecompilerSettings(); var tempDir = Path.Combine(Path.GetTempPath(), "ILSpySaveProj_" + System.Guid.NewGuid().ToString("N")); Directory.CreateDirectory(tempDir); try { await ProjectExport.ExportSingleAssemblyAsync( assembly, tempDir, settings, language, dock); Directory.EnumerateFiles(tempDir, "*.csproj").Should().HaveCount(1, "Save Code -> .csproj must run the whole-project decompiler, exactly like Export Project"); Directory.EnumerateFiles(tempDir, "*.cs", SearchOption.AllDirectories).Should().NotBeEmpty(); var exportTab = dock.Documents!.VisibleDockables!.OfType() .Select(t => t.Content).OfType() .FirstOrDefault(d => d.Text != null && d.Text.Contains("Project written to")); exportTab.Should().NotBeNull("the export runs in its own progress tab and shows the project-written report there"); exportTab!.Title.Should().Be(assembly.Text, "a single-assembly export tab is titled after that assembly's tree-node label -- the same however it was started (Save Code or Export Project)"); } finally { try { Directory.Delete(tempDir, recursive: true); } catch { /* best-effort */ } } } [AvaloniaTest] public async Task Save_Several_Assemblies_Exports_A_Solution_Through_The_Export_Project_Tab() { var (_, vm) = await TestHarness.BootAsync(); var assemblies = new[] { await vm.OpenFixtureAsync("FixtureA"), await vm.OpenFixtureAsync("FixtureB"), }; var dock = vm.DockWorkspace; var language = AppComposition.Current.GetExport() .Languages.OfType().First(); var settings = AppComposition.Current.GetExport().CreateEffectiveDecompilerSettings(); var tempDir = Path.Combine(Path.GetTempPath(), "ILSpySaveSln_" + System.Guid.NewGuid().ToString("N")); Directory.CreateDirectory(tempDir); // A name the output folder does not imply: Save Code lets the user pick the .sln file itself, // unlike the Export Project dialog, which derives the name from the chosen folder. var solutionPath = Path.Combine(tempDir, "Picked.sln"); try { await ProjectExport.ExportSolutionAsync(assemblies, solutionPath, settings, language, dock); File.Exists(solutionPath).Should().BeTrue( "the solution must land on the exact path picked in Save Code, not one derived from the folder"); foreach (var a in assemblies) { Directory.EnumerateFiles(Path.Combine(tempDir, a.ShortName), "*.csproj").Should().HaveCount(1, $"each exported assembly gets its own project ({a.ShortName})"); } var exportTab = dock.Documents!.VisibleDockables!.OfType() .Select(t => t.Content).OfType() .FirstOrDefault(d => d.Title == string.Join(", ", assemblies.Select(a => a.Text))); exportTab.Should().NotBeNull( "a solution export tab is titled after the assemblies being exported -- the same however it was started (Save Code or Export Project)"); exportTab!.Text.Should().Contain("Created the Visual Studio Solution file", "the export runs in its own progress tab and reports there"); } finally { try { Directory.Delete(tempDir, recursive: true); } catch { /* best-effort */ } } } [AvaloniaTest] public async Task A_Failed_Load_In_The_Selection_Does_Not_Disqualify_The_Solution_Export() { var (_, vm) = await TestHarness.BootAsync(); var good = await vm.OpenFixtureAsync("FixtureA"); var broken = await vm.OpenBrokenFixtureAsync(); // The selection Ctrl+S sees: two assembly nodes, one of which failed to load. Rejecting it // here is what used to make Save Code fall through and quietly save the focused assembly // alone; the exporter skips the unloadable one and reports it instead. var nodes = new SharpTreeNode[] { new AssemblyTreeNode(good), new AssemblyTreeNode(broken) }; ProjectExport.TryGetSolutionAssemblies(nodes, out var assemblies).Should().BeTrue( "an assembly that failed to load must not disqualify the whole selection"); assemblies.Should().BeEquivalentTo([good, broken], "the exporter decides what to skip, so it has to see the failed load to report it"); } [AvaloniaTest] public async Task A_Selection_Where_Nothing_Loaded_Has_Nothing_To_Export() { var (_, vm) = await TestHarness.BootAsync(); var broken = await vm.OpenBrokenFixtureAsync("BrokenA"); var alsoBroken = await vm.OpenBrokenFixtureAsync("BrokenB"); var nodes = new SharpTreeNode[] { new AssemblyTreeNode(broken), new AssemblyTreeNode(alsoBroken) }; ProjectExport.TryGetSolutionAssemblies(nodes, out _).Should().BeFalse( "a solution of nothing but failed loads would be empty; Save Code leaves the selection to the single-node path"); } [AvaloniaTest] public async Task Save_As_Single_File_Runs_Through_The_Cancellable_Progress_Tab() { var (_, vm) = await TestHarness.BootAsync(); var assembly = await vm.OpenFixtureAsync(); var dock = vm.DockWorkspace; var assemblyNode = vm.AssemblyTreeModel.FindNode(assembly.ShortName); Assert.That(assemblyNode, Is.Not.Null); // Select the assembly so there is an active decompiler tab for RunWithCancellation to run on. vm.AssemblyTreeModel.SelectNode(assemblyNode); await dock.WaitForDecompiledTextAsync(); var language = AppComposition.Current.GetExport() .Languages.OfType().First(); var path = Path.Combine(Path.GetTempPath(), "ILSpySaveSingle_" + System.Guid.NewGuid().ToString("N") + ".cs"); try { await SaveCodeHelper.SaveNodeToFileWithProgressAsync(assemblyNode!, language, path, dock); File.Exists(path).Should().BeTrue("the single-file save must write the assembly to disk"); (await File.ReadAllTextAsync(path)).Should().Contain(FixtureAssembly.TypeName, "the decompiled assembly's type must be present"); dock.Documents!.VisibleDockables!.OfType() .Any(t => t.Content is DecompilerTabPageModel d && d.Text != null && d.Text.Contains("Decompilation complete")) .Should().BeTrue("the single-file save runs behind the cancellable overlay and shows a completion breadcrumb"); } finally { if (File.Exists(path)) File.Delete(path); } } }