From 358ec20dd9166511cc26aac527acf294ce3d16b7 Mon Sep 17 00:00:00 2001 From: Christoph Wille Date: Wed, 10 Jun 2026 20:43:47 +0200 Subject: [PATCH] Add Open from NuGet feed dialog for browsing and opening packages Opening a package from a feed previously meant downloading the .nupkg by hand and using File > Open (#2313). The new File menu command searches any public V3 feed (editable package-source list, persisted as an MRU in the ILSpy settings), offers the latest 100 versions in a dropdown, and downloads into the NuGet global packages folder so the cache is shared with every other NuGet consumer on the machine and nothing is fetched twice. The cached .nupkg is then opened exactly like the regular Open command. Feed access sits behind INuGetFeedClient so the headless test suite covers search, paging, version selection, download, cancellation, and error surfacing without touching the network. Assisted-by: Claude:claude-fable-5:Claude Code --- ILSpy.ReadyToRun/packages.lock.json | 88 +++++ ILSpy.Tests.Windows/packages.lock.json | 64 ++++ ILSpy.Tests/NuGetFeeds/FakeNuGetFeedClient.cs | 115 ++++++ .../NuGetFeeds/NuGetFeedSettingsTests.cs | 133 +++++++ .../OpenFromNuGetFeedViewModelTests.cs | 341 ++++++++++++++++++ ILSpy.Tests/Views/MainMenuTests.cs | 27 ++ .../OpenFromNuGetFeedDialogStructureTests.cs | 167 +++++++++ ILSpy.Tests/packages.lock.json | 64 ++++ ILSpy/Commands/FileCommands.cs | 29 ++ ILSpy/ILSpy.csproj | 4 + ILSpy/NuGetFeeds/INuGetFeedClient.cs | 74 ++++ ILSpy/NuGetFeeds/NuGetFeedClient.cs | 134 +++++++ ILSpy/NuGetFeeds/NuGetFeedSettings.cs | 114 ++++++ ILSpy/Properties/Resources.Designer.cs | 144 ++++++++ ILSpy/Properties/Resources.resx | 48 +++ .../OpenFromNuGetFeedDialogViewModel.cs | 285 +++++++++++++++ ILSpy/Views/OpenFromNuGetFeedDialog.axaml | 160 ++++++++ ILSpy/Views/OpenFromNuGetFeedDialog.axaml.cs | 123 +++++++ ILSpy/packages.lock.json | 87 +++++ TestPlugin/packages.lock.json | 64 ++++ 20 files changed, 2265 insertions(+) create mode 100644 ILSpy.Tests/NuGetFeeds/FakeNuGetFeedClient.cs create mode 100644 ILSpy.Tests/NuGetFeeds/NuGetFeedSettingsTests.cs create mode 100644 ILSpy.Tests/NuGetFeeds/OpenFromNuGetFeedViewModelTests.cs create mode 100644 ILSpy.Tests/Views/OpenFromNuGetFeedDialogStructureTests.cs create mode 100644 ILSpy/NuGetFeeds/INuGetFeedClient.cs create mode 100644 ILSpy/NuGetFeeds/NuGetFeedClient.cs create mode 100644 ILSpy/NuGetFeeds/NuGetFeedSettings.cs create mode 100644 ILSpy/ViewModels/OpenFromNuGetFeedDialogViewModel.cs create mode 100644 ILSpy/Views/OpenFromNuGetFeedDialog.axaml create mode 100644 ILSpy/Views/OpenFromNuGetFeedDialog.axaml.cs diff --git a/ILSpy.ReadyToRun/packages.lock.json b/ILSpy.ReadyToRun/packages.lock.json index dc8bc69e8..1eaad86c7 100644 --- a/ILSpy.ReadyToRun/packages.lock.json +++ b/ILSpy.ReadyToRun/packages.lock.json @@ -317,6 +317,49 @@ "System.Xml.XDocument": "4.3.0" } }, + "Newtonsoft.Json": { + "type": "Transitive", + "resolved": "13.0.3", + "contentHash": "HrC5BXdl00IP9zeV+0Z848QWPAoCr9P3bDEZguI+gkLcBKAOxix/tLEAAHC+UvDNPv4a2d18lOReHMOagPa+zQ==" + }, + "NuGet.Common": { + "type": "Transitive", + "resolved": "7.6.0", + "contentHash": "uyXLqkbbZmkMvdHOR23l1EHW2hRmULtzoG3Ocj84VpGptnNfkODVboHGJfDfcn9Gi9oUNDs8/VjL4FZcST6zVg==", + "dependencies": { + "NuGet.Frameworks": "7.6.0" + } + }, + "NuGet.Configuration": { + "type": "Transitive", + "resolved": "7.6.0", + "contentHash": "+bNj+YneC5CNg1vR+WZjLAakscJlsi0KhADZUgIJPn4pwh8/jeCUMC5ik5cpET/i+QOplDy7aJVTGkpdfrlPww==", + "dependencies": { + "NuGet.Common": "7.6.0", + "System.Security.Cryptography.ProtectedData": "8.0.0" + } + }, + "NuGet.Frameworks": { + "type": "Transitive", + "resolved": "7.6.0", + "contentHash": "rJ7QtKN45XzLXCrMATve6eFLiUyUGEkA1rFSb6U6Fw6laM4hEAcKOrcdbgWlcFUlCK2158qP1LF00hg/ivF3nw==" + }, + "NuGet.Packaging": { + "type": "Transitive", + "resolved": "7.6.0", + "contentHash": "TDp+qHzRBy1zjwiJGCbfpdO0jMG5hH/bk7p1EABLKv9p5SIykDPGnbuYXm2iZO0QJ/H+hOI/vo5LfqM17Q+G0w==", + "dependencies": { + "Newtonsoft.Json": "13.0.3", + "NuGet.Configuration": "7.6.0", + "NuGet.Versioning": "7.6.0", + "System.Security.Cryptography.Pkcs": "8.0.1" + } + }, + "NuGet.Versioning": { + "type": "Transitive", + "resolved": "7.6.0", + "contentHash": "TpZxfOoQBQk/0r/2uc1A1qNYIKHkJGgOrWP+ax3nsNAUN/1BOQMDrgmGADogSA4hOXH1ZJiyeYg4Ca+vUW0sEg==" + }, "Onigwrap": { "type": "Transitive", "resolved": "1.0.10", @@ -1089,6 +1132,11 @@ "System.Threading.Tasks": "4.3.0" } }, + "System.Security.Cryptography.ProtectedData": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "+TUFINV2q2ifyXauQXRwy4CiBhqvDEDZeVJU7qfxya4aRYOKzVBpN+4acx25VcPB9ywUN6C0n8drWl110PhZEg==" + }, "System.Security.Cryptography.X509Certificates": { "type": "Transitive", "resolved": "4.3.0", @@ -1355,6 +1403,7 @@ "Microsoft.DiaSymReader.Converter.Xml": "[1.1.0-beta2-22171-02, )", "Microsoft.DiaSymReader.Native": "[17.0.0-beta1.21524.1, )", "Microsoft.Extensions.DependencyInjection.Abstractions": "[10.0.9, )", + "NuGet.Protocol": "[7.6.0, )", "ProDataGrid": "[12.0.0, )", "Svg.Controls.Skia.Avalonia": "[12.0.0.11, )", "System.Composition.AttributedModel": "[10.0.9, )", @@ -1524,6 +1573,15 @@ "resolved": "0.11.6", "contentHash": "f33RkDtZO8VlGXCtmQIviOtxgnUdym9xx/b1p9h91CRGOsJFxCFOFK1FDbVt1OCf1aWwYejUFa2MOQyFWTFjbA==" }, + "NuGet.Protocol": { + "type": "CentralTransitive", + "requested": "[7.6.0, )", + "resolved": "7.6.0", + "contentHash": "Ccb9dJG9hW0FdHFjXoHmhJBBJRYSCeSJArLdZjyZj6/FEAIKezLn75KFQNrTPE5UiAp4HVrjBizufZ/0IXhfKQ==", + "dependencies": { + "NuGet.Packaging": "7.6.0" + } + }, "ProDataGrid": { "type": "CentralTransitive", "requested": "[12.0.0, )", @@ -1623,6 +1681,12 @@ "runtime.native.System.Security.Cryptography.OpenSsl": "4.3.2" } }, + "System.Security.Cryptography.Pkcs": { + "type": "CentralTransitive", + "requested": "[10.0.9, )", + "resolved": "10.0.9", + "contentHash": "aud6Ox9eSPayMBNu6qL+Zk7yDB6ShrRxg1aMJ1uPqFTGAFVisrjFeKj6RE9SimjNKcbl+chwmiYIK4b3hvc61A==" + }, "System.Text.RegularExpressions": { "type": "CentralTransitive", "requested": "[4.3.1, )", @@ -2567,6 +2631,12 @@ "Microsoft.NETCore.Targets": "1.1.3", "runtime.unix.System.Private.Uri": "4.3.0" } + }, + "System.Security.Cryptography.Pkcs": { + "type": "CentralTransitive", + "requested": "[10.0.9, )", + "resolved": "10.0.9", + "contentHash": "aud6Ox9eSPayMBNu6qL+Zk7yDB6ShrRxg1aMJ1uPqFTGAFVisrjFeKj6RE9SimjNKcbl+chwmiYIK4b3hvc61A==" } }, "net10.0/osx-arm64": { @@ -3487,6 +3557,12 @@ "Microsoft.NETCore.Targets": "1.1.3", "runtime.unix.System.Private.Uri": "4.3.0" } + }, + "System.Security.Cryptography.Pkcs": { + "type": "CentralTransitive", + "requested": "[10.0.9, )", + "resolved": "10.0.9", + "contentHash": "aud6Ox9eSPayMBNu6qL+Zk7yDB6ShrRxg1aMJ1uPqFTGAFVisrjFeKj6RE9SimjNKcbl+chwmiYIK4b3hvc61A==" } }, "net10.0/win-arm64": { @@ -4390,6 +4466,12 @@ "Microsoft.NETCore.Platforms": "1.1.1", "Microsoft.NETCore.Targets": "1.1.3" } + }, + "System.Security.Cryptography.Pkcs": { + "type": "CentralTransitive", + "requested": "[10.0.9, )", + "resolved": "10.0.9", + "contentHash": "aud6Ox9eSPayMBNu6qL+Zk7yDB6ShrRxg1aMJ1uPqFTGAFVisrjFeKj6RE9SimjNKcbl+chwmiYIK4b3hvc61A==" } }, "net10.0/win-x64": { @@ -5293,6 +5375,12 @@ "Microsoft.NETCore.Platforms": "1.1.1", "Microsoft.NETCore.Targets": "1.1.3" } + }, + "System.Security.Cryptography.Pkcs": { + "type": "CentralTransitive", + "requested": "[10.0.9, )", + "resolved": "10.0.9", + "contentHash": "aud6Ox9eSPayMBNu6qL+Zk7yDB6ShrRxg1aMJ1uPqFTGAFVisrjFeKj6RE9SimjNKcbl+chwmiYIK4b3hvc61A==" } } } diff --git a/ILSpy.Tests.Windows/packages.lock.json b/ILSpy.Tests.Windows/packages.lock.json index 0b8ee64fb..4a409c983 100644 --- a/ILSpy.Tests.Windows/packages.lock.json +++ b/ILSpy.Tests.Windows/packages.lock.json @@ -539,6 +539,49 @@ "System.Xml.XDocument": "4.3.0" } }, + "Newtonsoft.Json": { + "type": "Transitive", + "resolved": "13.0.3", + "contentHash": "HrC5BXdl00IP9zeV+0Z848QWPAoCr9P3bDEZguI+gkLcBKAOxix/tLEAAHC+UvDNPv4a2d18lOReHMOagPa+zQ==" + }, + "NuGet.Common": { + "type": "Transitive", + "resolved": "7.6.0", + "contentHash": "uyXLqkbbZmkMvdHOR23l1EHW2hRmULtzoG3Ocj84VpGptnNfkODVboHGJfDfcn9Gi9oUNDs8/VjL4FZcST6zVg==", + "dependencies": { + "NuGet.Frameworks": "7.6.0" + } + }, + "NuGet.Configuration": { + "type": "Transitive", + "resolved": "7.6.0", + "contentHash": "+bNj+YneC5CNg1vR+WZjLAakscJlsi0KhADZUgIJPn4pwh8/jeCUMC5ik5cpET/i+QOplDy7aJVTGkpdfrlPww==", + "dependencies": { + "NuGet.Common": "7.6.0", + "System.Security.Cryptography.ProtectedData": "8.0.0" + } + }, + "NuGet.Frameworks": { + "type": "Transitive", + "resolved": "7.6.0", + "contentHash": "rJ7QtKN45XzLXCrMATve6eFLiUyUGEkA1rFSb6U6Fw6laM4hEAcKOrcdbgWlcFUlCK2158qP1LF00hg/ivF3nw==" + }, + "NuGet.Packaging": { + "type": "Transitive", + "resolved": "7.6.0", + "contentHash": "TDp+qHzRBy1zjwiJGCbfpdO0jMG5hH/bk7p1EABLKv9p5SIykDPGnbuYXm2iZO0QJ/H+hOI/vo5LfqM17Q+G0w==", + "dependencies": { + "Newtonsoft.Json": "13.0.3", + "NuGet.Configuration": "7.6.0", + "NuGet.Versioning": "7.6.0", + "System.Security.Cryptography.Pkcs": "8.0.1" + } + }, + "NuGet.Versioning": { + "type": "Transitive", + "resolved": "7.6.0", + "contentHash": "TpZxfOoQBQk/0r/2uc1A1qNYIKHkJGgOrWP+ax3nsNAUN/1BOQMDrgmGADogSA4hOXH1ZJiyeYg4Ca+vUW0sEg==" + }, "Onigwrap": { "type": "Transitive", "resolved": "1.0.10", @@ -1309,6 +1352,11 @@ "System.Threading.Tasks": "4.3.0" } }, + "System.Security.Cryptography.ProtectedData": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "+TUFINV2q2ifyXauQXRwy4CiBhqvDEDZeVJU7qfxya4aRYOKzVBpN+4acx25VcPB9ywUN6C0n8drWl110PhZEg==" + }, "System.Security.Cryptography.X509Certificates": { "type": "Transitive", "resolved": "4.3.0", @@ -1575,6 +1623,7 @@ "Microsoft.DiaSymReader.Converter.Xml": "[1.1.0-beta2-22171-02, )", "Microsoft.DiaSymReader.Native": "[17.0.0-beta1.21524.1, )", "Microsoft.Extensions.DependencyInjection.Abstractions": "[10.0.9, )", + "NuGet.Protocol": "[7.6.0, )", "ProDataGrid": "[12.0.0, )", "Svg.Controls.Skia.Avalonia": "[12.0.0.11, )", "System.Composition.AttributedModel": "[10.0.9, )", @@ -1751,6 +1800,15 @@ "resolved": "0.11.6", "contentHash": "f33RkDtZO8VlGXCtmQIviOtxgnUdym9xx/b1p9h91CRGOsJFxCFOFK1FDbVt1OCf1aWwYejUFa2MOQyFWTFjbA==" }, + "NuGet.Protocol": { + "type": "CentralTransitive", + "requested": "[7.6.0, )", + "resolved": "7.6.0", + "contentHash": "Ccb9dJG9hW0FdHFjXoHmhJBBJRYSCeSJArLdZjyZj6/FEAIKezLn75KFQNrTPE5UiAp4HVrjBizufZ/0IXhfKQ==", + "dependencies": { + "NuGet.Packaging": "7.6.0" + } + }, "ProDataGrid": { "type": "CentralTransitive", "requested": "[12.0.0, )", @@ -1862,6 +1920,12 @@ "resolved": "6.1.2", "contentHash": "2hBr6zdbIBTDE3EhK7NSVNdX58uTK6iHW/P/Axmm9sl1xoGSLqDvMtpecn226TNwHByFokYwJmt/aQQNlO5CRw==" }, + "System.Security.Cryptography.Pkcs": { + "type": "CentralTransitive", + "requested": "[10.0.9, )", + "resolved": "10.0.9", + "contentHash": "aud6Ox9eSPayMBNu6qL+Zk7yDB6ShrRxg1aMJ1uPqFTGAFVisrjFeKj6RE9SimjNKcbl+chwmiYIK4b3hvc61A==" + }, "System.Text.RegularExpressions": { "type": "CentralTransitive", "requested": "[4.3.1, )", diff --git a/ILSpy.Tests/NuGetFeeds/FakeNuGetFeedClient.cs b/ILSpy.Tests/NuGetFeeds/FakeNuGetFeedClient.cs new file mode 100644 index 000000000..0035a17f5 --- /dev/null +++ b/ILSpy.Tests/NuGetFeeds/FakeNuGetFeedClient.cs @@ -0,0 +1,115 @@ +// 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 System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +using ILSpy.NuGetFeeds; + +namespace ICSharpCode.ILSpy.Tests.NuGetFeeds; + +/// +/// Scriptable in-memory : records every call, returns +/// configurable results, throws configurable exceptions, and can hold a download open +/// on a so cancellation paths become testable. +/// +public sealed class FakeNuGetFeedClient : INuGetFeedClient +{ + public sealed record SearchCall(string FeedUrl, string SearchText, bool IncludePrerelease, int Skip, int Take); + public sealed record VersionsCall(string FeedUrl, string PackageId, bool IncludePrerelease, int MaxCount); + public sealed record DownloadCall(string FeedUrl, string PackageId, string Version); + + public List SearchCalls { get; } = new(); + public List VersionsCalls { get; } = new(); + public List DownloadCalls { get; } = new(); + + /// Computes search results per call; defaults to one well-known package. + public Func> SearchHandler { get; set; } + = call => new[] { MakePackage("Newtonsoft.Json") }; + + public Exception? SearchException { get; set; } + + public IReadOnlyList VersionsToReturn { get; set; } = new[] { "13.0.3", "13.0.2", "12.0.1" }; + + public Exception? VersionsException { get; set; } + + public string DownloadResultPath { get; set; } + = @"C:\nuget-cache\newtonsoft.json\13.0.3\newtonsoft.json.13.0.3.nupkg"; + + public Exception? DownloadException { get; set; } + + /// + /// When set, downloads do not complete until this source is resolved (or the call's + /// cancellation token fires), letting tests cancel a download mid-flight. + /// + public TaskCompletionSource? PendingDownload { get; set; } + + public static NuGetPackageInfo MakePackage(string id, string version = "1.0.0") => new( + Id: id, + LatestVersion: version, + Description: $"Description of {id}", + Authors: "Test Author", + IconUrl: null, + LicenseUrl: "https://licenses.example/MIT", + ProjectUrl: $"https://github.example/{id}", + Tags: "test fake", + DownloadCount: 42, + Published: new DateTimeOffset(2024, 1, 2, 0, 0, 0, TimeSpan.Zero)); + + public static IReadOnlyList MakePage(int count, int offset = 0) + => Enumerable.Range(offset, count).Select(i => MakePackage($"Package{i:D3}")).ToArray(); + + public Task> SearchAsync( + string feedUrl, string searchText, bool includePrerelease, + int skip, int take, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + var call = new SearchCall(feedUrl, searchText, includePrerelease, skip, take); + SearchCalls.Add(call); + if (SearchException != null) + return Task.FromException>(SearchException); + return Task.FromResult(SearchHandler(call)); + } + + public Task> GetVersionsAsync( + string feedUrl, string packageId, bool includePrerelease, + int maxCount, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + VersionsCalls.Add(new VersionsCall(feedUrl, packageId, includePrerelease, maxCount)); + if (VersionsException != null) + return Task.FromException>(VersionsException); + return Task.FromResult(VersionsToReturn); + } + + public async Task DownloadToGlobalPackagesFolderAsync( + string feedUrl, string packageId, string version, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + DownloadCalls.Add(new DownloadCall(feedUrl, packageId, version)); + if (DownloadException != null) + throw DownloadException; + if (PendingDownload != null) + return await PendingDownload.Task.WaitAsync(cancellationToken); + return DownloadResultPath; + } +} diff --git a/ILSpy.Tests/NuGetFeeds/NuGetFeedSettingsTests.cs b/ILSpy.Tests/NuGetFeeds/NuGetFeedSettingsTests.cs new file mode 100644 index 000000000..053b4ec3c --- /dev/null +++ b/ILSpy.Tests/NuGetFeeds/NuGetFeedSettingsTests.cs @@ -0,0 +1,133 @@ +// 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.Linq; +using System.Xml.Linq; + +using AwesomeAssertions; + +using ILSpy.NuGetFeeds; + +using NUnit.Framework; + +namespace ICSharpCode.ILSpy.Tests.NuGetFeeds; + +/// +/// Pins the persistence contract of the "Open from NuGet feed" dialog's settings section: +/// the nuget.org feed is always available even after loading damaged settings XML, the +/// feed MRU stays bounded and free of case-duplicates, and the selected feed plus the +/// prerelease toggle survive a save/load round-trip. +/// +[TestFixture] +public class NuGetFeedSettingsTests +{ + const string CustomFeed = "https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-tools/nuget/v3/index.json"; + + [Test] + public void Defaults_Offer_The_NuGetOrg_Feed_Selected_Without_Prerelease() + { + var settings = new NuGetFeedSettings(); + + settings.Feeds.Should().Equal(new[] { NuGetFeedSettings.DefaultFeed }, + "a fresh install must offer exactly the official nuget.org V3 feed"); + settings.SelectedFeed.Should().Be(NuGetFeedSettings.DefaultFeed); + settings.IncludePrerelease.Should().BeFalse("stable-only is the conventional default"); + } + + [Test] + public void SaveToXml_LoadFromXml_RoundTrips_Feeds_Selection_And_Prerelease() + { + var settings = new NuGetFeedSettings(); + settings.RememberFeed(CustomFeed); + settings.SelectedFeed = CustomFeed; + settings.IncludePrerelease = true; + + var restored = new NuGetFeedSettings(); + restored.LoadFromXml(settings.SaveToXml()); + + restored.Feeds.Should().Equal(settings.Feeds); + restored.SelectedFeed.Should().Be(CustomFeed); + restored.IncludePrerelease.Should().BeTrue(); + } + + [Test] + public void LoadFromXml_On_An_Empty_Section_Falls_Back_To_Defaults() + { + var settings = new NuGetFeedSettings(); + settings.RememberFeed(CustomFeed); + settings.SelectedFeed = CustomFeed; + settings.IncludePrerelease = true; + + settings.LoadFromXml(new XElement("NuGetFeedSettings")); + + settings.Feeds.Should().Equal(new[] { NuGetFeedSettings.DefaultFeed }); + settings.SelectedFeed.Should().Be(NuGetFeedSettings.DefaultFeed); + settings.IncludePrerelease.Should().BeFalse(); + } + + [Test] + public void LoadFromXml_Ignores_Garbage_Content_And_Keeps_The_Default_Feed_Present() + { + var settings = new NuGetFeedSettings(); + var section = new XElement("NuGetFeedSettings", + new XElement("Feeds", + new XElement("Feed", " "), + new XElement("Feed", CustomFeed)), + new XElement("SelectedFeed", "https://feed.example/that/was/never/remembered"), + new XElement("IncludePrerelease", "not-a-bool")); + + settings.LoadFromXml(section); + + settings.Feeds.Should().Contain(NuGetFeedSettings.DefaultFeed, + "the nuget.org feed must survive any settings file content"); + settings.Feeds.Should().Contain(CustomFeed); + settings.Feeds.Should().NotContain(f => string.IsNullOrWhiteSpace(f)); + settings.Feeds.Should().Contain(settings.SelectedFeed, + "the selection must always point at an offered feed"); + settings.IncludePrerelease.Should().BeFalse("malformed booleans fall back to the default"); + } + + [Test] + public void RememberFeed_Dedupes_Case_Insensitively_And_Ignores_Blank_Urls() + { + var settings = new NuGetFeedSettings(); + + settings.RememberFeed(CustomFeed); + settings.RememberFeed(CustomFeed.ToUpperInvariant()); + settings.RememberFeed(" " + CustomFeed + " "); + settings.RememberFeed(""); + settings.RememberFeed(" "); + + settings.Feeds.Should().HaveCount(2, "the default feed plus one custom feed, no duplicates"); + settings.Feeds.Count(f => string.Equals(f, CustomFeed, System.StringComparison.OrdinalIgnoreCase)) + .Should().Be(1); + } + + [Test] + public void RememberFeed_Caps_The_List_But_Never_Evicts_The_Default_Feed() + { + var settings = new NuGetFeedSettings(); + for (int i = 0; i < 25; i++) + settings.RememberFeed($"https://feed{i}.example/v3/index.json"); + + settings.Feeds.Count.Should().BeLessThanOrEqualTo(NuGetFeedSettings.MaxFeeds); + settings.Feeds.Should().Contain(NuGetFeedSettings.DefaultFeed); + settings.Feeds.Should().Contain("https://feed24.example/v3/index.json", + "the most recently used feed must survive the cap"); + } +} diff --git a/ILSpy.Tests/NuGetFeeds/OpenFromNuGetFeedViewModelTests.cs b/ILSpy.Tests/NuGetFeeds/OpenFromNuGetFeedViewModelTests.cs new file mode 100644 index 000000000..3bbe87ab9 --- /dev/null +++ b/ILSpy.Tests/NuGetFeeds/OpenFromNuGetFeedViewModelTests.cs @@ -0,0 +1,341 @@ +// 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 System.Linq; +using System.Net.Http; +using System.Threading.Tasks; + +using Avalonia.Headless.NUnit; + +using AwesomeAssertions; + +using ILSpy.NuGetFeeds; +using ILSpy.ViewModels; + +using NUnit.Framework; + +namespace ICSharpCode.ILSpy.Tests.NuGetFeeds; + +/// +/// Behavior of the "Open from NuGet feed" dialog's view model against a fake feed client: +/// search runs against the persisted feed with the persisted prerelease flag, typing is +/// debounced into a single query, paging appends via skip/take, and feed errors land in +/// ErrorMessage instead of escaping. The fake records every call, so the assertions +/// pin the exact requests the real NuGet client would receive. +/// +[TestFixture] +public class OpenFromNuGetFeedViewModelTests +{ + const string CustomFeed = "https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-tools/nuget/v3/index.json"; + + static (OpenFromNuGetFeedDialogViewModel vm, FakeNuGetFeedClient client, NuGetFeedSettings settings) + CreateViewModel(TimeSpan? debounceDelay = null, NuGetFeedSettings? settings = null) + { + var client = new FakeNuGetFeedClient(); + settings ??= new NuGetFeedSettings(); + var vm = new OpenFromNuGetFeedDialogViewModel(client, settings, debounceDelay ?? TimeSpan.Zero); + return (vm, client, settings); + } + + [AvaloniaTest] + public async Task Refresh_Searches_The_Persisted_Feed_With_Empty_Term_And_Persisted_Prerelease() + { + var settings = new NuGetFeedSettings { IncludePrerelease = true }; + settings.RememberFeed(CustomFeed); + settings.SelectedFeed = CustomFeed; + var (vm, client, _) = CreateViewModel(settings: settings); + + vm.RefreshCommand.Execute(null); + await Waiters.WaitForAsync(() => vm.Packages.Count > 0); + + client.SearchCalls.Should().ContainSingle() + .Which.Should().Be(new FakeNuGetFeedClient.SearchCall( + CustomFeed, "", true, 0, OpenFromNuGetFeedDialogViewModel.PageSize), + "the initial listing must use the persisted feed, prerelease flag, an empty term, and the first page"); + vm.IsSearching.Should().BeFalse(); + } + + [AvaloniaTest] + public async Task Typing_Is_Debounced_Into_A_Single_Search_With_The_Final_Term() + { + var (vm, client, _) = CreateViewModel(debounceDelay: TimeSpan.FromMilliseconds(75)); + + vm.SearchText = "n"; + vm.SearchText = "ne"; + vm.SearchText = "newtonsoft"; + await Waiters.WaitForAsync(() => client.SearchCalls.Count > 0); + // Allow a full extra debounce window to elapse so stale timers would have fired. + await Task.Delay(150); + + client.SearchCalls.Should().ContainSingle("intermediate keystrokes must be coalesced") + .Which.SearchText.Should().Be("newtonsoft"); + } + + [AvaloniaTest] + public async Task Toggling_Prerelease_Reruns_The_Search_And_Persists_The_Flag() + { + var (vm, client, settings) = CreateViewModel(); + + vm.IncludePrerelease = true; + await Waiters.WaitForAsync(() => client.SearchCalls.Count > 0); + + client.SearchCalls.Last().IncludePrerelease.Should().BeTrue(); + settings.IncludePrerelease.Should().BeTrue("the toggle must survive an app restart"); + } + + [AvaloniaTest] + public async Task Switching_To_A_New_Feed_Searches_It_And_Remembers_It_After_Success() + { + var (vm, client, settings) = CreateViewModel(); + + vm.SelectedFeedUrl = CustomFeed; + await Waiters.WaitForAsync(() => client.SearchCalls.Count > 0); + await Waiters.WaitForAsync(() => vm.FeedUrls.Contains(CustomFeed)); + + client.SearchCalls.Last().FeedUrl.Should().Be(CustomFeed); + settings.Feeds.Should().Contain(CustomFeed, + "a feed that produced results is worth offering again next time"); + settings.SelectedFeed.Should().Be(CustomFeed); + } + + [AvaloniaTest] + public async Task A_Failing_Feed_Does_Not_Get_Remembered() + { + var (vm, client, settings) = CreateViewModel(); + client.SearchException = new HttpRequestException("503 Service Unavailable"); + + vm.SelectedFeedUrl = CustomFeed; + await Waiters.WaitForAsync(() => vm.ErrorMessage != null); + + settings.Feeds.Should().NotContain(CustomFeed, + "feeds are only added to the MRU once they answered a search"); + } + + [AvaloniaTest] + public async Task A_Full_Page_Enables_LoadMore_Which_Appends_The_Next_Page() + { + var (vm, client, _) = CreateViewModel(); + client.SearchHandler = call => FakeNuGetFeedClient.MakePage( + count: call.Skip == 0 ? OpenFromNuGetFeedDialogViewModel.PageSize : 3, + offset: call.Skip); + + vm.RefreshCommand.Execute(null); + await Waiters.WaitForAsync(() => vm.Packages.Count == OpenFromNuGetFeedDialogViewModel.PageSize); + vm.CanLoadMore.Should().BeTrue("a full first page suggests more results exist"); + + vm.LoadMoreCommand.Execute(null); + await Waiters.WaitForAsync(() => vm.Packages.Count == OpenFromNuGetFeedDialogViewModel.PageSize + 3); + + client.SearchCalls.Should().HaveCount(2); + client.SearchCalls[1].Skip.Should().Be(OpenFromNuGetFeedDialogViewModel.PageSize, + "the second page must start where the first ended"); + vm.CanLoadMore.Should().BeFalse("a short page means the listing is exhausted"); + vm.Packages.Select(p => p.Id).Should().OnlyHaveUniqueItems(); + } + + [AvaloniaTest] + public async Task ClearSearch_Resets_The_Term_And_Lists_The_Feed_Defaults_Again() + { + var (vm, client, _) = CreateViewModel(); + vm.SearchText = "newtonsoft"; + await Waiters.WaitForAsync(() => client.SearchCalls.Count == 1); + + vm.ClearSearchCommand.Execute(null); + await Waiters.WaitForAsync(() => client.SearchCalls.Count >= 2); + + vm.SearchText.Should().BeEmpty(); + client.SearchCalls.Last().SearchText.Should().BeEmpty(); + } + + [AvaloniaTest] + public async Task Selecting_A_Package_Loads_Its_Versions_And_Preselects_The_Newest() + { + var (vm, client, _) = CreateViewModel(); + client.VersionsToReturn = new[] { "13.0.3", "13.0.2", "12.0.1" }; + + vm.SelectedPackage = FakeNuGetFeedClient.MakePackage("Newtonsoft.Json"); + await Waiters.WaitForAsync(() => vm.Versions.Count == 3); + + vm.Versions.Should().Equal(new[] { "13.0.3", "13.0.2", "12.0.1" }, + "the dropdown lists versions in the order the client returns them (newest first)"); + vm.SelectedVersion.Should().Be("13.0.3", "the newest version is the most likely pick"); + client.VersionsCalls.Should().ContainSingle() + .Which.Should().Be(new FakeNuGetFeedClient.VersionsCall( + NuGetFeedSettings.DefaultFeed, "Newtonsoft.Json", false, + OpenFromNuGetFeedDialogViewModel.MaxVersions)); + } + + [AvaloniaTest] + public async Task Version_Load_Respects_The_Prerelease_Toggle() + { + var (vm, client, _) = CreateViewModel(); + vm.IncludePrerelease = true; + await Waiters.WaitForAsync(() => client.SearchCalls.Count > 0); + + vm.SelectedPackage = FakeNuGetFeedClient.MakePackage("Newtonsoft.Json"); + await Waiters.WaitForAsync(() => client.VersionsCalls.Count > 0); + + client.VersionsCalls.Last().IncludePrerelease.Should().BeTrue(); + } + + [AvaloniaTest] + public async Task Switching_Packages_Replaces_The_Version_List() + { + var (vm, client, _) = CreateViewModel(); + vm.SelectedPackage = FakeNuGetFeedClient.MakePackage("PackageA"); + await Waiters.WaitForAsync(() => vm.Versions.Count > 0); + + client.VersionsToReturn = new[] { "2.0.0" }; + vm.SelectedPackage = FakeNuGetFeedClient.MakePackage("PackageB"); + await Waiters.WaitForAsync(() => vm.Versions.Count == 1); + + vm.Versions.Should().Equal(new[] { "2.0.0" }); + vm.SelectedVersion.Should().Be("2.0.0"); + client.VersionsCalls.Last().PackageId.Should().Be("PackageB"); + } + + [AvaloniaTest] + public async Task A_Cleared_Version_Selection_Snaps_Back_To_The_Latest_Version() + { + var (vm, client, _) = CreateViewModel(); + client.VersionsToReturn = new[] { "13.0.3", "13.0.2" }; + vm.SelectedPackage = FakeNuGetFeedClient.MakePackage("Newtonsoft.Json"); + await Waiters.WaitForAsync(() => vm.SelectedVersion != null); + + // A ComboBox resets its SelectedItem to null when its items change under it; + // the view model must answer by reselecting the latest version, never sitting + // on an empty selection while versions are available. + vm.SelectedVersion = null; + + vm.SelectedVersion.Should().Be("13.0.3"); + } + + [AvaloniaTest] + public async Task Version_Load_Failure_Surfaces_In_ErrorMessage() + { + var (vm, client, _) = CreateViewModel(); + client.VersionsException = new HttpRequestException("flat container unavailable"); + + vm.SelectedPackage = FakeNuGetFeedClient.MakePackage("Newtonsoft.Json"); + await Waiters.WaitForAsync(() => vm.ErrorMessage != null); + + vm.ErrorMessage.Should().Contain("flat container unavailable"); + vm.Versions.Should().BeEmpty(); + vm.SelectedVersion.Should().BeNull(); + } + + [AvaloniaTest] + public async Task Open_Is_Disabled_Until_A_Version_Is_Available() + { + var (vm, client, _) = CreateViewModel(); + vm.OpenPackageCommand.CanExecute(null).Should().BeFalse( + "there is nothing to download before a package/version is selected"); + + vm.SelectedPackage = FakeNuGetFeedClient.MakePackage("Newtonsoft.Json"); + await Waiters.WaitForAsync(() => vm.SelectedVersion != null); + + vm.OpenPackageCommand.CanExecute(null).Should().BeTrue(); + } + + [AvaloniaTest] + public async Task Open_Downloads_The_Selected_Version_And_Requests_Close_With_The_Nupkg_Path() + { + var (vm, client, _) = CreateViewModel(); + string? closedWith = null; + vm.CloseRequested += path => closedWith = path; + + vm.SelectedPackage = FakeNuGetFeedClient.MakePackage("Newtonsoft.Json"); + await Waiters.WaitForAsync(() => vm.SelectedVersion != null); + vm.SelectedVersion = "13.0.2"; + + vm.OpenPackageCommand.Execute(null); + await Waiters.WaitForAsync(() => closedWith != null); + + closedWith.Should().Be(client.DownloadResultPath, + "the dialog result is the cached .nupkg the command then opens"); + vm.DownloadedPackagePath.Should().Be(client.DownloadResultPath); + client.DownloadCalls.Should().ContainSingle() + .Which.Should().Be(new FakeNuGetFeedClient.DownloadCall( + NuGetFeedSettings.DefaultFeed, "Newtonsoft.Json", "13.0.2")); + vm.IsDownloading.Should().BeFalse(); + } + + [AvaloniaTest] + public async Task Cancelling_A_Download_Leaves_The_Dialog_Open_Without_An_Error() + { + var (vm, client, _) = CreateViewModel(); + client.PendingDownload = new TaskCompletionSource(); + bool closeRaised = false; + vm.CloseRequested += _ => closeRaised = true; + + vm.SelectedPackage = FakeNuGetFeedClient.MakePackage("Newtonsoft.Json"); + await Waiters.WaitForAsync(() => vm.SelectedVersion != null); + vm.OpenPackageCommand.Execute(null); + await Waiters.WaitForAsync(() => vm.IsDownloading); + + vm.OpenPackageCommand.CanExecute(null).Should().BeFalse( + "a second download must not start while one is running"); + + vm.CancelDownloadCommand.Execute(null); + await Waiters.WaitForAsync(() => !vm.IsDownloading); + + closeRaised.Should().BeFalse("a cancelled download must keep the dialog open"); + vm.ErrorMessage.Should().BeNull("user-initiated cancellation is not an error"); + vm.OpenPackageCommand.CanExecute(null).Should().BeTrue("the user can retry"); + } + + [AvaloniaTest] + public async Task A_Failed_Download_Surfaces_The_Error_And_Keeps_The_Dialog_Open() + { + var (vm, client, _) = CreateViewModel(); + client.DownloadException = new HttpRequestException("connection reset by peer"); + bool closeRaised = false; + vm.CloseRequested += _ => closeRaised = true; + + vm.SelectedPackage = FakeNuGetFeedClient.MakePackage("Newtonsoft.Json"); + await Waiters.WaitForAsync(() => vm.SelectedVersion != null); + vm.OpenPackageCommand.Execute(null); + await Waiters.WaitForAsync(() => vm.ErrorMessage != null); + + vm.ErrorMessage.Should().Contain("connection reset by peer"); + closeRaised.Should().BeFalse(); + vm.IsDownloading.Should().BeFalse(); + vm.OpenPackageCommand.CanExecute(null).Should().BeTrue("the user can retry after a failure"); + } + + [AvaloniaTest] + public async Task Feed_Errors_Surface_In_ErrorMessage_And_Clear_On_The_Next_Success() + { + var (vm, client, _) = CreateViewModel(); + client.SearchException = new HttpRequestException("name or service not known"); + + vm.RefreshCommand.Execute(null); + await Waiters.WaitForAsync(() => vm.ErrorMessage != null); + + vm.ErrorMessage.Should().Contain("name or service not known", + "the user must see why the feed produced nothing"); + vm.IsSearching.Should().BeFalse(); + vm.Packages.Should().BeEmpty(); + + client.SearchException = null; + vm.RefreshCommand.Execute(null); + await Waiters.WaitForAsync(() => vm.Packages.Count > 0); + vm.ErrorMessage.Should().BeNull("a successful retry must dismiss the stale error"); + } +} diff --git a/ILSpy.Tests/Views/MainMenuTests.cs b/ILSpy.Tests/Views/MainMenuTests.cs index b044b7743..87884c7e4 100644 --- a/ILSpy.Tests/Views/MainMenuTests.cs +++ b/ILSpy.Tests/Views/MainMenuTests.cs @@ -51,6 +51,33 @@ public class MainMenuTests return null; } + [AvaloniaTest] + public void OpenFromNuGetFeed_item_sits_between_GAC_and_Reload_and_works_on_every_OS() + { + var window = new Window(); + MainMenu.Attach(window); + + var menu = NativeMenu.GetMenu(window); + menu.Should().NotBeNull(); + + var nuget = Find(menu!, i => i.Header?.Contains("NuGet feed", StringComparison.OrdinalIgnoreCase) == true); + nuget.Should().NotBeNull("the File menu must contain an 'Open from NuGet feed' item"); + nuget!.IsEnabled.Should().BeTrue("NuGet feeds are reachable from every OS, unlike the GAC"); + + 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 gacIndex = items.FindIndex(i => i.Header?.Contains("GAC", StringComparison.OrdinalIgnoreCase) == true); + int nugetIndex = items.FindIndex(i => i.Header?.Contains("NuGet feed", StringComparison.OrdinalIgnoreCase) == true); + int reloadIndex = items.FindIndex(i => i.Header?.Contains("Reload", StringComparison.OrdinalIgnoreCase) == true); + + nugetIndex.Should().BeGreaterThan(gacIndex, + "the WPF File menu groups the open-from sources right below Open, GAC first"); + nugetIndex.Should().BeLessThan(reloadIndex, "Reload ends the open group"); + } + [AvaloniaTest] public void OpenFromGac_item_enabled_state_follows_the_command() { diff --git a/ILSpy.Tests/Views/OpenFromNuGetFeedDialogStructureTests.cs b/ILSpy.Tests/Views/OpenFromNuGetFeedDialogStructureTests.cs new file mode 100644 index 000000000..9400e69c7 --- /dev/null +++ b/ILSpy.Tests/Views/OpenFromNuGetFeedDialogStructureTests.cs @@ -0,0 +1,167 @@ +// 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 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.NuGetFeeds; + +using ILSpy.ViewModels; +using ILSpy.Views; + +using NUnit.Framework; + +namespace ICSharpCode.ILSpy.Tests.Views; + +/// +/// Pins the package-chooser dialog's shape: +/// search box, pre-release toggle, refresh, an editable package-source ComboBox, +/// a results list, a version dropdown with an Open button, a Cancel button, and an +/// error bar that appears when the feed reports a problem. The editable-ComboBox +/// assertion doubles as a canary for the Simple theme actually templating +/// PART_EditableTextBox - if a theme change drops it, custom feed URLs become untypable. +/// +[TestFixture] +public class OpenFromNuGetFeedDialogStructureTests +{ + static OpenFromNuGetFeedDialog CreateDialog() + => new(settingsService: null, client: new FakeNuGetFeedClient()); + + [AvaloniaTest] + public void Dialog_Title_And_Captions_Come_From_Localised_Resources() + { + var dialog = CreateDialog(); + + dialog.Title.Should().Be(Resources.NuGetFeedSelectPackage); + dialog.FindControl("PrereleaseCheckBox")!.Content + .Should().Be(Resources.NuGetFeedShowPrerelease); + dialog.FindControl + + + + +