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 + + + + +