From 083f3692bcd2d9decf3f7c436515ba01feb93fc8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Moreau?= Date: Wed, 8 Apr 2026 22:16:26 -0400 Subject: [PATCH 1/2] Expand unit test coverage and stabilize CI - add broad package-engine, telemetry, settings, and app-side test coverage - extract targeted test seams and helpers for deterministic parser and operation tests - pin Tmds.DBus.Protocol to 0.92.0 to remove the Avalonia NU1903 warning Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../UniGetUI.Avalonia.csproj | 1 + .../SecureSettings.cs | 7 + .../SecureSettingsTests.cs | 111 ++++ .../SettingsImportExportTests.cs | 83 +++ .../TestAssembly.cs | 3 + .../UniGetUI.Core.Settings.Tests.csproj | 1 + .../SettingsEngine_ImportExport.cs | 5 + .../TelemetryHandlerTests.cs | 327 ++++++++++ .../UniGetUI.Interface.Telemetry.Tests.csproj | 41 ++ .../InternalsVisibleTo.cs | 3 + .../TelemetryHandler.cs | 84 ++- .../CratesIOClient.cs | 9 +- .../InternalsVisibleTo.cs | 3 + .../Chocolatey.cs | 162 +++-- .../Helpers/ChocolateyDetailsHelper.cs | 23 +- .../Helpers/ChocolateySourceHelper.cs | 27 +- .../InternalsVisibleTo.cs | 3 + .../DotNet.cs | 101 +-- .../InternalsVisibleTo.cs | 3 + .../InternalsVisibleTo.cs | 3 + .../InternalsVisibleTo.cs | 3 + .../Npm.cs | 213 ++++--- .../InternalsVisibleTo.cs | 3 + .../Pip.cs | 200 +++--- .../InternalsVisibleTo.cs | 3 + .../PowerShell.cs | 81 ++- .../Helpers/ScoopSourceHelper.cs | 138 +++-- .../InternalsVisibleTo.cs | 3 + .../Scoop.cs | 390 ++++++------ .../InternalsVisibleTo.cs | 3 + .../CargoClientTests.cs | 100 +++ .../ChocolateyManagerTests.cs | 277 +++++++++ .../DotNetManagerTests.cs | 61 ++ .../Fixtures/Chocolatey/list-output.txt | 6 + .../Fixtures/Chocolatey/outdated-output.txt | 6 + .../Chocolatey/search-versions-output.txt | 5 + .../Chocolatey/source-list-output.txt | 5 + .../Fixtures/Npm/installed.json | 11 + .../Fixtures/Npm/outdated.json | 11 + .../Npm/search-array-with-warning.txt | 13 + .../Fixtures/Npm/search-ndjson.txt | 5 + .../Fixtures/Pip/installed-list.txt | 6 + .../Fixtures/Pip/outdated-list.txt | 6 + .../Fixtures/Pip/simple-index.json | 12 + .../Fixtures/Scoop/bucket-list-output.txt | 5 + .../Fixtures/Scoop/list-output.txt | 5 + .../Fixtures/Scoop/search-output.txt | 6 + .../Fixtures/Scoop/status-output.txt | 6 + .../Fixtures/sample-manager-output.txt | 3 + .../HarnessSmokeTests.cs | 147 +++++ .../IgnoredUpdatesDatabaseTests.cs | 87 +++ .../Assertions/OperationAssert.cs | 16 + .../Assertions/PackageAssert.cs | 25 + .../Infrastructure/Builders/PackageBuilder.cs | 68 ++ .../Builders/PackageDetailsBuilder.cs | 125 ++++ .../Builders/PackageManagerBuilder.cs | 120 ++++ .../Infrastructure/Builders/SourceBuilder.cs | 63 ++ .../Fakes/TestPackageDetailsHelper.cs | 71 +++ .../Infrastructure/Fakes/TestPackageLoader.cs | 49 ++ .../Fakes/TestPackageManager.cs | 175 ++++++ .../Fakes/TestPackageOperationHelper.cs | 35 ++ .../Infrastructure/Fakes/TestSourceHelper.cs | 60 ++ .../Fakes/TestUpgradablePackagesLoader.cs | 25 + .../Helpers/LoaderEventRecorder.cs | 37 ++ .../Helpers/PackageEngineFixtureFiles.cs | 23 + .../Infrastructure/Helpers/TestHttpServer.cs | 87 +++ .../InstallOptionsFactoryTests.cs | 164 +++++ .../NpmManagerTests.cs | 166 +++++ .../NuGetManifestLoaderTests.cs | 84 +++ .../PackageLoaderPipelineTests.cs | 191 ++++++ .../PackageManagerTests.cs | 334 ++++++++++ .../PackageOperationsTests.cs | 356 +++++++++++ .../PipManagerTests.cs | 222 +++++++ .../PowerShellManagerTests.cs | 59 ++ .../ScoopManagerTests.cs | 328 ++++++++++ .../SourceOperationsTests.cs | 180 ++++++ .../TestAssembly.cs | 3 + .../UniGetUI.PackageEngine.Tests.csproj | 55 ++ .../UpgradablePackagesLoaderTests.cs | 144 +++++ .../WinGetManagerTests.cs | 192 ++++++ src/UniGetUI.Tests/AutoUpdaterTests.cs | 99 +++ src/UniGetUI.Tests/CLIHandlerTests.cs | 118 ++++ src/UniGetUI.Tests/TestAssembly.cs | 3 + src/UniGetUI.Tests/UniGetUI.Tests.csproj | 49 ++ src/UniGetUI.sln | 579 +++++++++++++++--- src/UniGetUI/AutoUpdater.Helpers.cs | 280 +++++++++ src/UniGetUI/AutoUpdater.cs | 274 --------- src/UniGetUI/CLIHandler.cs | 114 ++-- src/UniGetUI/InternalsVisibleTo.cs | 3 + 89 files changed, 6788 insertions(+), 1008 deletions(-) create mode 100644 src/UniGetUI.Core.Settings.Tests/SecureSettingsTests.cs create mode 100644 src/UniGetUI.Core.Settings.Tests/SettingsImportExportTests.cs create mode 100644 src/UniGetUI.Core.Settings.Tests/TestAssembly.cs create mode 100644 src/UniGetUI.Interface.Telemetry.Tests/TelemetryHandlerTests.cs create mode 100644 src/UniGetUI.Interface.Telemetry.Tests/UniGetUI.Interface.Telemetry.Tests.csproj create mode 100644 src/UniGetUI.Interface.Telemetry/InternalsVisibleTo.cs create mode 100644 src/UniGetUI.PackageEngine.Managers.Cargo/InternalsVisibleTo.cs create mode 100644 src/UniGetUI.PackageEngine.Managers.Chocolatey/InternalsVisibleTo.cs create mode 100644 src/UniGetUI.PackageEngine.Managers.Dotnet/InternalsVisibleTo.cs create mode 100644 src/UniGetUI.PackageEngine.Managers.Generic.NuGet/InternalsVisibleTo.cs create mode 100644 src/UniGetUI.PackageEngine.Managers.Npm/InternalsVisibleTo.cs create mode 100644 src/UniGetUI.PackageEngine.Managers.Pip/InternalsVisibleTo.cs create mode 100644 src/UniGetUI.PackageEngine.Managers.PowerShell/InternalsVisibleTo.cs create mode 100644 src/UniGetUI.PackageEngine.Managers.Scoop/InternalsVisibleTo.cs create mode 100644 src/UniGetUI.PackageEngine.Managers.WinGet/InternalsVisibleTo.cs create mode 100644 src/UniGetUI.PackageEngine.Tests/CargoClientTests.cs create mode 100644 src/UniGetUI.PackageEngine.Tests/ChocolateyManagerTests.cs create mode 100644 src/UniGetUI.PackageEngine.Tests/DotNetManagerTests.cs create mode 100644 src/UniGetUI.PackageEngine.Tests/Fixtures/Chocolatey/list-output.txt create mode 100644 src/UniGetUI.PackageEngine.Tests/Fixtures/Chocolatey/outdated-output.txt create mode 100644 src/UniGetUI.PackageEngine.Tests/Fixtures/Chocolatey/search-versions-output.txt create mode 100644 src/UniGetUI.PackageEngine.Tests/Fixtures/Chocolatey/source-list-output.txt create mode 100644 src/UniGetUI.PackageEngine.Tests/Fixtures/Npm/installed.json create mode 100644 src/UniGetUI.PackageEngine.Tests/Fixtures/Npm/outdated.json create mode 100644 src/UniGetUI.PackageEngine.Tests/Fixtures/Npm/search-array-with-warning.txt create mode 100644 src/UniGetUI.PackageEngine.Tests/Fixtures/Npm/search-ndjson.txt create mode 100644 src/UniGetUI.PackageEngine.Tests/Fixtures/Pip/installed-list.txt create mode 100644 src/UniGetUI.PackageEngine.Tests/Fixtures/Pip/outdated-list.txt create mode 100644 src/UniGetUI.PackageEngine.Tests/Fixtures/Pip/simple-index.json create mode 100644 src/UniGetUI.PackageEngine.Tests/Fixtures/Scoop/bucket-list-output.txt create mode 100644 src/UniGetUI.PackageEngine.Tests/Fixtures/Scoop/list-output.txt create mode 100644 src/UniGetUI.PackageEngine.Tests/Fixtures/Scoop/search-output.txt create mode 100644 src/UniGetUI.PackageEngine.Tests/Fixtures/Scoop/status-output.txt create mode 100644 src/UniGetUI.PackageEngine.Tests/Fixtures/sample-manager-output.txt create mode 100644 src/UniGetUI.PackageEngine.Tests/HarnessSmokeTests.cs create mode 100644 src/UniGetUI.PackageEngine.Tests/IgnoredUpdatesDatabaseTests.cs create mode 100644 src/UniGetUI.PackageEngine.Tests/Infrastructure/Assertions/OperationAssert.cs create mode 100644 src/UniGetUI.PackageEngine.Tests/Infrastructure/Assertions/PackageAssert.cs create mode 100644 src/UniGetUI.PackageEngine.Tests/Infrastructure/Builders/PackageBuilder.cs create mode 100644 src/UniGetUI.PackageEngine.Tests/Infrastructure/Builders/PackageDetailsBuilder.cs create mode 100644 src/UniGetUI.PackageEngine.Tests/Infrastructure/Builders/PackageManagerBuilder.cs create mode 100644 src/UniGetUI.PackageEngine.Tests/Infrastructure/Builders/SourceBuilder.cs create mode 100644 src/UniGetUI.PackageEngine.Tests/Infrastructure/Fakes/TestPackageDetailsHelper.cs create mode 100644 src/UniGetUI.PackageEngine.Tests/Infrastructure/Fakes/TestPackageLoader.cs create mode 100644 src/UniGetUI.PackageEngine.Tests/Infrastructure/Fakes/TestPackageManager.cs create mode 100644 src/UniGetUI.PackageEngine.Tests/Infrastructure/Fakes/TestPackageOperationHelper.cs create mode 100644 src/UniGetUI.PackageEngine.Tests/Infrastructure/Fakes/TestSourceHelper.cs create mode 100644 src/UniGetUI.PackageEngine.Tests/Infrastructure/Fakes/TestUpgradablePackagesLoader.cs create mode 100644 src/UniGetUI.PackageEngine.Tests/Infrastructure/Helpers/LoaderEventRecorder.cs create mode 100644 src/UniGetUI.PackageEngine.Tests/Infrastructure/Helpers/PackageEngineFixtureFiles.cs create mode 100644 src/UniGetUI.PackageEngine.Tests/Infrastructure/Helpers/TestHttpServer.cs create mode 100644 src/UniGetUI.PackageEngine.Tests/InstallOptionsFactoryTests.cs create mode 100644 src/UniGetUI.PackageEngine.Tests/NpmManagerTests.cs create mode 100644 src/UniGetUI.PackageEngine.Tests/NuGetManifestLoaderTests.cs create mode 100644 src/UniGetUI.PackageEngine.Tests/PackageLoaderPipelineTests.cs create mode 100644 src/UniGetUI.PackageEngine.Tests/PackageManagerTests.cs create mode 100644 src/UniGetUI.PackageEngine.Tests/PackageOperationsTests.cs create mode 100644 src/UniGetUI.PackageEngine.Tests/PipManagerTests.cs create mode 100644 src/UniGetUI.PackageEngine.Tests/PowerShellManagerTests.cs create mode 100644 src/UniGetUI.PackageEngine.Tests/ScoopManagerTests.cs create mode 100644 src/UniGetUI.PackageEngine.Tests/SourceOperationsTests.cs create mode 100644 src/UniGetUI.PackageEngine.Tests/TestAssembly.cs create mode 100644 src/UniGetUI.PackageEngine.Tests/UniGetUI.PackageEngine.Tests.csproj create mode 100644 src/UniGetUI.PackageEngine.Tests/UpgradablePackagesLoaderTests.cs create mode 100644 src/UniGetUI.PackageEngine.Tests/WinGetManagerTests.cs create mode 100644 src/UniGetUI.Tests/AutoUpdaterTests.cs create mode 100644 src/UniGetUI.Tests/CLIHandlerTests.cs create mode 100644 src/UniGetUI.Tests/TestAssembly.cs create mode 100644 src/UniGetUI.Tests/UniGetUI.Tests.csproj create mode 100644 src/UniGetUI/AutoUpdater.Helpers.cs create mode 100644 src/UniGetUI/InternalsVisibleTo.cs diff --git a/src/UniGetUI.Avalonia/UniGetUI.Avalonia.csproj b/src/UniGetUI.Avalonia/UniGetUI.Avalonia.csproj index bbc10d5ddd..3b3ee8888a 100644 --- a/src/UniGetUI.Avalonia/UniGetUI.Avalonia.csproj +++ b/src/UniGetUI.Avalonia/UniGetUI.Avalonia.csproj @@ -37,6 +37,7 @@ + diff --git a/src/UniGetUI.Core.SecureSettings/SecureSettings.cs b/src/UniGetUI.Core.SecureSettings/SecureSettings.cs index b5063de47c..eedce59ee9 100644 --- a/src/UniGetUI.Core.SecureSettings/SecureSettings.cs +++ b/src/UniGetUI.Core.SecureSettings/SecureSettings.cs @@ -6,6 +6,8 @@ namespace UniGetUI.Core.SettingsEngine.SecureSettings; public static class SecureSettings { + public static string? TEST_SecureSettingsRootOverride { private get; set; } + // Various predefined secure settings keys public enum K { @@ -140,6 +142,11 @@ public static int ApplyForUser(string username, string setting, bool enable) private static string GetSecureSettingsRoot() { + if (TEST_SecureSettingsRootOverride is not null) + { + return TEST_SecureSettingsRootOverride; + } + if (OperatingSystem.IsWindows()) { return Path.Join( diff --git a/src/UniGetUI.Core.Settings.Tests/SecureSettingsTests.cs b/src/UniGetUI.Core.Settings.Tests/SecureSettingsTests.cs new file mode 100644 index 0000000000..e19fe06f70 --- /dev/null +++ b/src/UniGetUI.Core.Settings.Tests/SecureSettingsTests.cs @@ -0,0 +1,111 @@ +using System.Reflection; +using UniGetUI.Core.Tools; +using SecureSettingsStore = UniGetUI.Core.SettingsEngine.SecureSettings.SecureSettings; + +namespace UniGetUI.Core.SettingsEngine.Tests; + +public sealed class SecureSettingsTests : IDisposable +{ + private readonly string _testRoot; + + public SecureSettingsTests() + { + _testRoot = Path.Combine(Path.GetTempPath(), $"UniGetUI-SecureSettingsTests-{Guid.NewGuid():N}"); + Directory.CreateDirectory(_testRoot); + SecureSettingsStore.TEST_SecureSettingsRootOverride = _testRoot; + ClearSecureSettingsCache(); + } + + public void Dispose() + { + ClearSecureSettingsCache(); + SecureSettingsStore.TEST_SecureSettingsRootOverride = null; + + if (Directory.Exists(_testRoot)) + { + Directory.Delete(_testRoot, true); + } + } + + [Theory] + [InlineData(SecureSettingsStore.K.AllowCLIArguments, "AllowCLIArguments")] + [InlineData(SecureSettingsStore.K.AllowImportingCLIArguments, "AllowImportingCLIArguments")] + [InlineData(SecureSettingsStore.K.AllowPrePostOpCommand, "AllowPrePostInstallCommands")] + [InlineData(SecureSettingsStore.K.AllowImportPrePostOpCommands, "AllowImportingPrePostInstallCommands")] + [InlineData(SecureSettingsStore.K.ForceUserGSudo, "ForceUserGSudo")] + [InlineData(SecureSettingsStore.K.AllowCustomManagerPaths, "AllowCustomManagerPaths")] + public void ResolveKey_ReturnsExpectedMappings(SecureSettingsStore.K key, string expected) + { + Assert.Equal(expected, SecureSettingsStore.ResolveKey(key)); + } + + [Fact] + public void ResolveKey_ThrowsForUnsetAndUnknownKeys() + { + Assert.Throws(() => + SecureSettingsStore.ResolveKey(SecureSettingsStore.K.Unset) + ); + Assert.Throws(() => + SecureSettingsStore.ResolveKey((SecureSettingsStore.K)999) + ); + } + + [Fact] + public void Get_ReturnsFalseWhenSettingDoesNotExist() + { + Assert.False(SecureSettingsStore.Get(SecureSettingsStore.K.AllowCLIArguments)); + Assert.False(Directory.Exists(GetCurrentUserSettingsDirectory())); + } + + [Fact] + public void ApplyForUser_CreatesAndRemovesSanitizedFile() + { + const string username = "test:user?"; + const string setting = "settinginvalid|chars"; + + Assert.Equal(0, SecureSettingsStore.ApplyForUser(username, setting, true)); + Assert.True(File.Exists(GetSettingsFilePath(username, setting))); + + Assert.Equal(0, SecureSettingsStore.ApplyForUser(username, setting, false)); + Assert.False(File.Exists(GetSettingsFilePath(username, setting))); + } + + [Fact] + public void Get_RefreshesCachedValueAfterApplyForUserWrites() + { + string username = Environment.UserName; + string setting = SecureSettingsStore.ResolveKey(SecureSettingsStore.K.AllowCLIArguments); + + Assert.False(SecureSettingsStore.Get(SecureSettingsStore.K.AllowCLIArguments)); + + Assert.Equal(0, SecureSettingsStore.ApplyForUser(username, setting, true)); + Assert.True(File.Exists(GetSettingsFilePath(username, setting))); + Assert.True(SecureSettingsStore.Get(SecureSettingsStore.K.AllowCLIArguments)); + + Assert.Equal(0, SecureSettingsStore.ApplyForUser(username, setting, false)); + Assert.False(File.Exists(GetSettingsFilePath(username, setting))); + Assert.False(SecureSettingsStore.Get(SecureSettingsStore.K.AllowCLIArguments)); + } + + private string GetCurrentUserSettingsDirectory() => + Path.Combine(_testRoot, CoreTools.MakeValidFileName(Environment.UserName)); + + private string GetSettingsFilePath(string username, string setting) => + Path.Combine( + _testRoot, + CoreTools.MakeValidFileName(username), + CoreTools.MakeValidFileName(setting) + ); + + private static void ClearSecureSettingsCache() + { + FieldInfo? cacheField = typeof(SecureSettingsStore).GetField( + "_cache", + BindingFlags.NonPublic | BindingFlags.Static + ); + Assert.NotNull(cacheField); + + var cache = Assert.IsType>(cacheField.GetValue(null)); + cache.Clear(); + } +} diff --git a/src/UniGetUI.Core.Settings.Tests/SettingsImportExportTests.cs b/src/UniGetUI.Core.Settings.Tests/SettingsImportExportTests.cs new file mode 100644 index 0000000000..5dfa26833e --- /dev/null +++ b/src/UniGetUI.Core.Settings.Tests/SettingsImportExportTests.cs @@ -0,0 +1,83 @@ +using System.Text.Json; +using UniGetUI.Core.Data; + +namespace UniGetUI.Core.SettingsEngine.Tests; + +public sealed class SettingsImportExportTests : IDisposable +{ + private readonly string _testRoot = Path.Combine( + Path.GetTempPath(), + nameof(SettingsImportExportTests), + Guid.NewGuid().ToString("N") + ); + + public SettingsImportExportTests() + { + Directory.CreateDirectory(_testRoot); + CoreData.TEST_DataDirectoryOverride = Path.Combine(_testRoot, "Data"); + Directory.CreateDirectory(CoreData.UniGetUIUserConfigurationDirectory); + Settings.ResetSettings(); + } + + public void Dispose() + { + Settings.ResetSettings(); + CoreData.TEST_DataDirectoryOverride = null; + if (Directory.Exists(_testRoot)) + { + Directory.Delete(_testRoot, recursive: true); + } + } + + [Fact] + public void ExportToStringJson_ExcludesSensitiveFiles() + { + Settings.Set(Settings.K.FreshBoolSetting, true); + Settings.SetValue(Settings.K.FreshValue, "configured"); + File.WriteAllText(Path.Combine(CoreData.UniGetUIUserConfigurationDirectory, "TelemetryClientToken"), "secret"); + File.WriteAllText(Path.Combine(CoreData.UniGetUIUserConfigurationDirectory, "CurrentSessionToken"), "secret"); + + var exported = JsonSerializer.Deserialize>(Settings.ExportToString_JSON()); + + Assert.NotNull(exported); + Assert.Contains(Settings.ResolveKey(Settings.K.FreshBoolSetting), exported.Keys); + Assert.Equal("configured", exported[Settings.ResolveKey(Settings.K.FreshValue)]); + Assert.DoesNotContain("TelemetryClientToken", exported.Keys); + Assert.DoesNotContain("CurrentSessionToken", exported.Keys); + } + + [Fact] + public void ImportFromStringJson_ResetsExistingFilesAndReloadsCache() + { + Settings.Set(Settings.K.Test1, true); + Settings.SetValue(Settings.K.FreshValue, "old-value"); + + string importedJson = JsonSerializer.Serialize( + new Dictionary + { + [Settings.ResolveKey(Settings.K.Test2)] = "", + [Settings.ResolveKey(Settings.K.FreshValue)] = "new-value", + } + ); + + Settings.ImportFromString_JSON(importedJson); + + Assert.False(File.Exists(Path.Combine(CoreData.UniGetUIUserConfigurationDirectory, Settings.ResolveKey(Settings.K.Test1)))); + Assert.True(Settings.Get(Settings.K.Test2)); + Assert.Equal("new-value", Settings.GetValue(Settings.K.FreshValue)); + } + + [Fact] + public void ImportFromFileJson_CopiesSourceWhenBackupLivesInSettingsDirectory() + { + Settings.SetValue(Settings.K.FreshValue, "before-import"); + string exportPath = Path.Combine(CoreData.UniGetUIUserConfigurationDirectory, "settings-backup.json"); + + Settings.ExportToFile_JSON(exportPath); + Settings.SetValue(Settings.K.FreshValue, "after-export"); + + Settings.ImportFromFile_JSON(exportPath); + + Assert.Equal("before-import", Settings.GetValue(Settings.K.FreshValue)); + } +} diff --git a/src/UniGetUI.Core.Settings.Tests/TestAssembly.cs b/src/UniGetUI.Core.Settings.Tests/TestAssembly.cs new file mode 100644 index 0000000000..217120083b --- /dev/null +++ b/src/UniGetUI.Core.Settings.Tests/TestAssembly.cs @@ -0,0 +1,3 @@ +using Xunit; + +[assembly: CollectionBehavior(DisableTestParallelization = true)] diff --git a/src/UniGetUI.Core.Settings.Tests/UniGetUI.Core.Settings.Tests.csproj b/src/UniGetUI.Core.Settings.Tests/UniGetUI.Core.Settings.Tests.csproj index 70793a2084..7fdd8a3d6f 100644 --- a/src/UniGetUI.Core.Settings.Tests/UniGetUI.Core.Settings.Tests.csproj +++ b/src/UniGetUI.Core.Settings.Tests/UniGetUI.Core.Settings.Tests.csproj @@ -24,6 +24,7 @@ + diff --git a/src/UniGetUI.Core.Settings/SettingsEngine_ImportExport.cs b/src/UniGetUI.Core.Settings/SettingsEngine_ImportExport.cs index 877c007c0c..f4be6a8b84 100644 --- a/src/UniGetUI.Core.Settings/SettingsEngine_ImportExport.cs +++ b/src/UniGetUI.Core.Settings/SettingsEngine_ImportExport.cs @@ -77,6 +77,11 @@ public static void ImportFromString_JSON(string jsonContent) public static void ResetSettings() { + booleanSettings.Clear(); + valueSettings.Clear(); + listSettings.Clear(); + _dictionarySettings.Clear(); + foreach ( string entry in Directory.EnumerateFiles(CoreData.UniGetUIUserConfigurationDirectory) ) diff --git a/src/UniGetUI.Interface.Telemetry.Tests/TelemetryHandlerTests.cs b/src/UniGetUI.Interface.Telemetry.Tests/TelemetryHandlerTests.cs new file mode 100644 index 0000000000..7f130ab1ac --- /dev/null +++ b/src/UniGetUI.Interface.Telemetry.Tests/TelemetryHandlerTests.cs @@ -0,0 +1,327 @@ +using System.Net; +using System.Reflection; +using System.Text; +using System.Text.Json; +using UniGetUI.Core.Data; +using UniGetUI.Core.Logging; +using UniGetUI.Core.SettingsEngine; +using UniGetUI.PackageEngine.Classes.Manager; +using UniGetUI.PackageEngine.Enums; +using UniGetUI.PackageEngine.PackageClasses; + +[assembly: CollectionBehavior(DisableTestParallelization = true)] + +namespace UniGetUI.Interface.Telemetry.Tests; + +public sealed class TelemetryHandlerTests : IDisposable +{ + private const string KnownInstallId = + "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef"; + + private readonly string _testRoot; + private readonly string _portableMarkerPath; + private readonly bool _originalWasDaemon; + + public TelemetryHandlerTests() + { + _testRoot = Path.Combine( + AppContext.BaseDirectory, + nameof(TelemetryHandlerTests), + Guid.NewGuid().ToString("N") + ); + _portableMarkerPath = Path.Combine(Environment.CurrentDirectory, "ForceUniGetUIPortable"); + _originalWasDaemon = CoreData.WasDaemon; + + CoreData.TEST_DataDirectoryOverride = Path.Combine(_testRoot, "Data"); + Directory.CreateDirectory(CoreData.UniGetUIUserConfigurationDirectory); + + ClearSettingsCaches(); + Settings.ResetSettings(); + Settings.Set(Settings.K.DisableTelemetry, false); + Settings.Set(Settings.K.DisableWaitForInternetConnection, true); + Settings.SetValue(Settings.K.TelemetryClientToken, KnownInstallId); + + TelemetryHandler.ResetTestState(); + File.Delete(_portableMarkerPath); + CoreData.WasDaemon = false; + } + + public void Dispose() + { + TelemetryHandler.ResetTestState(); + ClearSettingsCaches(); + CoreData.TEST_DataDirectoryOverride = null; + CoreData.WasDaemon = _originalWasDaemon; + + if (File.Exists(_portableMarkerPath)) + { + File.Delete(_portableMarkerPath); + } + + if (Directory.Exists(_testRoot)) + { + Directory.Delete(_testRoot, true); + } + } + + [Fact] + public async Task InitializeAsync_WithoutCredentials_DoesNotSendAndLogsOnce() + { + int sendCount = 0; + TelemetryHandler.TestSendAsyncOverride = _ => + { + sendCount++; + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)); + }; + + int warningCountBefore = Logger + .GetLogs() + .Count(log => log.Content.Contains("OpenSearch credentials are not configured")); + + await TelemetryHandler.InitializeAsync(); + await TelemetryHandler.InitializeAsync(); + + int warningCountAfter = Logger + .GetLogs() + .Count(log => log.Content.Contains("OpenSearch credentials are not configured")); + + Assert.Equal(0, sendCount); + Assert.Equal(warningCountBefore + 1, warningCountAfter); + } + + [Fact] + public void ComputeActiveSettingsBitmask_IncludesDeterministicSettingsAndSpecialPaths() + { + Settings.Set(Settings.K.DisableAutoUpdateWingetUI, false); + Settings.Set(Settings.K.EnableUniGetUIBeta, true); + Settings.Set(Settings.K.DisableSystemTray, true); + Settings.Set(Settings.K.DisableNotifications, true); + Settings.Set(Settings.K.DisableAutoCheckforUpdates, false); + Settings.Set(Settings.K.AutomaticallyUpdatePackages, true); + Settings.Set(Settings.K.AskToDeleteNewDesktopShortcuts, false); + Settings.Set(Settings.K.EnablePackageBackup_LOCAL, false); + Settings.Set(Settings.K.DoCacheAdminRights, false); + Settings.Set(Settings.K.DoCacheAdminRightsForBatches, false); + Settings.Set(Settings.K.ForceLegacyBundledWinGet, false); + Settings.Set(Settings.K.UseSystemChocolatey, false); + File.WriteAllText(_portableMarkerPath, string.Empty); + CoreData.WasDaemon = true; + + int activeSettings = TelemetryHandler.ComputeActiveSettingsBitmask(); + + Assert.Equal(12339, activeSettings); + } + + [Fact] + public async Task InitializeAsync_SendsActivityPayloadWithRequiredFields() + { + Settings.Set(Settings.K.DisableAutoUpdateWingetUI, false); + Settings.Set(Settings.K.EnableUniGetUIBeta, true); + Settings.Set(Settings.K.DisableSystemTray, true); + Settings.Set(Settings.K.DisableNotifications, true); + Settings.Set(Settings.K.DisableAutoCheckforUpdates, false); + Settings.Set(Settings.K.AutomaticallyUpdatePackages, true); + File.WriteAllText(_portableMarkerPath, string.Empty); + CoreData.WasDaemon = true; + + TelemetryHandler.Configure("telemetry-user", "telemetry-pass"); + var captured = await CaptureRequestAsync(TelemetryHandler.InitializeAsync); + + using var json = JsonDocument.Parse(captured.Body); + JsonElement root = json.RootElement; + + Assert.Contains("activity_events", captured.RequestUri.AbsoluteUri); + Assert.Equal("Basic", captured.Authorization?.Scheme); + Assert.Equal( + Convert.ToBase64String(Encoding.UTF8.GetBytes("telemetry-user:telemetry-pass")), + captured.Authorization?.Parameter + ); + Assert.NotEmpty(root.GetProperty("eventID").GetString()); + Assert.NotEqual(default, root.GetProperty("eventDate").GetDateTime()); + Assert.Equal(KnownInstallId, root.GetProperty("installID").GetString()); + Assert.True(root.TryGetProperty("enabledManagers", out _)); + Assert.True(root.TryGetProperty("foundManagers", out _)); + Assert.Equal(12339, root.GetProperty("activeSettings").GetInt32()); + Assert.Equal("UniGetUI", root.GetProperty("application").GetProperty("name").GetString()); + Assert.Equal("NotApplicable", root.GetProperty("application").GetProperty("dataSource").GetString()); + Assert.Equal("Free", root.GetProperty("application").GetProperty("pricing").GetString()); + Assert.NotEmpty(root.GetProperty("platform").GetProperty("name").GetString()); + } + + [Fact] + public async Task InstallPackage_SendsPackagePayloadWithResultAndReferral() + { + TelemetryHandler.Configure("telemetry-user", "telemetry-pass"); + var package = new Package( + "Telemetry Package", + "Telemetry.Package", + "1.0.0", + new NullSource("Telemetry Source"), + NullPackageManager.Instance + ); + + var captured = await CaptureRequestAsync(() => + { + TelemetryHandler.InstallPackage( + package, + TEL_OP_RESULT.SUCCESS, + TEL_InstallReferral.FROM_BUNDLE + ); + }); + + using var json = JsonDocument.Parse(captured.Body); + JsonElement root = json.RootElement; + + Assert.Contains("package_events", captured.RequestUri.AbsoluteUri); + Assert.Equal("install", root.GetProperty("operation").GetString()); + Assert.Equal(package.Id, root.GetProperty("packageId").GetString()); + Assert.Equal(package.Manager.Name, root.GetProperty("managerName").GetString()); + Assert.Equal(package.Source.Name, root.GetProperty("sourceName").GetString()); + Assert.Equal(TEL_OP_RESULT.SUCCESS.ToString(), root.GetProperty("operationResult").GetString()); + Assert.Equal( + TEL_InstallReferral.FROM_BUNDLE.ToString(), + root.GetProperty("eventSource").GetString() + ); + Assert.Equal(KnownInstallId, root.GetProperty("installID").GetString()); + } + + [Fact] + public async Task PackageDetails_SendsEventSourceWithoutOperationResult() + { + TelemetryHandler.Configure("telemetry-user", "telemetry-pass"); + var package = new Package( + "Telemetry Package", + "Telemetry.Package", + "1.0.0", + new NullSource("Telemetry Source"), + NullPackageManager.Instance + ); + + var captured = await CaptureRequestAsync(() => + { + TelemetryHandler.PackageDetails(package, "DIRECT_LINK"); + }); + + using var json = JsonDocument.Parse(captured.Body); + JsonElement root = json.RootElement; + + Assert.Equal("details", root.GetProperty("operation").GetString()); + Assert.Equal("DIRECT_LINK", root.GetProperty("eventSource").GetString()); + Assert.False(root.TryGetProperty("operationResult", out _)); + } + + [Fact] + public async Task BundleOperations_SendExpectedRoutingPayloads() + { + TelemetryHandler.Configure("telemetry-user", "telemetry-pass"); + var captured = await CaptureRequestsAsync( + expectedCount: 3, + trigger: () => + { + TelemetryHandler.ImportBundle(BundleFormatType.JSON); + TelemetryHandler.ExportBundle(BundleFormatType.UBUNDLE); + TelemetryHandler.ExportBatch(); + } + ); + + var actualRoutes = captured + .Select(request => + { + using JsonDocument json = JsonDocument.Parse(request.Body); + JsonElement root = json.RootElement; + return ( + Operation: root.GetProperty("operation").GetString(), + BundleType: root.GetProperty("bundleType").GetString() + ); + }) + .OrderBy(route => route.Operation, StringComparer.Ordinal) + .ThenBy(route => route.BundleType, StringComparer.Ordinal) + .ToArray(); + + Assert.Equal( + [ + ("export", "PS1_SCRIPT"), + ("export", BundleFormatType.UBUNDLE.ToString()), + ("import", BundleFormatType.JSON.ToString()), + ], + actualRoutes + ); + } + + private static async Task CaptureRequestAsync(Func trigger) + { + TaskCompletionSource completionSource = + new(TaskCreationOptions.RunContinuationsAsynchronously); + TelemetryHandler.TestSendAsyncOverride = async request => + { + completionSource.TrySetResult(await CapturedRequest.CreateAsync(request)); + return new HttpResponseMessage(HttpStatusCode.OK); + }; + + await trigger(); + return await completionSource.Task.WaitAsync(TimeSpan.FromSeconds(5)); + } + + private static Task CaptureRequestAsync(Action trigger) => + CaptureRequestAsync(() => + { + trigger(); + return Task.CompletedTask; + }); + + private static async Task> CaptureRequestsAsync( + int expectedCount, + Action trigger + ) + { + List captured = []; + Lock sync = new(); + TaskCompletionSource> completionSource = + new(TaskCreationOptions.RunContinuationsAsynchronously); + + TelemetryHandler.TestSendAsyncOverride = async request => + { + CapturedRequest capturedRequest = await CapturedRequest.CreateAsync(request); + lock (sync) + { + captured.Add(capturedRequest); + if (captured.Count == expectedCount) + { + completionSource.TrySetResult(captured.ToArray()); + } + } + + return new HttpResponseMessage(HttpStatusCode.OK); + }; + + trigger(); + return await completionSource.Task.WaitAsync(TimeSpan.FromSeconds(5)); + } + + private static void ClearSettingsCaches() + { + ClearDictionaryField("booleanSettings"); + ClearDictionaryField("valueSettings"); + } + + private static void ClearDictionaryField(string fieldName) + { + FieldInfo field = typeof(Settings).GetField(fieldName, BindingFlags.NonPublic | BindingFlags.Static)!; + object dictionary = field.GetValue(null)!; + dictionary.GetType().GetMethod("Clear")!.Invoke(dictionary, null); + } + + private sealed record CapturedRequest( + Uri RequestUri, + string Body, + System.Net.Http.Headers.AuthenticationHeaderValue? Authorization + ) + { + public static async Task CreateAsync(HttpRequestMessage request) => + new( + request.RequestUri!, + request.Content is null ? string.Empty : await request.Content.ReadAsStringAsync(), + request.Headers.Authorization + ); + } +} diff --git a/src/UniGetUI.Interface.Telemetry.Tests/UniGetUI.Interface.Telemetry.Tests.csproj b/src/UniGetUI.Interface.Telemetry.Tests/UniGetUI.Interface.Telemetry.Tests.csproj new file mode 100644 index 0000000000..63026ec87e --- /dev/null +++ b/src/UniGetUI.Interface.Telemetry.Tests/UniGetUI.Interface.Telemetry.Tests.csproj @@ -0,0 +1,41 @@ + + + $(PortableTargetFramework) + + false + true + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + + + + + + diff --git a/src/UniGetUI.Interface.Telemetry/InternalsVisibleTo.cs b/src/UniGetUI.Interface.Telemetry/InternalsVisibleTo.cs new file mode 100644 index 0000000000..569ff00f15 --- /dev/null +++ b/src/UniGetUI.Interface.Telemetry/InternalsVisibleTo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("UniGetUI.Interface.Telemetry.Tests")] diff --git a/src/UniGetUI.Interface.Telemetry/TelemetryHandler.cs b/src/UniGetUI.Interface.Telemetry/TelemetryHandler.cs index a63caa4ab5..0c02104c99 100644 --- a/src/UniGetUI.Interface.Telemetry/TelemetryHandler.cs +++ b/src/UniGetUI.Interface.Telemetry/TelemetryHandler.cs @@ -36,6 +36,7 @@ public static class TelemetryHandler private static string _openSearchUsername = ""; private static string _openSearchPassword = ""; private static bool _credentialsWarningLogged; + internal static Func>? TestSendAsyncOverride; public static void Configure(string username, string password) { @@ -119,45 +120,13 @@ public static async Task InitializeAsync() .Select(m => m.Name) .ToArray(); - int settingsMagicValue = 0; - int mask = 0x1; - foreach (var setting in SettingsToSend) - { - bool enabled = Settings.Get( - key: setting, - invert: Settings.ResolveKey(setting).StartsWith("Disable")); - - if (enabled) - settingsMagicValue |= mask; - mask <<= 1; - - if (mask == 0x1) - throw new OverflowException(); - } - foreach (var sp in new[] { "SP1", "SP2" }) - { - bool enabled = sp switch - { - "SP1" => File.Exists("ForceUniGetUIPortable"), - "SP2" => CoreData.WasDaemon, - _ => throw new NotImplementedException(), - }; - - if (enabled) - settingsMagicValue |= mask; - mask <<= 1; - - if (mask == 0x1) - throw new OverflowException(); - } - var ev = new UniGetUIActivityEvent { InstallID = GetRandomizedId(), Locale = LanguageEngine.SelectedLocale, EnabledManagers = enabledManagers, FoundManagers = foundManagers, - ActiveSettings = settingsMagicValue, + ActiveSettings = ComputeActiveSettingsBitmask(), Application = BuildApplicationInfo(), Platform = BuildPlatformInfo(), }; @@ -171,6 +140,51 @@ public static async Task InitializeAsync() } } + internal static int ComputeActiveSettingsBitmask() + { + int settingsMagicValue = 0; + int mask = 0x1; + foreach (var setting in SettingsToSend) + { + bool enabled = Settings.Get( + key: setting, + invert: Settings.ResolveKey(setting).StartsWith("Disable")); + + if (enabled) + settingsMagicValue |= mask; + mask <<= 1; + + if (mask == 0x1) + throw new OverflowException(); + } + foreach (var sp in new[] { "SP1", "SP2" }) + { + bool enabled = sp switch + { + "SP1" => File.Exists("ForceUniGetUIPortable"), + "SP2" => CoreData.WasDaemon, + _ => throw new NotImplementedException(), + }; + + if (enabled) + settingsMagicValue |= mask; + mask <<= 1; + + if (mask == 0x1) + throw new OverflowException(); + } + + return settingsMagicValue; + } + + internal static void ResetTestState() + { + _openSearchUsername = ""; + _openSearchPassword = ""; + _credentialsWarningLogged = false; + TestSendAsyncOverride = null; + } + // ------------------------------------------------------------------------- public static void InstallPackage( @@ -296,7 +310,9 @@ private static async Task PostToOpenSearchAsync(string indexName, T eventData }; request.Headers.Authorization = new AuthenticationHeaderValue("Basic", credentials); - HttpResponseMessage response = await _httpClient.SendAsync(request); + HttpResponseMessage response = TestSendAsyncOverride is { } sendAsync + ? await sendAsync(request) + : await _httpClient.SendAsync(request); if (response.IsSuccessStatusCode) Logger.Debug($"[Telemetry] Sent to {fullIndex}"); diff --git a/src/UniGetUI.PackageEngine.Managers.Cargo/CratesIOClient.cs b/src/UniGetUI.PackageEngine.Managers.Cargo/CratesIOClient.cs index 1be6c7e88d..95504dc411 100644 --- a/src/UniGetUI.PackageEngine.Managers.Cargo/CratesIOClient.cs +++ b/src/UniGetUI.PackageEngine.Managers.Cargo/CratesIOClient.cs @@ -63,10 +63,11 @@ internal sealed class CargoManifestPublisher internal sealed class CratesIOClient { public const string ApiUrl = "https://crates.io/api/v1"; + internal static string? TEST_ApiUrlOverride { private get; set; } public static Tuple GetManifest(string packageId) { - var manifestUrl = new Uri($"{ApiUrl}/crates/{packageId}"); + var manifestUrl = new Uri($"{GetApiUrl()}/crates/{packageId}"); var manifest = Fetch(manifestUrl); if (manifest.crate is null) { @@ -77,7 +78,7 @@ public static Tuple GetManifest(string packageId) public static CargoManifestVersion GetManifestVersion(string packageId, string version) { - var manifestUrl = new Uri($"{ApiUrl}/crates/{packageId}/{version}"); + var manifestUrl = new Uri($"{GetApiUrl()}/crates/{packageId}/{version}"); var manifest = Fetch(manifestUrl); if (manifest.version is null) { @@ -86,7 +87,9 @@ public static CargoManifestVersion GetManifestVersion(string packageId, string v return manifest.version; } - private static T Fetch(Uri url) + private static string GetApiUrl() => TEST_ApiUrlOverride ?? ApiUrl; + + internal static T Fetch(Uri url) { HttpClient client = new(CoreTools.GenericHttpClientParameters); client.DefaultRequestHeaders.UserAgent.ParseAdd(CoreData.UserAgentString); diff --git a/src/UniGetUI.PackageEngine.Managers.Cargo/InternalsVisibleTo.cs b/src/UniGetUI.PackageEngine.Managers.Cargo/InternalsVisibleTo.cs new file mode 100644 index 0000000000..eeb63dad19 --- /dev/null +++ b/src/UniGetUI.PackageEngine.Managers.Cargo/InternalsVisibleTo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("UniGetUI.PackageEngine.Tests")] diff --git a/src/UniGetUI.PackageEngine.Managers.Chocolatey/Chocolatey.cs b/src/UniGetUI.PackageEngine.Managers.Chocolatey/Chocolatey.cs index 1f0445a0e7..76331f3300 100644 --- a/src/UniGetUI.PackageEngine.Managers.Chocolatey/Chocolatey.cs +++ b/src/UniGetUI.PackageEngine.Managers.Chocolatey/Chocolatey.cs @@ -145,6 +145,94 @@ public static string GetProxyArgument() + $" --proxy-password={Uri.EscapeDataString(creds.Password)}"; } + internal IReadOnlyList ParseAvailableUpdates(IEnumerable lines) + { + List packages = []; + foreach (string line in lines) + { + if (line.StartsWith("Chocolatey")) + { + continue; + } + + string[] elements = line.Split('|'); + for (int i = 0; i < elements.Length; i++) + { + elements[i] = elements[i].Trim(); + } + + if (elements.Length <= 2) + { + continue; + } + + if ( + FALSE_PACKAGE_IDS.Contains(elements[0]) + || FALSE_PACKAGE_VERSIONS.Contains(elements[1]) + || elements[1] == elements[2] + ) + { + continue; + } + + packages.Add( + new Package( + CoreTools.FormatAsName(elements[0]), + elements[0], + elements[1], + elements[2], + DefaultSource, + this + ) + ); + } + + return packages; + } + + internal IReadOnlyList ParseInstalledPackages(IEnumerable lines) + { + List packages = []; + foreach (string line in lines) + { + if (line.StartsWith("Chocolatey")) + { + continue; + } + + string[] elements = line.Split(' '); + for (int i = 0; i < elements.Length; i++) + { + elements[i] = elements[i].Trim(); + } + + if (elements.Length <= 1) + { + continue; + } + + if ( + FALSE_PACKAGE_IDS.Contains(elements[0]) + || FALSE_PACKAGE_VERSIONS.Contains(elements[1]) + ) + { + continue; + } + + packages.Add( + new Package( + CoreTools.FormatAsName(elements[0]), + elements[0], + elements[1], + DefaultSource, + this + ) + ); + } + + return packages; + } + protected override IReadOnlyList GetAvailableUpdates_UnSafe() { using Process p = new() @@ -166,50 +254,18 @@ protected override IReadOnlyList GetAvailableUpdates_UnSafe() p.Start(); string? line; - List Packages = []; + List lines = []; while ((line = p.StandardOutput.ReadLine()) is not null) { logger.AddToStdOut(line); - if (!line.StartsWith("Chocolatey")) - { - string[] elements = line.Split('|'); - for (int i = 0; i < elements.Length; i++) - { - elements[i] = elements[i].Trim(); - } - - if (elements.Length <= 2) - { - continue; - } - - if ( - FALSE_PACKAGE_IDS.Contains(elements[0]) - || FALSE_PACKAGE_VERSIONS.Contains(elements[1]) - || elements[1] == elements[2] - ) - { - continue; - } - - Packages.Add( - new Package( - CoreTools.FormatAsName(elements[0]), - elements[0], - elements[1], - elements[2], - DefaultSource, - this - ) - ); - } + lines.Add(line); } logger.AddToStdErr(p.StandardError.ReadToEnd()); p.WaitForExit(); logger.Close(p.ExitCode); - return Packages; + return ParseAvailableUpdates(lines); } protected override IReadOnlyList _getInstalledPackages_UnSafe() @@ -236,48 +292,18 @@ protected override IReadOnlyList _getInstalledPackages_UnSafe() p.Start(); string? line; - List Packages = []; + List lines = []; while ((line = p.StandardOutput.ReadLine()) is not null) { logger.AddToStdOut(line); - if (!line.StartsWith("Chocolatey")) - { - string[] elements = line.Split(' '); - for (int i = 0; i < elements.Length; i++) - { - elements[i] = elements[i].Trim(); - } - - if (elements.Length <= 1) - { - continue; - } - - if ( - FALSE_PACKAGE_IDS.Contains(elements[0]) - || FALSE_PACKAGE_VERSIONS.Contains(elements[1]) - ) - { - continue; - } - - Packages.Add( - new Package( - CoreTools.FormatAsName(elements[0]), - elements[0], - elements[1], - DefaultSource, - this - ) - ); - } + lines.Add(line); } logger.AddToStdErr(p.StandardError.ReadToEnd()); p.WaitForExit(); logger.Close(p.ExitCode); - return Packages; + return ParseInstalledPackages(lines); } public override IReadOnlyList FindCandidateExecutableFiles() diff --git a/src/UniGetUI.PackageEngine.Managers.Chocolatey/Helpers/ChocolateyDetailsHelper.cs b/src/UniGetUI.PackageEngine.Managers.Chocolatey/Helpers/ChocolateyDetailsHelper.cs index bc0cc244d4..1b502f6971 100644 --- a/src/UniGetUI.PackageEngine.Managers.Chocolatey/Helpers/ChocolateyDetailsHelper.cs +++ b/src/UniGetUI.PackageEngine.Managers.Chocolatey/Helpers/ChocolateyDetailsHelper.cs @@ -12,6 +12,20 @@ public class ChocolateyDetailsHelper : BaseNuGetDetailsHelper public ChocolateyDetailsHelper(BaseNuGet manager) : base(manager) { } + internal static IReadOnlyList ParseInstallableVersions(IEnumerable lines) + { + List versions = []; + foreach (string line in lines) + { + if (line.Contains("[Approved]")) + { + versions.Add(line.Split(' ')[1].Trim()); + } + } + + return versions; + } + protected override IReadOnlyList GetInstallableVersions_UnSafe(IPackage package) { using Process p = new() @@ -40,20 +54,17 @@ protected override IReadOnlyList GetInstallableVersions_UnSafe(IPackage p.Start(); string? line; - List versions = []; + List lines = []; while ((line = p.StandardOutput.ReadLine()) is not null) { logger.AddToStdOut(line); - if (line.Contains("[Approved]")) - { - versions.Add(line.Split(' ')[1].Trim()); - } + lines.Add(line); } logger.AddToStdErr(p.StandardError.ReadToEnd()); p.WaitForExit(); logger.Close(p.ExitCode); - return versions; + return ParseInstallableVersions(lines); } protected override string? GetInstallLocation_UnSafe(IPackage package) diff --git a/src/UniGetUI.PackageEngine.Managers.Chocolatey/Helpers/ChocolateySourceHelper.cs b/src/UniGetUI.PackageEngine.Managers.Chocolatey/Helpers/ChocolateySourceHelper.cs index e853958236..b8d951c648 100644 --- a/src/UniGetUI.PackageEngine.Managers.Chocolatey/Helpers/ChocolateySourceHelper.cs +++ b/src/UniGetUI.PackageEngine.Managers.Chocolatey/Helpers/ChocolateySourceHelper.cs @@ -51,8 +51,6 @@ string[] Output protected override IReadOnlyList GetSources_UnSafe() { - List sources = []; - using Process p = new() { StartInfo = new() @@ -78,9 +76,26 @@ protected override IReadOnlyList GetSources_UnSafe() p.Start(); string? line; + List lines = []; while ((line = p.StandardOutput.ReadLine()) is not null) { logger.AddToStdOut(line); + lines.Add(line); + } + + logger.AddToStdErr(p.StandardError.ReadToEnd()); + p.WaitForExit(); + logger.Close(p.ExitCode); + + return ParseSources(lines); + } + + internal IReadOnlyList ParseSources(IEnumerable lines) + { + List sources = []; + + foreach (string line in lines) + { try { if (string.IsNullOrEmpty(line)) @@ -116,16 +131,12 @@ protected override IReadOnlyList GetSources_UnSafe() } } } - catch (Exception e) + catch { - logger.AddToStdErr(e.ToString()); + continue; } } - logger.AddToStdErr(p.StandardError.ReadToEnd()); - p.WaitForExit(); - logger.Close(p.ExitCode); - return sources; } } diff --git a/src/UniGetUI.PackageEngine.Managers.Chocolatey/InternalsVisibleTo.cs b/src/UniGetUI.PackageEngine.Managers.Chocolatey/InternalsVisibleTo.cs new file mode 100644 index 0000000000..eeb63dad19 --- /dev/null +++ b/src/UniGetUI.PackageEngine.Managers.Chocolatey/InternalsVisibleTo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("UniGetUI.PackageEngine.Tests")] diff --git a/src/UniGetUI.PackageEngine.Managers.Dotnet/DotNet.cs b/src/UniGetUI.PackageEngine.Managers.Dotnet/DotNet.cs index a7d7f4db23..f8ad3a5c4a 100644 --- a/src/UniGetUI.PackageEngine.Managers.Dotnet/DotNet.cs +++ b/src/UniGetUI.PackageEngine.Managers.Dotnet/DotNet.cs @@ -5,6 +5,7 @@ using UniGetUI.PackageEngine.Classes.Manager; using UniGetUI.PackageEngine.Classes.Manager.ManagerHelpers; using UniGetUI.PackageEngine.Enums; +using UniGetUI.PackageEngine.Interfaces; using UniGetUI.PackageEngine.ManagerClasses.Classes; using UniGetUI.PackageEngine.ManagerClasses.Manager; using UniGetUI.PackageEngine.Managers.Chocolatey; @@ -105,54 +106,18 @@ protected override IReadOnlyList _getInstalledPackages_UnSafe() ); p.Start(); + List outputLines = []; string? line; - bool DashesPassed = false; while ((line = p.StandardOutput.ReadLine()) is not null) { logger.AddToStdOut(line); - if (!DashesPassed) - { - if (line.Contains("----")) - { - DashesPassed = true; - } - } - else - { - string[] elements = Regex.Replace(line, " {2,}", " ").Split(' '); - if (elements.Length < 2) - { - continue; - } - - for (int i = 0; i < elements.Length; i++) - { - elements[i] = elements[i].Trim(); - } - - if ( - FALSE_PACKAGE_IDS.Contains(elements[0]) - || FALSE_PACKAGE_VERSIONS.Contains(elements[1]) - ) - { - continue; - } - - Packages.Add( - new Package( - CoreTools.FormatAsName(elements[0]), - elements[0], - elements[1], - DefaultSource, - this, - options - ) - ); - } + outputLines.Add(line); } logger.AddToStdErr(p.StandardError.ReadToEnd()); p.WaitForExit(); logger.Close(p.ExitCode); + + Packages.AddRange(ParseInstalledPackages(outputLines, DefaultSource, this, options)); } return Packages; } @@ -211,5 +176,61 @@ protected override void _loadManagerVersion(out string version) process.Start(); version = process.StandardOutput.ReadToEnd().Trim(); } + + internal static IReadOnlyList ParseInstalledPackages( + IEnumerable outputLines, + IManagerSource source, + IPackageManager manager, + OverridenInstallationOptions options + ) + { + List packages = []; + bool dashesPassed = false; + + foreach (string rawLine in outputLines) + { + if (!dashesPassed) + { + if (rawLine.Contains("----")) + { + dashesPassed = true; + } + + continue; + } + + string[] elements = Regex.Replace(rawLine, " {2,}", " ").Split(' '); + if (elements.Length < 2) + { + continue; + } + + for (int i = 0; i < elements.Length; i++) + { + elements[i] = elements[i].Trim(); + } + + if ( + FALSE_PACKAGE_IDS.Contains(elements[0]) + || FALSE_PACKAGE_VERSIONS.Contains(elements[1]) + ) + { + continue; + } + + packages.Add( + new Package( + CoreTools.FormatAsName(elements[0]), + elements[0], + elements[1], + source, + manager, + options + ) + ); + } + + return packages; + } } } diff --git a/src/UniGetUI.PackageEngine.Managers.Dotnet/InternalsVisibleTo.cs b/src/UniGetUI.PackageEngine.Managers.Dotnet/InternalsVisibleTo.cs new file mode 100644 index 0000000000..eeb63dad19 --- /dev/null +++ b/src/UniGetUI.PackageEngine.Managers.Dotnet/InternalsVisibleTo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("UniGetUI.PackageEngine.Tests")] diff --git a/src/UniGetUI.PackageEngine.Managers.Generic.NuGet/InternalsVisibleTo.cs b/src/UniGetUI.PackageEngine.Managers.Generic.NuGet/InternalsVisibleTo.cs new file mode 100644 index 0000000000..eeb63dad19 --- /dev/null +++ b/src/UniGetUI.PackageEngine.Managers.Generic.NuGet/InternalsVisibleTo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("UniGetUI.PackageEngine.Tests")] diff --git a/src/UniGetUI.PackageEngine.Managers.Npm/InternalsVisibleTo.cs b/src/UniGetUI.PackageEngine.Managers.Npm/InternalsVisibleTo.cs new file mode 100644 index 0000000000..eeb63dad19 --- /dev/null +++ b/src/UniGetUI.PackageEngine.Managers.Npm/InternalsVisibleTo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("UniGetUI.PackageEngine.Tests")] diff --git a/src/UniGetUI.PackageEngine.Managers.Npm/Npm.cs b/src/UniGetUI.PackageEngine.Managers.Npm/Npm.cs index 26356531a7..6532237b35 100644 --- a/src/UniGetUI.PackageEngine.Managers.Npm/Npm.cs +++ b/src/UniGetUI.PackageEngine.Managers.Npm/Npm.cs @@ -7,6 +7,7 @@ using UniGetUI.PackageEngine.Classes.Manager; using UniGetUI.PackageEngine.Classes.Manager.ManagerHelpers; using UniGetUI.PackageEngine.Enums; +using UniGetUI.PackageEngine.Interfaces; using UniGetUI.PackageEngine.ManagerClasses.Classes; using UniGetUI.PackageEngine.ManagerClasses.Manager; using UniGetUI.PackageEngine.PackageClasses; @@ -79,49 +80,7 @@ protected override IReadOnlyList FindPackages_UnSafe(string query) p.WaitForExit(); logger.Close(p.ExitCode); - List Packages = []; - - void TryAdd(JsonNode? node) - { - string? id = node?["name"]?.ToString(); - string? version = node?["version"]?.ToString(); - if (id is not null && version is not null) - Packages.Add(new Package(CoreTools.FormatAsName(id), id, version, DefaultSource, this)); - } - - // npm may emit warning lines before the JSON payload (even with --json). - // npm v7+ outputs a JSON array; npm v6 and earlier outputs NDJSON (one object per line). - // Try JSON array first (find the first '['); fall back to NDJSON if parsing fails. - bool parsedAsArray = false; - int arrayStart = strContents.IndexOf('['); - if (arrayStart >= 0) - { - try - { - JsonArray? results = JsonNode.Parse(strContents[arrayStart..]) as JsonArray; - foreach (JsonNode? entry in results ?? []) - TryAdd(entry); - parsedAsArray = true; - } - catch (Exception e) - { - Logger.Warn($"npm search JSON array parse failed, falling back to NDJSON: {e.Message}"); - } - } - - if (!parsedAsArray) - { - // NDJSON fallback (npm v6): one complete JSON object per line - foreach (string line in strContents.Split('\n')) - { - string trimmed = line.Trim(); - if (!trimmed.StartsWith("{")) continue; - try { TryAdd(JsonNode.Parse(trimmed)); } - catch (Exception e) { Logger.Warn($"npm search NDJSON line parse failed: {e.Message}"); } - } - } - - return Packages; + return ParseSearchOutput(strContents, DefaultSource, this); } protected override IReadOnlyList GetAvailableUpdates_UnSafe() @@ -161,28 +120,7 @@ protected override IReadOnlyList GetAvailableUpdates_UnSafe() string strContents = p.StandardOutput.ReadToEnd(); logger.AddToStdOut(strContents); - JsonObject? contents = null; - if (strContents.Any()) - contents = JsonNode.Parse(strContents) as JsonObject; - foreach (var (packageId, packageData) in contents?.ToDictionary() ?? []) - { - string? version = packageData?["current"]?.ToString(); - string? newVersion = packageData?["latest"]?.ToString(); - if (version is not null && newVersion is not null) - { - Packages.Add( - new Package( - CoreTools.FormatAsName(packageId), - packageId, - version, - newVersion, - DefaultSource, - this, - options - ) - ); - } - } + Packages.AddRange(ParseAvailableUpdatesOutput(strContents, DefaultSource, this, options)); logger.AddToStdErr(p.StandardError.ReadToEnd()); p.WaitForExit(); @@ -231,27 +169,7 @@ protected override IReadOnlyList GetInstalledPackages_UnSafe() string strContents = p.StandardOutput.ReadToEnd(); logger.AddToStdOut(strContents); - JsonObject? contents = null; - if (strContents.Any()) - contents = - (JsonNode.Parse(strContents) as JsonObject)?["dependencies"] as JsonObject; - foreach (var (packageId, packageData) in contents?.ToDictionary() ?? []) - { - string? version = packageData?["version"]?.ToString(); - if (version is not null) - { - Packages.Add( - new Package( - CoreTools.FormatAsName(packageId), - packageId, - version, - DefaultSource, - this, - options - ) - ); - } - } + Packages.AddRange(ParseInstalledPackagesOutput(strContents, DefaultSource, this, options)); logger.AddToStdErr(p.StandardError.ReadToEnd()); p.WaitForExit(); @@ -309,5 +227,128 @@ protected override void _loadManagerVersion(out string version) version = process.StandardOutput.ReadToEnd().Trim(); process.WaitForExit(); } + + internal static IReadOnlyList ParseSearchOutput( + string output, + IManagerSource source, + IPackageManager manager + ) + { + List packages = []; + + void TryAdd(JsonNode? node) + { + string? id = node?["name"]?.ToString(); + string? version = node?["version"]?.ToString(); + if (id is not null && version is not null) + packages.Add(new Package(CoreTools.FormatAsName(id), id, version, source, manager)); + } + + bool parsedAsArray = false; + int arrayStart = output.IndexOf('['); + if (arrayStart >= 0) + { + try + { + JsonArray? results = JsonNode.Parse(output[arrayStart..]) as JsonArray; + foreach (JsonNode? entry in results ?? []) + TryAdd(entry); + parsedAsArray = true; + } + catch (Exception e) + { + Logger.Warn($"npm search JSON array parse failed, falling back to NDJSON: {e.Message}"); + } + } + + if (!parsedAsArray) + { + foreach (string line in output.Split('\n')) + { + string trimmed = line.Trim(); + if (!trimmed.StartsWith("{")) + continue; + + try + { + TryAdd(JsonNode.Parse(trimmed)); + } + catch (Exception e) + { + Logger.Warn($"npm search NDJSON line parse failed: {e.Message}"); + } + } + } + + return packages; + } + + internal static IReadOnlyList ParseAvailableUpdatesOutput( + string output, + IManagerSource source, + IPackageManager manager, + OverridenInstallationOptions options + ) + { + List packages = []; + if (!output.Any()) + return packages; + + JsonObject? contents = JsonNode.Parse(output) as JsonObject; + foreach (var (packageId, packageData) in contents?.ToDictionary() ?? []) + { + string? version = packageData?["current"]?.ToString(); + string? newVersion = packageData?["latest"]?.ToString(); + if (version is not null && newVersion is not null) + { + packages.Add( + new Package( + CoreTools.FormatAsName(packageId), + packageId, + version, + newVersion, + source, + manager, + options + ) + ); + } + } + + return packages; + } + + internal static IReadOnlyList ParseInstalledPackagesOutput( + string output, + IManagerSource source, + IPackageManager manager, + OverridenInstallationOptions options + ) + { + List packages = []; + if (!output.Any()) + return packages; + + JsonObject? contents = (JsonNode.Parse(output) as JsonObject)?["dependencies"] as JsonObject; + foreach (var (packageId, packageData) in contents?.ToDictionary() ?? []) + { + string? version = packageData?["version"]?.ToString(); + if (version is not null) + { + packages.Add( + new Package( + CoreTools.FormatAsName(packageId), + packageId, + version, + source, + manager, + options + ) + ); + } + } + + return packages; + } } } diff --git a/src/UniGetUI.PackageEngine.Managers.Pip/InternalsVisibleTo.cs b/src/UniGetUI.PackageEngine.Managers.Pip/InternalsVisibleTo.cs new file mode 100644 index 0000000000..eeb63dad19 --- /dev/null +++ b/src/UniGetUI.PackageEngine.Managers.Pip/InternalsVisibleTo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("UniGetUI.PackageEngine.Tests")] diff --git a/src/UniGetUI.PackageEngine.Managers.Pip/Pip.cs b/src/UniGetUI.PackageEngine.Managers.Pip/Pip.cs index 785afd004d..a0cb271eda 100644 --- a/src/UniGetUI.PackageEngine.Managers.Pip/Pip.cs +++ b/src/UniGetUI.PackageEngine.Managers.Pip/Pip.cs @@ -8,6 +8,7 @@ using UniGetUI.Interface.Enums; using UniGetUI.PackageEngine.Classes.Manager; using UniGetUI.PackageEngine.Enums; +using UniGetUI.PackageEngine.Interfaces; using UniGetUI.PackageEngine.ManagerClasses.Classes; using UniGetUI.PackageEngine.ManagerClasses.Manager; using UniGetUI.PackageEngine.PackageClasses; @@ -128,13 +129,7 @@ protected override IReadOnlyList FindPackages_UnSafe(string query) { string[] allNames = GetOrRefreshIndex(logger); - string queryLower = query.ToLowerInvariant(); - string[] matches = allNames - .Where(n => n.Contains(queryLower, StringComparison.OrdinalIgnoreCase)) - .OrderBy(n => n.StartsWith(queryLower, StringComparison.OrdinalIgnoreCase) ? 0 : 1) - .ThenBy(n => n.Length) - .Take(MaxSearchResults) - .ToArray(); + string[] matches = SelectSearchMatches(query, allNames); logger.Log($"Matched {matches.Length} packages for query '{query}'"); @@ -203,16 +198,7 @@ private static string[] GetOrRefreshIndex(INativeTaskLogger logger) throw new HttpRequestException($"PyPI simple index returned {(int)response.StatusCode} {response.ReasonPhrase}"); string json = response.Content.ReadAsStringAsync().GetAwaiter().GetResult(); - - var projects = (JsonNode.Parse(json) as JsonObject)?["projects"] as JsonArray; - string[] names = projects? - .Select(p => p?["name"]?.GetValue()) - .Where(n => !string.IsNullOrEmpty(n)) - .Select(n => n!) - .ToArray() ?? []; - - if (names.Length == 0) - throw new InvalidDataException("PyPI simple index returned 0 packages — response may be malformed"); + string[] names = ParseSimpleIndexProjectNames(json); logger.Log($"Downloaded {names.Length} package names from PyPI"); @@ -232,6 +218,32 @@ private static string[] GetOrRefreshIndex(INativeTaskLogger logger) return names; } + internal static string[] ParseSimpleIndexProjectNames(string json) + { + var projects = (JsonNode.Parse(json) as JsonObject)?["projects"] as JsonArray; + string[] names = projects? + .Select(p => p?["name"]?.GetValue()) + .Where(n => !string.IsNullOrEmpty(n)) + .Select(n => n!) + .ToArray() ?? []; + + if (names.Length == 0) + throw new InvalidDataException("PyPI simple index returned 0 packages — response may be malformed"); + + return names; + } + + internal static string[] SelectSearchMatches(string query, IEnumerable allNames) + { + string queryLower = query.ToLowerInvariant(); + return allNames + .Where(n => n.Contains(queryLower, StringComparison.OrdinalIgnoreCase)) + .OrderBy(n => n.StartsWith(queryLower, StringComparison.OrdinalIgnoreCase) ? 0 : 1) + .ThenBy(n => n.Length) + .Take(MaxSearchResults) + .ToArray(); + } + private static async Task FetchLatestVersionAsync(string packageName) { await _versionFetchSemaphore.WaitAsync().ConfigureAwait(false); @@ -274,58 +286,18 @@ protected override IReadOnlyList GetAvailableUpdates_UnSafe() p.Start(); string? line; - bool DashesPassed = false; - List Packages = []; + List outputLines = []; while ((line = p.StandardOutput.ReadLine()) is not null) { logger.AddToStdOut(line); - if (!DashesPassed) - { - if (line.Contains("----")) - { - DashesPassed = true; - } - } - else - { - string[] elements = Regex.Replace(line, " {2,}", " ").Split(' '); - if (elements.Length < 3) - { - continue; - } - - for (int i = 0; i < elements.Length; i++) - { - elements[i] = elements[i].Trim(); - } - - if ( - FALSE_PACKAGE_IDS.Contains(elements[0]) - || FALSE_PACKAGE_VERSIONS.Contains(elements[1]) - ) - { - continue; - } - - Packages.Add( - new Package( - CoreTools.FormatAsName(elements[0]), - elements[0], - elements[1], - elements[2], - DefaultSource, - this, - new(PackageScope.Global) - ) - ); - } + outputLines.Add(line); } logger.AddToStdErr(p.StandardError.ReadToEnd()); p.WaitForExit(); logger.Close(p.ExitCode); - return Packages; + return ParseAvailableUpdates(outputLines, DefaultSource, this); } protected override IReadOnlyList GetInstalledPackages_UnSafe() @@ -352,57 +324,99 @@ protected override IReadOnlyList GetInstalledPackages_UnSafe() p.Start(); string? line; - bool DashesPassed = false; - List Packages = []; + List outputLines = []; while ((line = p.StandardOutput.ReadLine()) is not null) { logger.AddToStdOut(line); - if (!DashesPassed) + outputLines.Add(line); + } + + logger.AddToStdErr(p.StandardError.ReadToEnd()); + p.WaitForExit(); + logger.Close(p.ExitCode); + + return ParseInstalledPackages(outputLines, DefaultSource, this); + } + + internal static IReadOnlyList ParseAvailableUpdates( + IEnumerable outputLines, + IManagerSource source, + Pip manager + ) + { + return ParsePackages(outputLines, source, manager, expectAvailableVersion: true); + } + + internal static IReadOnlyList ParseInstalledPackages( + IEnumerable outputLines, + IManagerSource source, + Pip manager + ) + { + return ParsePackages(outputLines, source, manager, expectAvailableVersion: false); + } + + private static IReadOnlyList ParsePackages( + IEnumerable outputLines, + IManagerSource source, + Pip manager, + bool expectAvailableVersion + ) + { + bool dashesPassed = false; + List packages = []; + int requiredElements = expectAvailableVersion ? 3 : 2; + + foreach (string line in outputLines) + { + if (!dashesPassed) { if (line.Contains("----")) { - DashesPassed = true; + dashesPassed = true; } + continue; } - else - { - string[] elements = Regex.Replace(line, " {2,}", " ").Split(' '); - if (elements.Length < 2) - { - continue; - } - for (int i = 0; i < elements.Length; i++) - { - elements[i] = elements[i].Trim(); - } + string[] elements = Regex + .Replace(line.Trim(), " {2,}", " ") + .Split(' ', StringSplitOptions.RemoveEmptyEntries); + if (elements.Length < requiredElements) + { + continue; + } - if ( - FALSE_PACKAGE_IDS.Contains(elements[0]) - || FALSE_PACKAGE_VERSIONS.Contains(elements[1]) - ) - { - continue; - } + if ( + FALSE_PACKAGE_IDS.Contains(elements[0]) + || FALSE_PACKAGE_VERSIONS.Contains(elements[1]) + ) + { + continue; + } - Packages.Add( - new Package( + packages.Add( + expectAvailableVersion + ? new Package( CoreTools.FormatAsName(elements[0]), elements[0], elements[1], - DefaultSource, - this, + elements[2], + source, + manager, new(PackageScope.Global) ) - ); - } + : new Package( + CoreTools.FormatAsName(elements[0]), + elements[0], + elements[1], + source, + manager, + new(PackageScope.Global) + ) + ); } - logger.AddToStdErr(p.StandardError.ReadToEnd()); - p.WaitForExit(); - logger.Close(p.ExitCode); - - return Packages; + return packages; } public override IReadOnlyList FindCandidateExecutableFiles() diff --git a/src/UniGetUI.PackageEngine.Managers.PowerShell/InternalsVisibleTo.cs b/src/UniGetUI.PackageEngine.Managers.PowerShell/InternalsVisibleTo.cs new file mode 100644 index 0000000000..eeb63dad19 --- /dev/null +++ b/src/UniGetUI.PackageEngine.Managers.PowerShell/InternalsVisibleTo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("UniGetUI.PackageEngine.Tests")] diff --git a/src/UniGetUI.PackageEngine.Managers.PowerShell/PowerShell.cs b/src/UniGetUI.PackageEngine.Managers.PowerShell/PowerShell.cs index c1d3ca7f28..e2b4d30ce2 100644 --- a/src/UniGetUI.PackageEngine.Managers.PowerShell/PowerShell.cs +++ b/src/UniGetUI.PackageEngine.Managers.PowerShell/PowerShell.cs @@ -100,48 +100,18 @@ protected override IReadOnlyList _getInstalledPackages_UnSafe() p.Start(); string? line; - List Packages = []; - bool DashesPassed = false; + List outputLines = []; while ((line = p.StandardOutput.ReadLine()) is not null) { logger.AddToStdOut(line); - if (!DashesPassed) - { - if (line.Contains("-----")) - { - DashesPassed = true; - } - } - else - { - string[] elements = Regex.Replace(line, " {2,}", " ").Split(' '); - if (elements.Length < 3) - { - continue; - } - - for (int i = 0; i < elements.Length; i++) - { - elements[i] = elements[i].Trim(); - } - - Packages.Add( - new Package( - CoreTools.FormatAsName(elements[1]), - elements[1], - elements[0], - SourcesHelper.Factory.GetSourceOrDefault(elements[2]), - this - ) - ); - } + outputLines.Add(line); } logger.AddToStdErr(p.StandardError.ReadToEnd()); p.WaitForExit(); logger.Close(p.ExitCode); - return Packages; + return ParseInstalledPackages(outputLines, this); } public override List FindCandidateExecutableFiles() @@ -182,5 +152,50 @@ protected override void _loadManagerVersion(out string version) process.Start(); version = process.StandardOutput.ReadToEnd().Trim(); } + + internal static IReadOnlyList ParseInstalledPackages( + IEnumerable outputLines, + PowerShell manager + ) + { + List packages = []; + bool dashesPassed = false; + + foreach (string rawLine in outputLines) + { + if (!dashesPassed) + { + if (rawLine.Contains("-----")) + { + dashesPassed = true; + } + + continue; + } + + string[] elements = Regex.Replace(rawLine, " {2,}", " ").Split(' '); + if (elements.Length < 3) + { + continue; + } + + for (int i = 0; i < elements.Length; i++) + { + elements[i] = elements[i].Trim(); + } + + packages.Add( + new Package( + CoreTools.FormatAsName(elements[1]), + elements[1], + elements[0], + manager.SourcesHelper.Factory.GetSourceOrDefault(elements[2]), + manager + ) + ); + } + + return packages; + } } } diff --git a/src/UniGetUI.PackageEngine.Managers.Scoop/Helpers/ScoopSourceHelper.cs b/src/UniGetUI.PackageEngine.Managers.Scoop/Helpers/ScoopSourceHelper.cs index 11dfa45281..44b55e9a36 100644 --- a/src/UniGetUI.PackageEngine.Managers.Scoop/Helpers/ScoopSourceHelper.cs +++ b/src/UniGetUI.PackageEngine.Managers.Scoop/Helpers/ScoopSourceHelper.cs @@ -64,81 +64,100 @@ protected override IReadOnlyList GetSources_UnSafe() LoggableTaskType.ListSources, p ); - List sources = []; p.Start(); - bool DashesPassed = false; - + List lines = []; string? line; while ((line = p.StandardOutput.ReadLine()) is not null) { logger.AddToStdOut(line); + lines.Add(line); + } + logger.AddToStdErr(p.StandardError.ReadToEnd()); + p.WaitForExit(); + logger.Close(p.ExitCode); + + return ParseSources(lines); + } + + internal IReadOnlyList ParseSources(IEnumerable lines) + { + List sources = []; + bool dashesPassed = false; + + foreach (string line in lines) + { try { - if (!DashesPassed) + if (!dashesPassed) { if (line.Contains("---")) { - DashesPassed = true; + dashesPassed = true; } + + continue; } - else if (line.Trim() != "") + + if (string.IsNullOrWhiteSpace(line)) { - string[] elements = Regex - .Replace( - Regex.Replace(line, "[1234567890 :.-][AaPp][Mm][\\W]", "").Trim(), - " {2,}", - " " + continue; + } + + string[] elements = Regex + .Replace( + Regex.Replace(line, "[1234567890 :.-][AaPp][Mm][\\W]", "").Trim(), + " {2,}", + " " + ) + .Split(' ', StringSplitOptions.RemoveEmptyEntries); + if (elements.Length < 5) + { + continue; + } + + if ( + !elements[1].Contains("https://") + && !elements[1].Contains("http://") + ) + { + elements[1] = Path.Join( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + "scoop", + "buckets", + elements[0].Trim() + ); + } + else + { + elements[1] = Regex.Replace(elements[1], @"^(.*)\.git$", "$1"); + } + + try + { + sources.Add( + new ManagerSource( + Manager, + elements[0].Trim(), + new Uri(elements[1]), + int.Parse(elements[4].Trim()), + elements[2].Trim() + " " + elements[3].Trim() ) - .Split(' '); - if (elements.Length >= 5) - { - if ( - !elements[1].Contains("https://") - && !elements[1].Contains("http://") + ); + } + catch (Exception ex) + { + Logger.Warn(ex); + sources.Add( + new ManagerSource( + Manager, + elements[0].Trim(), + new Uri(elements[1]), + -1, + "1/1/1970" ) - { - elements[1] = Path.Join( - Environment.GetFolderPath( - Environment.SpecialFolder.UserProfile - ), - "scoop", - "buckets", - elements[0].Trim() - ); - } - else - { - elements[1] = Regex.Replace(elements[1], @"^(.*)\.git$", "$1"); - } - - try - { - sources.Add( - new ManagerSource( - Manager, - elements[0].Trim(), - new Uri(elements[1]), - int.Parse(elements[4].Trim()), - elements[2].Trim() + " " + elements[3].Trim() - ) - ); - } - catch (Exception ex) - { - logger.AddToStdErr(ex.ToString()); - sources.Add( - new ManagerSource( - Manager, - elements[0].Trim(), - new Uri(elements[1]), - -1, - "1/1/1970" - ) - ); - } - } + ); } } catch (Exception e) @@ -146,9 +165,6 @@ protected override IReadOnlyList GetSources_UnSafe() Logger.Warn(e); } } - logger.AddToStdErr(p.StandardError.ReadToEnd()); - p.WaitForExit(); - logger.Close(p.ExitCode); return sources; } diff --git a/src/UniGetUI.PackageEngine.Managers.Scoop/InternalsVisibleTo.cs b/src/UniGetUI.PackageEngine.Managers.Scoop/InternalsVisibleTo.cs new file mode 100644 index 0000000000..eeb63dad19 --- /dev/null +++ b/src/UniGetUI.PackageEngine.Managers.Scoop/InternalsVisibleTo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("UniGetUI.PackageEngine.Tests")] diff --git a/src/UniGetUI.PackageEngine.Managers.Scoop/Scoop.cs b/src/UniGetUI.PackageEngine.Managers.Scoop/Scoop.cs index 736a728085..df2bd2e591 100644 --- a/src/UniGetUI.PackageEngine.Managers.Scoop/Scoop.cs +++ b/src/UniGetUI.PackageEngine.Managers.Scoop/Scoop.cs @@ -159,10 +159,216 @@ public Scoop() OperationHelper = new ScoopPkgOperationHelper(this); } - protected override IReadOnlyList FindPackages_UnSafe(string query) + internal IReadOnlyList ParseSearchOutput(IEnumerable lines) { - List Packages = []; + List packages = []; + IManagerSource source = Properties.DefaultSource; + + foreach (string line in lines) + { + if (line.StartsWith("'")) + { + string sourceName = line.Split(" ")[0].Replace("'", ""); + source = SourcesHelper.Factory.GetSourceOrDefault(sourceName); + continue; + } + + if (string.IsNullOrWhiteSpace(line)) + { + continue; + } + + string[] elements = line.Trim().Split(' ', StringSplitOptions.RemoveEmptyEntries); + if (elements.Length < 2) + { + continue; + } + for (int i = 0; i < elements.Length; i++) + { + elements[i] = elements[i].Trim(); + } + + if ( + FALSE_PACKAGE_IDS.Contains(elements[0]) + || FALSE_PACKAGE_VERSIONS.Contains(elements[1]) + ) + { + continue; + } + + packages.Add( + new Package( + CoreTools.FormatAsName(elements[0]), + elements[0], + elements[1].Replace("(", "").Replace(")", ""), + source, + this + ) + ); + } + + return packages; + } + + internal IReadOnlyList ParseAvailableUpdates( + IEnumerable lines, + IEnumerable installedPackages + ) + { + Dictionary installedPackageMap = []; + foreach (IPackage installedPackage in installedPackages) + { + string key = installedPackage.Id + "." + installedPackage.VersionString; + if (!installedPackageMap.ContainsKey(key)) + { + installedPackageMap.Add(key, installedPackage); + } + } + + List packages = []; + bool dashesPassed = false; + foreach (string line in lines) + { + if (!dashesPassed) + { + if (line.Contains("---")) + { + dashesPassed = true; + } + + continue; + } + + if (string.IsNullOrWhiteSpace(line)) + { + continue; + } + + string[] elements = Regex + .Replace(line, " {2,}", " ") + .Trim() + .Split(' ', StringSplitOptions.RemoveEmptyEntries); + if (elements.Length < 3) + { + continue; + } + + for (int i = 0; i < elements.Length; i++) + { + elements[i] = elements[i].Trim(); + } + + if ( + FALSE_PACKAGE_IDS.Contains(elements[0]) + || FALSE_PACKAGE_VERSIONS.Contains(elements[1]) + || FALSE_PACKAGE_VERSIONS.Contains(elements[2]) + ) + { + continue; + } + + if ( + installedPackageMap.TryGetValue( + elements[0] + "." + elements[1], + out IPackage? installedPackage + ) + ) + { + OverridenInstallationOptions options = new(installedPackage.OverridenOptions.Scope); + packages.Add( + new Package( + CoreTools.FormatAsName(elements[0]), + elements[0], + elements[1], + elements[2], + installedPackage.Source, + this, + options + ) + ); + } + } + + return packages; + } + + internal IReadOnlyList ParseInstalledPackages(IEnumerable lines) + { + List packages = []; + bool dashesPassed = false; + foreach (string line in lines) + { + if (!dashesPassed) + { + if (line.Contains("---")) + { + dashesPassed = true; + } + + continue; + } + + if (string.IsNullOrWhiteSpace(line)) + { + continue; + } + + string[] elements = Regex + .Replace(line, " {2,}", " ") + .Trim() + .Split(' ', StringSplitOptions.RemoveEmptyEntries); + if (elements.Length < 3) + { + continue; + } + + if (elements[2].Contains(":\\")) + { + var path = Regex.Match( + line, + "[A-Za-z]:(?:[\\\\\\/][^\\\\\\/\\n]+)+(?:.json|…)" + ); + if (!string.IsNullOrEmpty(path.Value)) + { + elements[2] = path.Value; + } + } + + for (int i = 0; i < elements.Length; i++) + { + elements[i] = elements[i].Trim(); + } + + if ( + FALSE_PACKAGE_IDS.Contains(elements[0]) + || FALSE_PACKAGE_VERSIONS.Contains(elements[1]) + ) + { + continue; + } + + OverridenInstallationOptions options = new( + line.Contains("Global install") ? PackageScope.Global : PackageScope.User + ); + + packages.Add( + new Package( + CoreTools.FormatAsName(elements[0]), + elements[0], + elements[1], + SourcesHelper.Factory.GetSourceOrDefault(elements[2]), + this, + options + ) + ); + } + + return packages; + } + + protected override IReadOnlyList FindPackages_UnSafe(string query) + { var (found, path) = CoreTools.Which("scoop-search.exe"); if (!found) { @@ -208,73 +414,22 @@ protected override IReadOnlyList FindPackages_UnSafe(string query) p.Start(); + List lines = []; string? line; - IManagerSource source = Properties.DefaultSource; while ((line = p.StandardOutput.ReadLine()) is not null) { logger.AddToStdOut(line); - if (line.StartsWith("'")) - { - string sourceName = line.Split(" ")[0].Replace("'", ""); - source = SourcesHelper.Factory.GetSourceOrDefault(sourceName); - } - else if (line.Trim() != "") - { - string[] elements = line.Trim().Split(" "); - if (elements.Length < 2) - { - continue; - } - - for (int i = 0; i < elements.Length; i++) - { - elements[i] = elements[i].Trim(); - } - - if ( - FALSE_PACKAGE_IDS.Contains(elements[0]) - || FALSE_PACKAGE_VERSIONS.Contains(elements[1]) - ) - { - continue; - } - - Packages.Add( - new Package( - CoreTools.FormatAsName(elements[0]), - elements[0], - elements[1].Replace("(", "").Replace(")", ""), - source, - this - ) - ); - } + lines.Add(line); } logger.AddToStdErr(p.StandardError.ReadToEnd()); p.WaitForExit(); logger.Close(p.ExitCode); - return Packages; + return ParseSearchOutput(lines); } protected override IReadOnlyList GetAvailableUpdates_UnSafe() { - Dictionary InstalledPackages = []; - foreach (IPackage InstalledPackage in GetInstalledPackages()) - { - if ( - !InstalledPackages.ContainsKey( - InstalledPackage.Id + "." + InstalledPackage.VersionString - ) - ) - { - InstalledPackages.Add( - InstalledPackage.Id + "." + InstalledPackage.VersionString, - InstalledPackage - ); - } - } - - List Packages = []; + IReadOnlyList installedPackages = GetInstalledPackages(); using Process p = new() { @@ -293,75 +448,17 @@ protected override IReadOnlyList GetAvailableUpdates_UnSafe() p.Start(); + List lines = []; string? line; - bool DashesPassed = false; while ((line = p.StandardOutput.ReadLine()) is not null) { logger.AddToStdOut(line); - if (!DashesPassed) - { - if (line.Contains("---")) - { - DashesPassed = true; - } - } - else if (line.Trim() != "") - { - string[] elements = Regex.Replace(line, " {2,}", " ").Trim().Split(" "); - if (elements.Length < 3) - { - continue; - } - - for (int i = 0; i < elements.Length; i++) - { - elements[i] = elements[i].Trim(); - } - - if ( - FALSE_PACKAGE_IDS.Contains(elements[0]) - || FALSE_PACKAGE_VERSIONS.Contains(elements[1]) - || FALSE_PACKAGE_VERSIONS.Contains(elements[2]) - ) - { - continue; - } - - if ( - InstalledPackages.TryGetValue( - elements[0] + "." + elements[1], - out IPackage? InstalledPackage - ) - ) - { - OverridenInstallationOptions options = new( - InstalledPackage.OverridenOptions.Scope - ); - Packages.Add( - new Package( - CoreTools.FormatAsName(elements[0]), - elements[0], - elements[1], - elements[2], - InstalledPackage.Source, - this, - options - ) - ); - } - else - { - Logger.Warn( - "Upgradable scoop package not listed on installed packages - id=" - + elements[0] - ); - } - } + lines.Add(line); } logger.AddToStdErr(p.StandardError.ReadToEnd()); p.WaitForExit(); logger.Close(p.ExitCode); - return Packages; + return ParseAvailableUpdates(lines, installedPackages); } protected override IReadOnlyList GetInstalledPackages_UnSafe() => @@ -369,8 +466,6 @@ protected override IReadOnlyList GetInstalledPackages_UnSafe() => private IReadOnlyList _getInstalledPackages_UnSafe() { - List Packages = []; - using Process p = new() { StartInfo = new ProcessStartInfo @@ -390,66 +485,17 @@ private IReadOnlyList _getInstalledPackages_UnSafe() ); p.Start(); + List lines = []; string? line; - bool DashesPassed = false; while ((line = p.StandardOutput.ReadLine()) is not null) { logger.AddToStdOut(line); - if (!DashesPassed) - { - if (line.Contains("---")) - { - DashesPassed = true; - } - } - else if (line.Trim() != "") - { - string[] elements = Regex.Replace(line, " {2,}", " ").Trim().Split(" "); - if (elements.Length < 3) - continue; - - if (elements[2].Contains(":\\")) - { - var path = Regex.Match( - line, - "[A-Za-z]:(?:[\\\\\\/][^\\\\\\/\\n]+)+(?:.json|…)" - ); - elements[2] = path.Value; - } - - for (int i = 0; i < elements.Length; i++) - { - elements[i] = elements[i].Trim(); - } - - if ( - FALSE_PACKAGE_IDS.Contains(elements[0]) - || FALSE_PACKAGE_VERSIONS.Contains(elements[1]) - ) - { - continue; - } - - OverridenInstallationOptions options = new( - line.Contains("Global install") ? PackageScope.Global : PackageScope.User - ); - - Packages.Add( - new Package( - CoreTools.FormatAsName(elements[0]), - elements[0], - elements[1], - SourcesHelper.Factory.GetSourceOrDefault(elements[2]), - this, - options - ) - ); - } + lines.Add(line); } logger.AddToStdErr(p.StandardError.ReadToEnd()); p.WaitForExit(); logger.Close(p.ExitCode); - return Packages; + return ParseInstalledPackages(lines); } public override void RefreshPackageIndexes() diff --git a/src/UniGetUI.PackageEngine.Managers.WinGet/InternalsVisibleTo.cs b/src/UniGetUI.PackageEngine.Managers.WinGet/InternalsVisibleTo.cs new file mode 100644 index 0000000000..eeb63dad19 --- /dev/null +++ b/src/UniGetUI.PackageEngine.Managers.WinGet/InternalsVisibleTo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("UniGetUI.PackageEngine.Tests")] diff --git a/src/UniGetUI.PackageEngine.Tests/CargoClientTests.cs b/src/UniGetUI.PackageEngine.Tests/CargoClientTests.cs new file mode 100644 index 0000000000..57b6e883dd --- /dev/null +++ b/src/UniGetUI.PackageEngine.Tests/CargoClientTests.cs @@ -0,0 +1,100 @@ +using UniGetUI.PackageEngine.Managers.CargoManager; +using UniGetUI.PackageEngine.Tests.Infrastructure.Helpers; + +namespace UniGetUI.PackageEngine.Tests; + +public sealed class CargoClientTests : IDisposable +{ + public void Dispose() + { + CratesIOClient.TEST_ApiUrlOverride = null; + } + + [Fact] + public void GetManifest_ReturnsManifestAndResolvedUri() + { + using var server = new TestHttpServer(request => + { + return request.Url?.AbsolutePath switch + { + "/api/v1/crates/ripgrep" => ( + 200, + """ + { + "crate": { + "max_stable_version": "14.1.0", + "max_version": "14.1.0", + "name": "ripgrep", + "newest_version": "14.1.0" + }, + "versions": [ + { + "checksum": "abc", + "dl_path": "/api/v1/crates/ripgrep/14.1.0/download", + "num": "14.1.0", + "yanked": false + } + ] + } + """, + "application/json" + ), + _ => (404, string.Empty, "application/json"), + }; + }); + CratesIOClient.TEST_ApiUrlOverride = $"{server.BaseUri.AbsoluteUri.TrimEnd('/')}/api/v1"; + + var (uri, manifest) = CratesIOClient.GetManifest("ripgrep"); + + Assert.Equal($"{server.BaseUri.AbsoluteUri.TrimEnd('/')}/api/v1/crates/ripgrep", uri.ToString()); + Assert.Equal("ripgrep", manifest.crate.name); + Assert.Equal("14.1.0", Assert.Single(manifest.versions).num); + } + + [Fact] + public void GetManifestVersion_ReturnsWrappedVersion() + { + using var server = new TestHttpServer(request => + { + return request.Url?.AbsolutePath switch + { + "/api/v1/crates/ripgrep/14.1.0" => ( + 200, + """ + { + "version": { + "checksum": "abc", + "dl_path": "/api/v1/crates/ripgrep/14.1.0/download", + "num": "14.1.0", + "yanked": false + } + } + """, + "application/json" + ), + _ => (404, string.Empty, "application/json"), + }; + }); + CratesIOClient.TEST_ApiUrlOverride = $"{server.BaseUri.AbsoluteUri.TrimEnd('/')}/api/v1"; + + var version = CratesIOClient.GetManifestVersion("ripgrep", "14.1.0"); + + Assert.Equal("14.1.0", version.num); + } + + [Fact] + public void GetManifest_ThrowsWhenResponseContainsNullCrate() + { + using var server = new TestHttpServer(request => + { + return request.Url?.AbsolutePath switch + { + "/api/v1/crates/ripgrep" => (200, """{"crate":null,"versions":[]}""", "application/json"), + _ => (404, string.Empty, "application/json"), + }; + }); + CratesIOClient.TEST_ApiUrlOverride = $"{server.BaseUri.AbsoluteUri.TrimEnd('/')}/api/v1"; + + Assert.Throws(() => CratesIOClient.GetManifest("ripgrep")); + } +} diff --git a/src/UniGetUI.PackageEngine.Tests/ChocolateyManagerTests.cs b/src/UniGetUI.PackageEngine.Tests/ChocolateyManagerTests.cs new file mode 100644 index 0000000000..367b0b2f7a --- /dev/null +++ b/src/UniGetUI.PackageEngine.Tests/ChocolateyManagerTests.cs @@ -0,0 +1,277 @@ +#if WINDOWS +using UniGetUI.Core.Data; +using UniGetUI.Core.SettingsEngine; +using UniGetUI.PackageEngine.Enums; +using UniGetUI.PackageEngine.Managers.Choco; +using UniGetUI.PackageEngine.Managers.ChocolateyManager; +using UniGetUI.PackageEngine.Serializable; +using UniGetUI.PackageEngine.Structs; +using UniGetUI.PackageEngine.Tests.Infrastructure.Assertions; +using UniGetUI.PackageEngine.Tests.Infrastructure.Builders; +using UniGetUI.PackageEngine.Tests.Infrastructure.Helpers; +using Architecture = UniGetUI.PackageEngine.Enums.Architecture; + +namespace UniGetUI.PackageEngine.Tests; + +[CollectionDefinition("Chocolatey manager tests", DisableParallelization = true)] +public sealed class ChocolateyManagerTestCollection +{ + public const string Name = "Chocolatey manager tests"; +} + +[Collection(ChocolateyManagerTestCollection.Name)] +public sealed class ChocolateyManagerTests : IDisposable +{ + private readonly string _testRoot = Path.Combine( + AppContext.BaseDirectory, + nameof(ChocolateyManagerTests), + Guid.NewGuid().ToString("N") + ); + + public ChocolateyManagerTests() + { + Directory.CreateDirectory(_testRoot); + CoreData.TEST_DataDirectoryOverride = Path.Combine(_testRoot, "Data"); + Directory.CreateDirectory(CoreData.UniGetUIUserConfigurationDirectory); + Settings.ResetSettings(); + Settings.Set(Settings.K.EnableProxy, false); + Settings.Set(Settings.K.EnableProxyAuth, false); + Settings.Set(Settings.K.UseSystemChocolatey, false); + Settings.SetValue(Settings.K.ProxyURL, ""); + } + + public void Dispose() + { + Settings.ResetSettings(); + CoreData.TEST_DataDirectoryOverride = null; + if (Directory.Exists(_testRoot)) + { + Directory.Delete(_testRoot, recursive: true); + } + } + + [Fact] + public void GetProxyArgumentReturnsConfiguredProxyWhenAuthenticationIsDisabled() + { + Settings.Set(Settings.K.EnableProxy, true); + Settings.Set(Settings.K.EnableProxyAuth, false); + Settings.SetValue(Settings.K.ProxyURL, "http://proxy.example.test:3128/"); + + Assert.Equal("--proxy http://proxy.example.test:3128/", Chocolatey.GetProxyArgument()); + } + + [Fact] + public void ParseAvailableUpdatesFiltersNoiseAndBuildsPackagesFromFixture() + { + var manager = new Chocolatey(); + + var packages = manager.ParseAvailableUpdates( + ReadFixtureLines("Chocolatey\\outdated-output.txt") + ); + + Assert.Collection( + packages, + package => + { + Assert.Equal("git", package.Id); + Assert.Equal("2.47.0", package.VersionString); + Assert.Equal("2.48.1", package.NewVersionString); + PackageAssert.BelongsTo(package, manager, manager.DefaultSource); + }, + package => + { + Assert.Equal("7zip", package.Id); + Assert.Equal("24.9.0", package.VersionString); + Assert.Equal("25.0.0", package.NewVersionString); + PackageAssert.BelongsTo(package, manager, manager.DefaultSource); + } + ); + } + + [Fact] + public void ParseInstalledPackagesFiltersNoiseAndBuildsPackagesFromFixture() + { + var manager = new Chocolatey(); + + var packages = manager.ParseInstalledPackages(ReadFixtureLines("Chocolatey\\list-output.txt")); + + Assert.Collection( + packages, + package => + { + Assert.Equal("git", package.Id); + Assert.Equal("2.47.0", package.VersionString); + PackageAssert.BelongsTo(package, manager, manager.DefaultSource); + }, + package => + { + Assert.Equal("7zip", package.Id); + Assert.Equal("24.9.0", package.VersionString); + PackageAssert.BelongsTo(package, manager, manager.DefaultSource); + } + ); + } + + [Fact] + public void ParseSourcesNormalizesCommunityFeedsAndPreservesCustomFeeds() + { + var manager = new Chocolatey(); + var helper = Assert.IsType(manager.SourcesHelper); + + var sources = helper.ParseSources(ReadFixtureLines("Chocolatey\\source-list-output.txt")); + + Assert.Collection( + sources, + source => + { + Assert.Equal("community", source.Name); + Assert.Equal(new Uri("https://community.chocolatey.org/api/v2/"), source.Url); + }, + source => + { + Assert.Equal("community", source.Name); + Assert.Equal(new Uri("https://community.chocolatey.org/api/v2/"), source.Url); + }, + source => + { + Assert.Equal("internal repo", source.Name); + Assert.Equal(new Uri("https://packages.example.test/api/v2/"), source.Url); + } + ); + } + + [Fact] + public void ParseInstallableVersionsReadsApprovedVersionsFromFixture() + { + var versions = ChocolateyDetailsHelper.ParseInstallableVersions( + ReadFixtureLines("Chocolatey\\search-versions-output.txt") + ); + + Assert.Equal(["2.46.0", "2.47.0", "2.48.1"], versions); + } + + [Fact] + public void InstallParametersIncludeChocolateySpecificFlagsAndCustomParameters() + { + var manager = new Chocolatey(); + var package = new PackageBuilder().WithManager(manager).WithId("git").Build(); + var options = new InstallOptions + { + InteractiveInstallation = true, + Architecture = Architecture.x86, + PreRelease = true, + SkipHashCheck = true, + Version = "2.48.1", + CustomParameters_Install = ["--install-arg"], + }; + + var parameters = manager.OperationHelper.GetParameters(package, options, OperationType.Install); + + OperationAssert.HasParameters( + parameters, + "install", + "git", + "-y", + "--notsilent", + "--no-progress", + "--forcex86", + "--prerelease", + "--ignore-checksums", + "--force", + "--version=2.48.1", + "--allow-downgrade", + "--install-arg" + ); + } + + [Fact] + public void UninstallParametersUseUninstallVerbAndOnlyUninstallCustomParameters() + { + var manager = new Chocolatey(); + var package = new PackageBuilder().WithManager(manager).WithId("git").Build(); + var options = new InstallOptions + { + InteractiveInstallation = true, + SkipHashCheck = true, + CustomParameters_Install = ["--install-arg"], + CustomParameters_Uninstall = ["--remove-arg"], + }; + + var parameters = manager.OperationHelper.GetParameters(package, options, OperationType.Uninstall); + + OperationAssert.HasParameters( + parameters, + "uninstall", + "git", + "-y", + "--notsilent", + "--remove-arg" + ); + } + + [Theory] + [InlineData(0)] + [InlineData(3010)] + [InlineData(1641)] + [InlineData(1614)] + [InlineData(1605)] + public void OperationResultTreatsChocolateySuccessCodesAsSuccess(int returnCode) + { + var manager = new Chocolatey(); + var package = new PackageBuilder().WithManager(manager).Build(); + + var veredict = manager.OperationHelper.GetResult( + package, + OperationType.Install, + ["completed"], + returnCode + ); + + OperationAssert.HasVeredict(veredict, OperationVeredict.Success); + } + + [Fact] + public void OperationResultPromotesElevationFailuresToAutoRetry() + { + var manager = new Chocolatey(); + var package = new PackageBuilder() + .WithManager(manager) + .WithOptions(new OverridenInstallationOptions(runAsAdministrator: false)) + .Build(); + + var veredict = manager.OperationHelper.GetResult( + package, + OperationType.Install, + ["Access denied while installing package"], + 1 + ); + + OperationAssert.HasVeredict(veredict, OperationVeredict.AutoRetry); + Assert.True(package.OverridenOptions.RunAsAdministrator); + } + + [Fact] + public void OperationResultReturnsFailureWhenElevationWasAlreadyRequested() + { + var manager = new Chocolatey(); + var package = new PackageBuilder() + .WithManager(manager) + .WithOptions(new OverridenInstallationOptions(runAsAdministrator: true)) + .Build(); + + var veredict = manager.OperationHelper.GetResult( + package, + OperationType.Install, + ["Access denied while installing package"], + 1 + ); + + OperationAssert.HasVeredict(veredict, OperationVeredict.Failure); + } + + private static string[] ReadFixtureLines(string relativePath) + { + return PackageEngineFixtureFiles.ReadAllText(relativePath).Replace("\r\n", "\n").Split('\n'); + } +} +#endif diff --git a/src/UniGetUI.PackageEngine.Tests/DotNetManagerTests.cs b/src/UniGetUI.PackageEngine.Tests/DotNetManagerTests.cs new file mode 100644 index 0000000000..d4972fe4e8 --- /dev/null +++ b/src/UniGetUI.PackageEngine.Tests/DotNetManagerTests.cs @@ -0,0 +1,61 @@ +using UniGetUI.PackageEngine.Enums; +using UniGetUI.PackageEngine.Managers.DotNetManager; +using UniGetUI.PackageEngine.Structs; + +namespace UniGetUI.PackageEngine.Tests; + +public sealed class DotNetManagerTests +{ + [Fact] + public void ParseInstalledPackages_SkipsHeadersAndFalseRows() + { + var manager = new DotNet(); + var packages = DotNet.ParseInstalledPackages( + [ + "Package Id Version Commands", + "--------------------------------------", + "dotnetsay 2.1.7 dotnetsay", + " 1.0.0", + "try-convert 0.9.232202", + ], + manager.DefaultSource, + manager, + new OverridenInstallationOptions(PackageScope.Local) + ); + + Assert.Collection( + packages, + package => + { + Assert.Equal("dotnetsay", package.Id); + Assert.Equal("2.1.7", package.VersionString); + Assert.Equal(PackageScope.Local, package.OverridenOptions.Scope); + }, + package => + { + Assert.Equal("try-convert", package.Id); + Assert.Equal("0.9.232202", package.VersionString); + } + ); + } + + [Fact] + public void ParseInstalledPackages_PreservesRequestedScope() + { + var manager = new DotNet(); + var package = Assert.Single( + DotNet.ParseInstalledPackages( + [ + "Package Id Version Commands", + "--------------------------------------", + "dotnetsay 2.1.7 dotnetsay", + ], + manager.DefaultSource, + manager, + new OverridenInstallationOptions(PackageScope.Global) + ) + ); + + Assert.Equal(PackageScope.Global, package.OverridenOptions.Scope); + } +} diff --git a/src/UniGetUI.PackageEngine.Tests/Fixtures/Chocolatey/list-output.txt b/src/UniGetUI.PackageEngine.Tests/Fixtures/Chocolatey/list-output.txt new file mode 100644 index 0000000000..40c5661457 --- /dev/null +++ b/src/UniGetUI.PackageEngine.Tests/Fixtures/Chocolatey/list-output.txt @@ -0,0 +1,6 @@ +Chocolatey v2.4.3 +git 2.47.0 +Validation Failures: +7zip 24.9.0 +Output is Id Version +foo diff --git a/src/UniGetUI.PackageEngine.Tests/Fixtures/Chocolatey/outdated-output.txt b/src/UniGetUI.PackageEngine.Tests/Fixtures/Chocolatey/outdated-output.txt new file mode 100644 index 0000000000..6a5d552f23 --- /dev/null +++ b/src/UniGetUI.PackageEngine.Tests/Fixtures/Chocolatey/outdated-output.txt @@ -0,0 +1,6 @@ +Chocolatey v2.4.3 +git|2.47.0|2.48.1 +Validation|current version|available version +7zip|24.9.0|25.0.0 +git|2.48.1|2.48.1 +foo|1.0 diff --git a/src/UniGetUI.PackageEngine.Tests/Fixtures/Chocolatey/search-versions-output.txt b/src/UniGetUI.PackageEngine.Tests/Fixtures/Chocolatey/search-versions-output.txt new file mode 100644 index 0000000000..faeea04bad --- /dev/null +++ b/src/UniGetUI.PackageEngine.Tests/Fixtures/Chocolatey/search-versions-output.txt @@ -0,0 +1,5 @@ +Chocolatey v2.4.3 +git 2.46.0 [Approved] +git 2.47.0 [Approved] +not-a-version line +git 2.48.1 [Approved] diff --git a/src/UniGetUI.PackageEngine.Tests/Fixtures/Chocolatey/source-list-output.txt b/src/UniGetUI.PackageEngine.Tests/Fixtures/Chocolatey/source-list-output.txt new file mode 100644 index 0000000000..b28ad329d8 --- /dev/null +++ b/src/UniGetUI.PackageEngine.Tests/Fixtures/Chocolatey/source-list-output.txt @@ -0,0 +1,5 @@ +Chocolatey v2.4.3 +community - https://community.chocolatey.org/api/v2/ | Priority 0|Bypass Proxy - false|Self-Service - false|Admin Only - false +legacy community - https://chocolatey.org/api/v2/ | Priority 0|Bypass Proxy - false|Self-Service - false|Admin Only - false +internal repo - https://packages.example.test/api/v2/ | Priority 10|Bypass Proxy - false|Self-Service - false|Admin Only - false +invalid source line diff --git a/src/UniGetUI.PackageEngine.Tests/Fixtures/Npm/installed.json b/src/UniGetUI.PackageEngine.Tests/Fixtures/Npm/installed.json new file mode 100644 index 0000000000..e94d287472 --- /dev/null +++ b/src/UniGetUI.PackageEngine.Tests/Fixtures/Npm/installed.json @@ -0,0 +1,11 @@ +{ + "name": "workspace", + "dependencies": { + "rimraf": { + "version": "6.0.1" + }, + "missing-version": { + "resolved": "https://registry.npmjs.org/missing-version" + } + } +} diff --git a/src/UniGetUI.PackageEngine.Tests/Fixtures/Npm/outdated.json b/src/UniGetUI.PackageEngine.Tests/Fixtures/Npm/outdated.json new file mode 100644 index 0000000000..8153358b79 --- /dev/null +++ b/src/UniGetUI.PackageEngine.Tests/Fixtures/Npm/outdated.json @@ -0,0 +1,11 @@ +{ + "npm": { + "current": "10.9.0", + "wanted": "10.9.0", + "latest": "11.0.0", + "location": "C:\\Users\\tester\\AppData\\Roaming\\npm\\node_modules\\npm" + }, + "missing-current": { + "latest": "1.0.0" + } +} diff --git a/src/UniGetUI.PackageEngine.Tests/Fixtures/Npm/search-array-with-warning.txt b/src/UniGetUI.PackageEngine.Tests/Fixtures/Npm/search-array-with-warning.txt new file mode 100644 index 0000000000..92d67450a1 --- /dev/null +++ b/src/UniGetUI.PackageEngine.Tests/Fixtures/Npm/search-array-with-warning.txt @@ -0,0 +1,13 @@ +npm warn config global `--global`, `--local` are deprecated. Use `--location=global` instead. +[ + { + "name": "left-pad", + "version": "1.3.0", + "description": "String left pad" + }, + { + "name": "@types/node", + "version": "24.0.0", + "description": "TypeScript definitions for Node.js" + } +] diff --git a/src/UniGetUI.PackageEngine.Tests/Fixtures/Npm/search-ndjson.txt b/src/UniGetUI.PackageEngine.Tests/Fixtures/Npm/search-ndjson.txt new file mode 100644 index 0000000000..cea052c7bb --- /dev/null +++ b/src/UniGetUI.PackageEngine.Tests/Fixtures/Npm/search-ndjson.txt @@ -0,0 +1,5 @@ +npm warn old lockfile +{"name":"chalk","version":"5.4.1","description":"Terminal string styling done right"} +not-json +{"name":"missing-version"} +{"name":"npm-check-updates","version":"17.1.1","description":"Find newer package dependencies"} diff --git a/src/UniGetUI.PackageEngine.Tests/Fixtures/Pip/installed-list.txt b/src/UniGetUI.PackageEngine.Tests/Fixtures/Pip/installed-list.txt new file mode 100644 index 0000000000..febd633a98 --- /dev/null +++ b/src/UniGetUI.PackageEngine.Tests/Fixtures/Pip/installed-list.txt @@ -0,0 +1,6 @@ +Package Version +------------------ ---------- +requests 2.31.0 +[notice] invalid +django-reqtools 1.0.0 +DEPRECATION: ignored diff --git a/src/UniGetUI.PackageEngine.Tests/Fixtures/Pip/outdated-list.txt b/src/UniGetUI.PackageEngine.Tests/Fixtures/Pip/outdated-list.txt new file mode 100644 index 0000000000..2b6088ce3b --- /dev/null +++ b/src/UniGetUI.PackageEngine.Tests/Fixtures/Pip/outdated-list.txt @@ -0,0 +1,6 @@ +Package Version Latest Type +------------------ ---------- ---------- ----- +requests 2.31.0 2.32.3 wheel +[notice] invalid invalid wheel +django-reqtools 1.0.0 1.1.0 wheel +WARNING: ignored ignored wheel diff --git a/src/UniGetUI.PackageEngine.Tests/Fixtures/Pip/simple-index.json b/src/UniGetUI.PackageEngine.Tests/Fixtures/Pip/simple-index.json new file mode 100644 index 0000000000..884cd9afff --- /dev/null +++ b/src/UniGetUI.PackageEngine.Tests/Fixtures/Pip/simple-index.json @@ -0,0 +1,12 @@ +{ + "projects": [ + { "name": "requests" }, + { "name": "req" }, + { "name": "requests-cache" }, + { "name": "requestium" }, + { "name": "django-reqtools" }, + { "name": "pytest" }, + { "name": null }, + {} + ] +} diff --git a/src/UniGetUI.PackageEngine.Tests/Fixtures/Scoop/bucket-list-output.txt b/src/UniGetUI.PackageEngine.Tests/Fixtures/Scoop/bucket-list-output.txt new file mode 100644 index 0000000000..537cc44389 --- /dev/null +++ b/src/UniGetUI.PackageEngine.Tests/Fixtures/Scoop/bucket-list-output.txt @@ -0,0 +1,5 @@ +Name Source Updated Manifests +---- ------ ------- --------- +main https://github.com/ScoopInstaller/Main.git 2024-02-01 12:34:56 1234 +extras C:\Users\fixture\scoop\buckets\extras 2024-02-02 09:08:07 321 +invalid line diff --git a/src/UniGetUI.PackageEngine.Tests/Fixtures/Scoop/list-output.txt b/src/UniGetUI.PackageEngine.Tests/Fixtures/Scoop/list-output.txt new file mode 100644 index 0000000000..90cec9e5f3 --- /dev/null +++ b/src/UniGetUI.PackageEngine.Tests/Fixtures/Scoop/list-output.txt @@ -0,0 +1,5 @@ +Name Version Source +---- ------- ------ +git 2.47.1 main +pwsh 7.4.6 versions Global install +No packages installed diff --git a/src/UniGetUI.PackageEngine.Tests/Fixtures/Scoop/search-output.txt b/src/UniGetUI.PackageEngine.Tests/Fixtures/Scoop/search-output.txt new file mode 100644 index 0000000000..c373032a12 --- /dev/null +++ b/src/UniGetUI.PackageEngine.Tests/Fixtures/Scoop/search-output.txt @@ -0,0 +1,6 @@ +'main' bucket: +7zip 24.09 +WARN ignored +'versions' bucket: +python310 3.10.11 +No Matches Found diff --git a/src/UniGetUI.PackageEngine.Tests/Fixtures/Scoop/status-output.txt b/src/UniGetUI.PackageEngine.Tests/Fixtures/Scoop/status-output.txt new file mode 100644 index 0000000000..9ec066f9e8 --- /dev/null +++ b/src/UniGetUI.PackageEngine.Tests/Fixtures/Scoop/status-output.txt @@ -0,0 +1,6 @@ +Name Installed Latest +---- --------- ------ +git 2.47.1 2.48.1 +pwsh 7.4.6 7.5.0 +orphan 0.1.0 0.2.0 +WARN failed removed diff --git a/src/UniGetUI.PackageEngine.Tests/Fixtures/sample-manager-output.txt b/src/UniGetUI.PackageEngine.Tests/Fixtures/sample-manager-output.txt new file mode 100644 index 0000000000..38d3959779 --- /dev/null +++ b/src/UniGetUI.PackageEngine.Tests/Fixtures/sample-manager-output.txt @@ -0,0 +1,3 @@ +Name Id Version +---------------------------------------- +Contoso Tool Contoso.Tool 1.2.3 diff --git a/src/UniGetUI.PackageEngine.Tests/HarnessSmokeTests.cs b/src/UniGetUI.PackageEngine.Tests/HarnessSmokeTests.cs new file mode 100644 index 0000000000..84ee4dd3a2 --- /dev/null +++ b/src/UniGetUI.PackageEngine.Tests/HarnessSmokeTests.cs @@ -0,0 +1,147 @@ +using UniGetUI.PackageEngine.Enums; +using UniGetUI.PackageEngine.PackageLoader; +using UniGetUI.PackageEngine.Serializable; +using UniGetUI.PackageEngine.Tests.Infrastructure.Assertions; +using UniGetUI.PackageEngine.Tests.Infrastructure.Builders; +using UniGetUI.PackageEngine.Tests.Infrastructure.Helpers; + +namespace UniGetUI.PackageEngine.Tests; + +public sealed class HarnessSmokeTests +{ + [Fact] + public void PackageManagerBuilderCreatesReadyManagerWithDeterministicSources() + { + var manager = new PackageManagerBuilder() + .WithName("Harness") + .WithDisplayName("Harness Manager") + .WithSources(manager => + [ + new SourceBuilder() + .WithManager(manager) + .WithName("community") + .WithUrl("https://example.test/community") + .WithPackageCount(42) + .Build(), + ]) + .Build(); + + Assert.True(manager.IsReady()); + Assert.Equal("Harness", manager.Name); + Assert.Equal("Harness Manager", manager.DisplayName); + var source = Assert.Single(manager.SourcesHelper.GetSources()); + Assert.Equal("community", source.Name); + Assert.Same(source, manager.SourcesHelper.Factory.GetSourceIfExists("community")); + } + + [Fact] + public async Task PackageDetailsBuilderPopulatesPackageThroughFakeHelper() + { + var manager = new PackageManagerBuilder() + .ConfigureDetails(helper => + helper.DetailsFactory = package => + new PackageDetailsBuilder() + .WithDescription("Deterministic package description") + .WithPublisher("Contoso") + .WithAuthor("UniGetUI Tests") + .WithHomepage("https://example.test/package") + .WithLicense("MIT", "https://example.test/license") + .WithInstaller( + "https://example.test/installer.exe", + "abc123", + "exe", + 2048 + ) + .WithManifest("https://example.test/manifest.json") + .WithReleaseNotes( + "Smoke-test release notes", + "https://example.test/release-notes" + ) + .WithUpdateDate("2026-01-01") + .WithTag("smoke") + .WithDependency("dep.one", "1.0.0") + .Build(package)) + .Build(); + var package = new PackageBuilder().WithManager(manager).WithId("Contoso.Test").Build(); + + await package.Details.Load(); + + Assert.True(package.Details.IsPopulated); + Assert.Equal("Deterministic package description", package.Details.Description); + Assert.Equal("Contoso", package.Details.Publisher); + Assert.Single(package.Details.Dependencies); + } + + [Fact] + public async Task InstalledPackagesLoaderLoadsConfiguredPackagesWithoutLiveProcesses() + { + var manager = new PackageManagerBuilder() + .WithInstalledPackages(manager => + [ + new PackageBuilder() + .WithManager(manager) + .WithName("Contoso Tool") + .WithId("Contoso.Tool") + .WithVersion("1.2.3") + .Build(), + ]) + .Build(); + _ = new DiscoverablePackagesLoader([manager]); + _ = new UpgradablePackagesLoader([manager]); + var loader = new InstalledPackagesLoader([manager]); + var recorder = new LoaderEventRecorder(loader); + + await loader.ReloadPackagesSilently(); + + Assert.True(recorder.StartedLoading); + Assert.True(recorder.FinishedLoading); + var package = Assert.Single(loader.Packages); + PackageAssert.Matches(package, "Contoso Tool", "Contoso.Tool", "1.2.3"); + Assert.Contains(recorder.AddedPackages, candidate => candidate.Id == "Contoso.Tool"); + } + + [Fact] + public void OperationHelperReturnsConfiguredParametersAndVeredict() + { + var manager = new PackageManagerBuilder() + .ConfigureOperation(helper => + { + helper.ParametersFactory = (package, options, operation) => + [ + operation.ToString().ToLowerInvariant(), + package.Id, + "--scope", + options.InstallationScope, + ]; + helper.ResultFactory = (_, _, _, returnCode) => + returnCode == 0 ? OperationVeredict.Success : OperationVeredict.Failure; + }) + .Build(); + var package = new PackageBuilder() + .WithManager(manager) + .WithName("Contoso Tool") + .WithId("Contoso.Tool") + .Build(); + var options = new InstallOptions { InstallationScope = PackageScope.User }; + + var parameters = manager.OperationHelper.GetParameters(package, options, OperationType.Install); + var veredict = manager.OperationHelper.GetResult( + package, + OperationType.Install, + ["completed"], + 0 + ); + + OperationAssert.HasParameters(parameters, "install", "Contoso.Tool", "--scope", PackageScope.User); + OperationAssert.HasVeredict(veredict, OperationVeredict.Success); + } + + [Fact] + public void FixtureHelperReadsSampleFixture() + { + var contents = PackageEngineFixtureFiles.ReadAllText("sample-manager-output.txt"); + + Assert.Contains("Contoso.Tool", contents); + Assert.Contains("1.2.3", contents); + } +} diff --git a/src/UniGetUI.PackageEngine.Tests/IgnoredUpdatesDatabaseTests.cs b/src/UniGetUI.PackageEngine.Tests/IgnoredUpdatesDatabaseTests.cs new file mode 100644 index 0000000000..f334da0613 --- /dev/null +++ b/src/UniGetUI.PackageEngine.Tests/IgnoredUpdatesDatabaseTests.cs @@ -0,0 +1,87 @@ +using UniGetUI.Core.Data; +using UniGetUI.Core.SettingsEngine; +using UniGetUI.Core.Tools; +using UniGetUI.PackageEngine.Classes.Packages.Classes; +using UniGetUI.PackageEngine.Tests.Infrastructure.Builders; + +namespace UniGetUI.PackageEngine.Tests; + +public sealed class IgnoredUpdatesDatabaseTests : IDisposable +{ + private readonly string _testRoot = Path.Combine( + Path.GetTempPath(), + nameof(IgnoredUpdatesDatabaseTests), + Guid.NewGuid().ToString("N") + ); + + public IgnoredUpdatesDatabaseTests() + { + Directory.CreateDirectory(_testRoot); + CoreData.TEST_DataDirectoryOverride = Path.Combine(_testRoot, "Data"); + Directory.CreateDirectory(CoreData.UniGetUIUserConfigurationDirectory); + Settings.ResetSettings(); + } + + public void Dispose() + { + Settings.ResetSettings(); + CoreData.TEST_DataDirectoryOverride = null; + if (Directory.Exists(_testRoot)) + { + Directory.Delete(_testRoot, recursive: true); + } + } + + [Fact] + public void AddGetAndRemoveRoundTripForSpecificVersions() + { + var manager = new PackageManagerBuilder().Build(); + var package = new PackageBuilder().WithManager(manager).WithId("Contoso.Tool").Build(); + string ignoredId = IgnoredUpdatesDatabase.GetIgnoredIdForPackage(package); + + IgnoredUpdatesDatabase.Add(ignoredId, "2.0.0"); + + Assert.Equal("2.0.0", IgnoredUpdatesDatabase.GetIgnoredVersion(ignoredId)); + Assert.True(IgnoredUpdatesDatabase.HasUpdatesIgnored(ignoredId, "2.0.0")); + Assert.False(IgnoredUpdatesDatabase.HasUpdatesIgnored(ignoredId, "3.0.0")); + Assert.True(IgnoredUpdatesDatabase.Remove(ignoredId)); + Assert.Null(IgnoredUpdatesDatabase.GetIgnoredVersion(ignoredId)); + } + + [Fact] + public void HasUpdatesIgnored_HonorsWildcardAndExpiresPastDateEntries() + { + const string ignoredId = "manager\\contoso.tool"; + string futureDate = $"<{DateTime.Today.AddDays(5):yyyy-MM-dd}"; + string pastDate = $"<{DateTime.Today.AddDays(-5):yyyy-MM-dd}"; + + IgnoredUpdatesDatabase.Add(ignoredId, "*"); + Assert.True(IgnoredUpdatesDatabase.HasUpdatesIgnored(ignoredId, "9.9.9")); + + Settings.SetDictionary(Settings.K.IgnoredPackageUpdates, new Dictionary { [ignoredId] = futureDate }); + Assert.True(IgnoredUpdatesDatabase.HasUpdatesIgnored(ignoredId, "1.0.0")); + + Settings.SetDictionary(Settings.K.IgnoredPackageUpdates, new Dictionary { [ignoredId] = pastDate }); + Assert.False(IgnoredUpdatesDatabase.HasUpdatesIgnored(ignoredId, "1.0.0")); + Assert.Null(IgnoredUpdatesDatabase.GetIgnoredVersion(ignoredId)); + } + + [Theory] + [InlineData(14)] + [InlineData(30)] + [InlineData(365)] + public void PauseTime_StringRepresentationUsesLargestFriendlyUnit(int days) + { + var pauseTime = new IgnoredUpdatesDatabase.PauseTime { Days = days }; + + string expected = days switch + { + 14 => CoreTools.Translate("{0} weeks", 2), + 30 => CoreTools.Translate("1 month"), + 365 => CoreTools.Translate("{0} months", 13), + _ => throw new InvalidOperationException("Unexpected test input"), + }; + + Assert.Equal(expected, pauseTime.StringRepresentation()); + } +} diff --git a/src/UniGetUI.PackageEngine.Tests/Infrastructure/Assertions/OperationAssert.cs b/src/UniGetUI.PackageEngine.Tests/Infrastructure/Assertions/OperationAssert.cs new file mode 100644 index 0000000000..f031ee94dc --- /dev/null +++ b/src/UniGetUI.PackageEngine.Tests/Infrastructure/Assertions/OperationAssert.cs @@ -0,0 +1,16 @@ +using UniGetUI.PackageEngine.Enums; + +namespace UniGetUI.PackageEngine.Tests.Infrastructure.Assertions; + +public static class OperationAssert +{ + public static void HasVeredict(OperationVeredict actual, OperationVeredict expected) + { + Assert.Equal(expected, actual); + } + + public static void HasParameters(IReadOnlyList actual, params string[] expected) + { + Assert.Equal(expected, actual); + } +} diff --git a/src/UniGetUI.PackageEngine.Tests/Infrastructure/Assertions/PackageAssert.cs b/src/UniGetUI.PackageEngine.Tests/Infrastructure/Assertions/PackageAssert.cs new file mode 100644 index 0000000000..5ea81ab65f --- /dev/null +++ b/src/UniGetUI.PackageEngine.Tests/Infrastructure/Assertions/PackageAssert.cs @@ -0,0 +1,25 @@ +using UniGetUI.PackageEngine.Interfaces; + +namespace UniGetUI.PackageEngine.Tests.Infrastructure.Assertions; + +public static class PackageAssert +{ + public static void Matches(IPackage package, string name, string id, string version, string? newVersion = null) + { + Assert.Equal(name, package.Name); + Assert.Equal(id, package.Id); + Assert.Equal(version, package.VersionString); + Assert.Equal(newVersion ?? version, package.NewVersionString); + } + + public static void BelongsTo(IPackage package, IPackageManager manager, IManagerSource source) + { + Assert.Same(manager, package.Manager); + Assert.Same(source, package.Source); + } + + public static void ContainsIds(IEnumerable packages, params string[] expectedIds) + { + Assert.Equal(expectedIds.OrderBy(id => id), packages.Select(package => package.Id).OrderBy(id => id)); + } +} diff --git a/src/UniGetUI.PackageEngine.Tests/Infrastructure/Builders/PackageBuilder.cs b/src/UniGetUI.PackageEngine.Tests/Infrastructure/Builders/PackageBuilder.cs new file mode 100644 index 0000000000..96ace5f14e --- /dev/null +++ b/src/UniGetUI.PackageEngine.Tests/Infrastructure/Builders/PackageBuilder.cs @@ -0,0 +1,68 @@ +using UniGetUI.PackageEngine.Interfaces; +using UniGetUI.PackageEngine.PackageClasses; +using UniGetUI.PackageEngine.Structs; + +namespace UniGetUI.PackageEngine.Tests.Infrastructure.Builders; + +public sealed class PackageBuilder +{ + private string _name = "Test Package"; + private string _id = "Contoso.Test"; + private string _installedVersion = "1.0.0"; + private string? _availableVersion; + private IManagerSource? _source; + private IPackageManager? _manager; + private OverridenInstallationOptions? _options; + + public PackageBuilder WithName(string name) + { + _name = name; + return this; + } + + public PackageBuilder WithId(string id) + { + _id = id; + return this; + } + + public PackageBuilder WithVersion(string version) + { + _installedVersion = version; + return this; + } + + public PackageBuilder WithNewVersion(string version) + { + _availableVersion = version; + return this; + } + + public PackageBuilder WithManager(IPackageManager manager) + { + _manager = manager; + return this; + } + + public PackageBuilder WithSource(IManagerSource source) + { + _source = source; + return this; + } + + public PackageBuilder WithOptions(OverridenInstallationOptions options) + { + _options = options; + return this; + } + + public Package Build() + { + var manager = _manager ?? new PackageManagerBuilder().Build(); + var source = _source ?? manager.DefaultSource; + + return _availableVersion is null + ? new Package(_name, _id, _installedVersion, source, manager, _options) + : new Package(_name, _id, _installedVersion, _availableVersion, source, manager, _options); + } +} diff --git a/src/UniGetUI.PackageEngine.Tests/Infrastructure/Builders/PackageDetailsBuilder.cs b/src/UniGetUI.PackageEngine.Tests/Infrastructure/Builders/PackageDetailsBuilder.cs new file mode 100644 index 0000000000..37f92c7c4a --- /dev/null +++ b/src/UniGetUI.PackageEngine.Tests/Infrastructure/Builders/PackageDetailsBuilder.cs @@ -0,0 +1,125 @@ +using UniGetUI.PackageEngine.Interfaces; +using UniGetUI.PackageEngine.PackageClasses; + +namespace UniGetUI.PackageEngine.Tests.Infrastructure.Builders; + +public sealed class PackageDetailsBuilder +{ + private string? _description; + private string? _publisher; + private string? _author; + private Uri? _homepageUrl; + private string? _license; + private Uri? _licenseUrl; + private Uri? _installerUrl; + private string? _installerHash; + private string? _installerType; + private long _installerSize; + private Uri? _manifestUrl; + private string? _updateDate; + private string? _releaseNotes; + private Uri? _releaseNotesUrl; + private readonly List _tags = []; + private readonly List _dependencies = []; + + public PackageDetailsBuilder WithDescription(string description) + { + _description = description; + return this; + } + + public PackageDetailsBuilder WithPublisher(string publisher) + { + _publisher = publisher; + return this; + } + + public PackageDetailsBuilder WithAuthor(string author) + { + _author = author; + return this; + } + + public PackageDetailsBuilder WithHomepage(string homepageUrl) + { + _homepageUrl = new Uri(homepageUrl); + return this; + } + + public PackageDetailsBuilder WithLicense(string license, string? licenseUrl = null) + { + _license = license; + _licenseUrl = licenseUrl is null ? null : new Uri(licenseUrl); + return this; + } + + public PackageDetailsBuilder WithInstaller(string installerUrl, string hash, string installerType, long installerSize) + { + _installerUrl = new Uri(installerUrl); + _installerHash = hash; + _installerType = installerType; + _installerSize = installerSize; + return this; + } + + public PackageDetailsBuilder WithManifest(string manifestUrl) + { + _manifestUrl = new Uri(manifestUrl); + return this; + } + + public PackageDetailsBuilder WithReleaseNotes(string releaseNotes, string? releaseNotesUrl = null) + { + _releaseNotes = releaseNotes; + _releaseNotesUrl = releaseNotesUrl is null ? null : new Uri(releaseNotesUrl); + return this; + } + + public PackageDetailsBuilder WithUpdateDate(string updateDate) + { + _updateDate = updateDate; + return this; + } + + public PackageDetailsBuilder WithTag(string tag) + { + _tags.Add(tag); + return this; + } + + public PackageDetailsBuilder WithDependency(string name, string version, bool mandatory = true) + { + _dependencies.Add( + new IPackageDetails.Dependency + { + Name = name, + Version = version, + Mandatory = mandatory, + } + ); + return this; + } + + public PackageDetails Build(IPackage package) + { + return new PackageDetails(package) + { + Description = _description, + Publisher = _publisher, + Author = _author, + HomepageUrl = _homepageUrl, + License = _license, + LicenseUrl = _licenseUrl, + InstallerUrl = _installerUrl, + InstallerHash = _installerHash, + InstallerType = _installerType, + InstallerSize = _installerSize, + ManifestUrl = _manifestUrl, + UpdateDate = _updateDate, + ReleaseNotes = _releaseNotes, + ReleaseNotesUrl = _releaseNotesUrl, + Tags = _tags.ToArray(), + Dependencies = _dependencies.ToList(), + }; + } +} diff --git a/src/UniGetUI.PackageEngine.Tests/Infrastructure/Builders/PackageManagerBuilder.cs b/src/UniGetUI.PackageEngine.Tests/Infrastructure/Builders/PackageManagerBuilder.cs new file mode 100644 index 0000000000..36a611b4e5 --- /dev/null +++ b/src/UniGetUI.PackageEngine.Tests/Infrastructure/Builders/PackageManagerBuilder.cs @@ -0,0 +1,120 @@ +using UniGetUI.PackageEngine.Interfaces; +using UniGetUI.PackageEngine.ManagerClasses.Manager; +using UniGetUI.PackageEngine.PackageClasses; +using UniGetUI.PackageEngine.Tests.Infrastructure.Fakes; + +namespace UniGetUI.PackageEngine.Tests.Infrastructure.Builders; + +public sealed class PackageManagerBuilder +{ + private string _name = "TestManager"; + private string _displayName = "Test Manager"; + private Func _capabilitiesFactory = static capabilities => capabilities; + private Func> _sourcesFactory = manager => [manager.DefaultSource]; + private Func> _availableUpdatesFactory = static _ => []; + private Func> _installedPackagesFactory = static _ => []; + private Action? _detailsConfiguration; + private Action? _operationConfiguration; + private Action? _sourceConfiguration; + private Action? _managerConfiguration; + + public PackageManagerBuilder WithName(string name) + { + _name = name; + return this; + } + + public PackageManagerBuilder WithDisplayName(string displayName) + { + _displayName = displayName; + return this; + } + + public PackageManagerBuilder ConfigureCapabilities( + Func capabilitiesFactory + ) + { + _capabilitiesFactory = capabilitiesFactory; + return this; + } + + public PackageManagerBuilder WithSources( + Func> sourcesFactory + ) + { + _sourcesFactory = sourcesFactory; + return this; + } + + public PackageManagerBuilder WithFindPackages( + Func> findPackagesFactory + ) + { + _managerConfiguration += manager => manager.SetFindPackages(query => findPackagesFactory(manager, query)); + return this; + } + + public PackageManagerBuilder WithInstalledPackages( + Func> installedPackagesFactory + ) + { + _installedPackagesFactory = installedPackagesFactory; + return this; + } + + public PackageManagerBuilder WithAvailableUpdates( + Func> availableUpdatesFactory + ) + { + _availableUpdatesFactory = availableUpdatesFactory; + return this; + } + + public PackageManagerBuilder ConfigureDetails(Action configure) + { + _detailsConfiguration = configure; + return this; + } + + public PackageManagerBuilder ConfigureOperation(Action configure) + { + _operationConfiguration = configure; + return this; + } + + public PackageManagerBuilder ConfigureSources(Action configure) + { + _sourceConfiguration = configure; + return this; + } + + public PackageManagerBuilder ConfigureManager(Action configure) + { + _managerConfiguration += configure; + return this; + } + + public TestPackageManager Build(bool initialize = true) + { + var manager = new TestPackageManager(_name, _displayName); + manager.Capabilities = _capabilitiesFactory(manager.Capabilities); + + _detailsConfiguration?.Invoke(manager.TestDetailsHelper); + _operationConfiguration?.Invoke(manager.TestOperationHelper); + _sourceConfiguration?.Invoke(manager.TestSourcesHelper); + + manager.SetKnownSources(_sourcesFactory(manager)); + manager.SetFindPackages(_ => []); + manager.SetAvailableUpdates(() => _availableUpdatesFactory(manager)); + manager.SetInstalledPackages(() => _installedPackagesFactory(manager)); + + _managerConfiguration?.Invoke(manager); + + if (initialize) + { + manager.Initialize(); + } + + return manager; + } +} diff --git a/src/UniGetUI.PackageEngine.Tests/Infrastructure/Builders/SourceBuilder.cs b/src/UniGetUI.PackageEngine.Tests/Infrastructure/Builders/SourceBuilder.cs new file mode 100644 index 0000000000..231ac96835 --- /dev/null +++ b/src/UniGetUI.PackageEngine.Tests/Infrastructure/Builders/SourceBuilder.cs @@ -0,0 +1,63 @@ +using UniGetUI.PackageEngine.Classes.Manager; +using UniGetUI.PackageEngine.Interfaces; + +namespace UniGetUI.PackageEngine.Tests.Infrastructure.Builders; + +public sealed class SourceBuilder +{ + private IPackageManager? _manager; + private string _name = "default"; + private Uri _url = new("https://example.test/default"); + private int? _packageCount = 0; + private string _updateDate = string.Empty; + private bool _isVirtualManager; + + public SourceBuilder WithManager(IPackageManager manager) + { + _manager = manager; + return this; + } + + public SourceBuilder WithName(string name) + { + _name = name; + return this; + } + + public SourceBuilder WithUrl(string url) + { + _url = new Uri(url); + return this; + } + + public SourceBuilder WithPackageCount(int? packageCount) + { + _packageCount = packageCount; + return this; + } + + public SourceBuilder WithUpdateDate(string updateDate) + { + _updateDate = updateDate; + return this; + } + + public SourceBuilder AsVirtualManager(bool isVirtualManager = true) + { + _isVirtualManager = isVirtualManager; + return this; + } + + public ManagerSource Build() + { + var manager = _manager ?? new PackageManagerBuilder().Build(); + return new ManagerSource( + manager, + _name, + _url, + _packageCount, + _updateDate, + _isVirtualManager + ); + } +} diff --git a/src/UniGetUI.PackageEngine.Tests/Infrastructure/Fakes/TestPackageDetailsHelper.cs b/src/UniGetUI.PackageEngine.Tests/Infrastructure/Fakes/TestPackageDetailsHelper.cs new file mode 100644 index 0000000000..b2b951b37a --- /dev/null +++ b/src/UniGetUI.PackageEngine.Tests/Infrastructure/Fakes/TestPackageDetailsHelper.cs @@ -0,0 +1,71 @@ +using UniGetUI.Core.IconEngine; +using UniGetUI.PackageEngine.Classes.Manager.BaseProviders; +using UniGetUI.PackageEngine.Interfaces; + +namespace UniGetUI.PackageEngine.Tests.Infrastructure.Fakes; + +public sealed class TestPackageDetailsHelper(TestPackageManager manager) : BasePkgDetailsHelper(manager) +{ + public Func? DetailsFactory { get; set; } + + public Action? PopulateDetails { get; set; } + + public Func> VersionsFactory { get; set; } = static _ => []; + + public Func IconFactory { get; set; } = static _ => null; + + public Func> ScreenshotsFactory { get; set; } = static _ => []; + + public Func InstallLocationFactory { get; set; } = static _ => null; + + protected override void GetDetails_UnSafe(IPackageDetails details) + { + if (DetailsFactory?.Invoke(details.Package) is { } source) + { + Copy(source, details); + } + + PopulateDetails?.Invoke(details); + } + + protected override IReadOnlyList GetInstallableVersions_UnSafe(IPackage package) + { + return VersionsFactory(package); + } + + protected override CacheableIcon? GetIcon_UnSafe(IPackage package) + { + return IconFactory(package); + } + + protected override IReadOnlyList GetScreenshots_UnSafe(IPackage package) + { + return ScreenshotsFactory(package); + } + + protected override string? GetInstallLocation_UnSafe(IPackage package) + { + return InstallLocationFactory(package); + } + + private static void Copy(IPackageDetails source, IPackageDetails target) + { + target.Description = source.Description; + target.Publisher = source.Publisher; + target.Author = source.Author; + target.HomepageUrl = source.HomepageUrl; + target.License = source.License; + target.LicenseUrl = source.LicenseUrl; + target.InstallerUrl = source.InstallerUrl; + target.InstallerHash = source.InstallerHash; + target.InstallerType = source.InstallerType; + target.InstallerSize = source.InstallerSize; + target.ManifestUrl = source.ManifestUrl; + target.UpdateDate = source.UpdateDate; + target.ReleaseNotes = source.ReleaseNotes; + target.ReleaseNotesUrl = source.ReleaseNotesUrl; + target.Tags = source.Tags.ToArray(); + target.Dependencies.Clear(); + target.Dependencies.AddRange(source.Dependencies); + } +} diff --git a/src/UniGetUI.PackageEngine.Tests/Infrastructure/Fakes/TestPackageLoader.cs b/src/UniGetUI.PackageEngine.Tests/Infrastructure/Fakes/TestPackageLoader.cs new file mode 100644 index 0000000000..e64af173ea --- /dev/null +++ b/src/UniGetUI.PackageEngine.Tests/Infrastructure/Fakes/TestPackageLoader.cs @@ -0,0 +1,49 @@ +using UniGetUI.PackageEngine.Interfaces; +using UniGetUI.PackageEngine.PackageLoader; + +namespace UniGetUI.PackageEngine.Tests.Infrastructure.Fakes; + +internal sealed class TestPackageLoader : AbstractPackageLoader +{ + private readonly Func> _loadPackages; + private readonly Func> _isPackageValid; + private readonly Func _whenAddingPackage; + + public TestPackageLoader( + IReadOnlyList managers, + bool allowMultiplePackageVersions = false, + bool disableReload = false, + bool checkedByDefault = false, + Func>? loadPackages = null, + Func>? isPackageValid = null, + Func? whenAddingPackage = null + ) + : base( + managers, + identifier: "TEST_LOADER", + AllowMultiplePackageVersions: allowMultiplePackageVersions, + DisableReload: disableReload, + CheckedBydefault: checkedByDefault, + RequiresInternet: false + ) + { + _loadPackages = loadPackages ?? (manager => manager.GetInstalledPackages()); + _isPackageValid = isPackageValid ?? (_ => Task.FromResult(true)); + _whenAddingPackage = whenAddingPackage ?? (_ => Task.CompletedTask); + } + + protected override IReadOnlyList LoadPackagesFromManager(IPackageManager manager) + { + return _loadPackages(manager); + } + + protected override Task IsPackageValid(IPackage package) + { + return _isPackageValid(package); + } + + protected override Task WhenAddingPackage(IPackage package) + { + return _whenAddingPackage(package); + } +} diff --git a/src/UniGetUI.PackageEngine.Tests/Infrastructure/Fakes/TestPackageManager.cs b/src/UniGetUI.PackageEngine.Tests/Infrastructure/Fakes/TestPackageManager.cs new file mode 100644 index 0000000000..a8ee96dc42 --- /dev/null +++ b/src/UniGetUI.PackageEngine.Tests/Infrastructure/Fakes/TestPackageManager.cs @@ -0,0 +1,175 @@ +using UniGetUI.Interface.Enums; +using UniGetUI.PackageEngine.Classes.Manager; +using UniGetUI.PackageEngine.Interfaces; +using UniGetUI.PackageEngine.ManagerClasses.Manager; +using UniGetUI.PackageEngine.PackageClasses; + +namespace UniGetUI.PackageEngine.Tests.Infrastructure.Fakes; + +public sealed class TestPackageManager : PackageManager +{ + private Func> _findPackages = _ => []; + private Func> _getAvailableUpdates = static () => []; + private Func> _getInstalledPackages = static () => []; + private IReadOnlyList _candidateExecutableFiles; + + public TestPackageManager(string name = "TestManager", string? displayName = null) + { + Capabilities = new ManagerCapabilities + { + CanRunAsAdmin = true, + CanSkipIntegrityChecks = true, + CanRunInteractively = true, + CanRemoveDataOnUninstall = true, + CanDownloadInstaller = true, + CanUninstallPreviousVersionsAfterUpdate = true, + CanListDependencies = true, + SupportsCustomVersions = true, + SupportsCustomArchitectures = true, + SupportsCustomScopes = true, + SupportsPreRelease = true, + SupportsCustomLocations = true, + SupportsCustomSources = true, + SupportsCustomPackageIcons = true, + SupportsCustomPackageScreenshots = true, + SupportsProxy = ProxySupport.Yes, + SupportsProxyAuth = true, + Sources = new SourceCapabilities + { + KnowsPackageCount = true, + KnowsUpdateDate = true, + }, + }; + + Properties = new ManagerProperties + { + Name = name, + DisplayName = displayName ?? name, + Description = "Package-engine test harness manager", + ExecutableFriendlyName = $"{name}.exe", + InstallVerb = "install", + UpdateVerb = "update", + UninstallVerb = "uninstall", + ColorIconId = "package_mask", + IconId = (IconType)'\uE8FD', + }; + + var defaultSource = new ManagerSource(this, "default", new Uri("https://example.test/default")); + var properties = Properties; + properties.DefaultSource = defaultSource; + properties.KnownSources = [defaultSource]; + Properties = properties; + + TestDetailsHelper = new TestPackageDetailsHelper(this); + TestOperationHelper = new TestPackageOperationHelper(this); + TestSourcesHelper = new TestSourceHelper(this); + + DetailsHelper = TestDetailsHelper; + OperationHelper = TestOperationHelper; + SourcesHelper = TestSourcesHelper; + + _candidateExecutableFiles = [$"C:\\test-tools\\{name}.exe"]; + } + + public TestPackageDetailsHelper TestDetailsHelper { get; } + + public TestPackageOperationHelper TestOperationHelper { get; } + + public TestSourceHelper TestSourcesHelper { get; } + + public bool ExecutableFound { get; set; } = true; + + public string ExecutablePath { get; set; } = "C:\\test-tools\\manager.exe"; + + public string ExecutableArguments { get; set; } = ""; + + public string LoadedVersion { get; set; } = "1.0.0-test"; + + public int AttemptFastRepairCalls { get; private set; } + + public int RefreshPackageIndexesCalls { get; private set; } + + public string? LastQuery { get; private set; } + + public void SetFindPackages(Func> findPackages) + { + _findPackages = findPackages; + } + + public void SetAvailableUpdates(Func> getAvailableUpdates) + { + _getAvailableUpdates = getAvailableUpdates; + } + + public void SetInstalledPackages(Func> getInstalledPackages) + { + _getInstalledPackages = getInstalledPackages; + } + + public void SetCandidateExecutableFiles(params string[] candidateExecutableFiles) + { + _candidateExecutableFiles = candidateExecutableFiles; + } + + public void SetKnownSources(IEnumerable sources) + { + var sourceArray = sources.ToArray(); + if (sourceArray.Length == 0) + { + sourceArray = [DefaultSource]; + } + + TestSourcesHelper.SetSources(sourceArray); + + var properties = Properties; + properties.KnownSources = sourceArray; + Properties = properties; + } + + protected override void _loadManagerExecutableFile( + out bool found, + out string path, + out string callArguments + ) + { + found = ExecutableFound; + path = ExecutablePath; + callArguments = ExecutableArguments; + } + + protected override void _loadManagerVersion(out string version) + { + version = LoadedVersion; + } + + protected override IReadOnlyList FindPackages_UnSafe(string query) + { + LastQuery = query; + return _findPackages(query); + } + + protected override IReadOnlyList GetAvailableUpdates_UnSafe() + { + return _getAvailableUpdates(); + } + + protected override IReadOnlyList GetInstalledPackages_UnSafe() + { + return _getInstalledPackages(); + } + + public override IReadOnlyList FindCandidateExecutableFiles() + { + return _candidateExecutableFiles; + } + + public override void AttemptFastRepair() + { + AttemptFastRepairCalls++; + } + + public override void RefreshPackageIndexes() + { + RefreshPackageIndexesCalls++; + } +} diff --git a/src/UniGetUI.PackageEngine.Tests/Infrastructure/Fakes/TestPackageOperationHelper.cs b/src/UniGetUI.PackageEngine.Tests/Infrastructure/Fakes/TestPackageOperationHelper.cs new file mode 100644 index 0000000000..74a9c33079 --- /dev/null +++ b/src/UniGetUI.PackageEngine.Tests/Infrastructure/Fakes/TestPackageOperationHelper.cs @@ -0,0 +1,35 @@ +using UniGetUI.PackageEngine.Classes.Manager.BaseProviders; +using UniGetUI.PackageEngine.Enums; +using UniGetUI.PackageEngine.Interfaces; +using UniGetUI.PackageEngine.Serializable; + +namespace UniGetUI.PackageEngine.Tests.Infrastructure.Fakes; + +public sealed class TestPackageOperationHelper(TestPackageManager manager) + : BasePkgOperationHelper(manager) +{ + public Func> ParametersFactory { get; set; } = + static (_, _, _) => []; + + public Func, int, OperationVeredict> ResultFactory { get; set; } = + static (_, _, _, returnCode) => returnCode == 0 ? OperationVeredict.Success : OperationVeredict.Failure; + + protected override IReadOnlyList _getOperationParameters( + IPackage package, + InstallOptions options, + OperationType operation + ) + { + return ParametersFactory(package, options, operation); + } + + protected override OperationVeredict _getOperationResult( + IPackage package, + OperationType operation, + IReadOnlyList processOutput, + int returnCode + ) + { + return ResultFactory(package, operation, processOutput, returnCode); + } +} diff --git a/src/UniGetUI.PackageEngine.Tests/Infrastructure/Fakes/TestSourceHelper.cs b/src/UniGetUI.PackageEngine.Tests/Infrastructure/Fakes/TestSourceHelper.cs new file mode 100644 index 0000000000..8f39f1b275 --- /dev/null +++ b/src/UniGetUI.PackageEngine.Tests/Infrastructure/Fakes/TestSourceHelper.cs @@ -0,0 +1,60 @@ +using UniGetUI.PackageEngine.Classes.Manager.Providers; +using UniGetUI.PackageEngine.Enums; +using UniGetUI.PackageEngine.Interfaces; + +namespace UniGetUI.PackageEngine.Tests.Infrastructure.Fakes; + +public sealed class TestSourceHelper(TestPackageManager manager) : BaseSourceHelper(manager) +{ + private IReadOnlyList _sources = [manager.DefaultSource]; + + public Func AddParametersFactory { get; set; } = + static source => ["source", "add", source.Name]; + + public Func RemoveParametersFactory { get; set; } = + static source => ["source", "remove", source.Name]; + + public Func AddVeredictFactory { get; set; } = + static (_, returnCode, _) => returnCode == 0 ? OperationVeredict.Success : OperationVeredict.Failure; + + public Func RemoveVeredictFactory { get; set; } = + static (_, returnCode, _) => returnCode == 0 ? OperationVeredict.Success : OperationVeredict.Failure; + + public void SetSources(IEnumerable sources) + { + _sources = sources.ToArray(); + } + + public override string[] GetAddSourceParameters(IManagerSource source) + { + return AddParametersFactory(source); + } + + public override string[] GetRemoveSourceParameters(IManagerSource source) + { + return RemoveParametersFactory(source); + } + + protected override OperationVeredict _getAddSourceOperationVeredict( + IManagerSource source, + int ReturnCode, + string[] Output + ) + { + return AddVeredictFactory(source, ReturnCode, Output); + } + + protected override OperationVeredict _getRemoveSourceOperationVeredict( + IManagerSource source, + int ReturnCode, + string[] Output + ) + { + return RemoveVeredictFactory(source, ReturnCode, Output); + } + + protected override IReadOnlyList GetSources_UnSafe() + { + return _sources; + } +} diff --git a/src/UniGetUI.PackageEngine.Tests/Infrastructure/Fakes/TestUpgradablePackagesLoader.cs b/src/UniGetUI.PackageEngine.Tests/Infrastructure/Fakes/TestUpgradablePackagesLoader.cs new file mode 100644 index 0000000000..b68e9158af --- /dev/null +++ b/src/UniGetUI.PackageEngine.Tests/Infrastructure/Fakes/TestUpgradablePackagesLoader.cs @@ -0,0 +1,25 @@ +using System.Reflection; +using UniGetUI.PackageEngine.Interfaces; +using UniGetUI.PackageEngine.PackageLoader; + +namespace UniGetUI.PackageEngine.Tests.Infrastructure.Fakes; + +internal sealed class TestUpgradablePackagesLoader : UpgradablePackagesLoader +{ + private static readonly FieldInfo UpdatesTimerField = typeof(UpgradablePackagesLoader).GetField( + "UpdatesTimer", + BindingFlags.Instance | BindingFlags.NonPublic + )!; + + public TestUpgradablePackagesLoader(IReadOnlyList managers) + : base(managers) { } + + public Task EvaluatePackageAsync(IPackage package) => IsPackageValid(package); + + public Task ApplyWhenAddingPackageAsync(IPackage package) => WhenAddingPackage(package); + + public void StartTimer() => StartAutoCheckTimeout(); + + public double? GetTimerIntervalMilliseconds() => + (UpdatesTimerField.GetValue(this) as System.Timers.Timer)?.Interval; +} diff --git a/src/UniGetUI.PackageEngine.Tests/Infrastructure/Helpers/LoaderEventRecorder.cs b/src/UniGetUI.PackageEngine.Tests/Infrastructure/Helpers/LoaderEventRecorder.cs new file mode 100644 index 0000000000..1d4fbc52a7 --- /dev/null +++ b/src/UniGetUI.PackageEngine.Tests/Infrastructure/Helpers/LoaderEventRecorder.cs @@ -0,0 +1,37 @@ +using UniGetUI.PackageEngine.PackageLoader; + +namespace UniGetUI.PackageEngine.Tests.Infrastructure.Helpers; + +public sealed class LoaderEventRecorder +{ + public LoaderEventRecorder(AbstractPackageLoader loader) + { + loader.StartedLoading += (_, _) => + { + StartedLoading = true; + StartedLoadingCount++; + }; + loader.FinishedLoading += (_, _) => + { + FinishedLoading = true; + FinishedLoadingCount++; + }; + loader.PackagesChanged += (_, args) => Changes.Add(args); + } + + public bool StartedLoading { get; private set; } + + public int StartedLoadingCount { get; private set; } + + public bool FinishedLoading { get; private set; } + + public int FinishedLoadingCount { get; private set; } + + public List Changes { get; } = []; + + public IReadOnlyList AddedPackages => + Changes.SelectMany(change => change.AddedPackages).ToArray(); + + public IReadOnlyList RemovedPackages => + Changes.SelectMany(change => change.RemovedPackages).ToArray(); +} diff --git a/src/UniGetUI.PackageEngine.Tests/Infrastructure/Helpers/PackageEngineFixtureFiles.cs b/src/UniGetUI.PackageEngine.Tests/Infrastructure/Helpers/PackageEngineFixtureFiles.cs new file mode 100644 index 0000000000..e8971042ac --- /dev/null +++ b/src/UniGetUI.PackageEngine.Tests/Infrastructure/Helpers/PackageEngineFixtureFiles.cs @@ -0,0 +1,23 @@ +namespace UniGetUI.PackageEngine.Tests.Infrastructure.Helpers; + +public static class PackageEngineFixtureFiles +{ + public static string RootPath => Path.Combine(AppContext.BaseDirectory, "Fixtures"); + + public static string GetPath(string relativePath) + { + var fullPath = Path.Combine(RootPath, relativePath); + Assert.True(File.Exists(fullPath), $"Expected fixture file to exist: {fullPath}"); + return fullPath; + } + + public static string ReadAllText(string relativePath) + { + return File.ReadAllText(GetPath(relativePath)); + } + + public static byte[] ReadAllBytes(string relativePath) + { + return File.ReadAllBytes(GetPath(relativePath)); + } +} diff --git a/src/UniGetUI.PackageEngine.Tests/Infrastructure/Helpers/TestHttpServer.cs b/src/UniGetUI.PackageEngine.Tests/Infrastructure/Helpers/TestHttpServer.cs new file mode 100644 index 0000000000..e3e731f47d --- /dev/null +++ b/src/UniGetUI.PackageEngine.Tests/Infrastructure/Helpers/TestHttpServer.cs @@ -0,0 +1,87 @@ +using System.Net; +using System.Net.Sockets; + +namespace UniGetUI.PackageEngine.Tests.Infrastructure.Helpers; + +internal sealed class TestHttpServer : IDisposable +{ + private readonly HttpListener _listener = new(); + private readonly Func _handler; + private readonly List _requestPaths = []; + private readonly Task _backgroundTask; + + public TestHttpServer( + Func handler + ) + { + _handler = handler; + int port = GetAvailablePort(); + BaseUri = new Uri($"http://127.0.0.1:{port}/"); + _listener.Prefixes.Add(BaseUri.AbsoluteUri); + _listener.Start(); + _backgroundTask = Task.Run(ListenAsync); + } + + public Uri BaseUri { get; } + + public IReadOnlyList RequestPaths + { + get + { + lock (_requestPaths) + { + return _requestPaths.ToArray(); + } + } + } + + public void Dispose() + { + if (_listener.IsListening) + { + _listener.Stop(); + } + + _listener.Close(); + _backgroundTask.GetAwaiter().GetResult(); + } + + private async Task ListenAsync() + { + while (true) + { + try + { + HttpListenerContext context = await _listener.GetContextAsync(); + + lock (_requestPaths) + { + _requestPaths.Add(context.Request.RawUrl ?? context.Request.Url?.AbsolutePath ?? string.Empty); + } + + var (statusCode, content, contentType) = _handler(context.Request); + context.Response.StatusCode = statusCode; + context.Response.ContentType = contentType; + using StreamWriter writer = new(context.Response.OutputStream); + await writer.WriteAsync(content); + await writer.FlushAsync(); + context.Response.Close(); + } + catch (HttpListenerException) + { + break; + } + catch (ObjectDisposedException) + { + break; + } + } + } + + private static int GetAvailablePort() + { + using TcpListener listener = new(IPAddress.Loopback, 0); + listener.Start(); + return ((IPEndPoint)listener.LocalEndpoint).Port; + } +} diff --git a/src/UniGetUI.PackageEngine.Tests/InstallOptionsFactoryTests.cs b/src/UniGetUI.PackageEngine.Tests/InstallOptionsFactoryTests.cs new file mode 100644 index 0000000000..ef63fad7ba --- /dev/null +++ b/src/UniGetUI.PackageEngine.Tests/InstallOptionsFactoryTests.cs @@ -0,0 +1,164 @@ +using UniGetUI.Core.Data; +using UniGetUI.Core.SettingsEngine; +using UniGetUI.Core.SettingsEngine.SecureSettings; +using UniGetUI.Core.Tools; +using UniGetUI.PackageEngine.Classes.Packages; +using UniGetUI.PackageEngine.Classes.Packages.Classes; +using UniGetUI.PackageEngine.PackageClasses; +using UniGetUI.PackageEngine.Serializable; +using UniGetUI.PackageEngine.Tests.Infrastructure.Builders; + +namespace UniGetUI.PackageEngine.Tests; + +public sealed class InstallOptionsFactoryTests : IDisposable +{ + private readonly string _testRoot = Path.Combine( + Path.GetTempPath(), + nameof(InstallOptionsFactoryTests), + Guid.NewGuid().ToString("N") + ); + + public InstallOptionsFactoryTests() + { + Directory.CreateDirectory(_testRoot); + CoreData.TEST_DataDirectoryOverride = Path.Combine(_testRoot, "Data"); + SecureSettings.TEST_SecureSettingsRootOverride = Path.Combine(_testRoot, "SecureSettings"); + Directory.CreateDirectory(CoreData.UniGetUIInstallationOptionsDirectory); + Directory.CreateDirectory(CoreData.UniGetUIUserConfigurationDirectory); + Settings.ResetSettings(); + SecureSettings.ApplyForUser( + Environment.UserName, + SecureSettings.ResolveKey(SecureSettings.K.AllowCLIArguments), + false + ); + SecureSettings.ApplyForUser( + Environment.UserName, + SecureSettings.ResolveKey(SecureSettings.K.AllowPrePostOpCommand), + false + ); + } + + public void Dispose() + { + Settings.ResetSettings(); + CoreData.TEST_DataDirectoryOverride = null; + SecureSettings.TEST_SecureSettingsRootOverride = null; + if (Directory.Exists(_testRoot)) + { + Directory.Delete(_testRoot, recursive: true); + } + } + + [Fact] + public void LoadApplicable_UsesManagerDefaultsAndExpandsPackageToken() + { + var manager = new PackageManagerBuilder().WithName($"Manager{Guid.NewGuid():N}").Build(); + var package = new PackageBuilder().WithManager(manager).WithId("Contoso:Tool").Build(); + var managerOptions = new InstallOptions + { + CustomInstallLocation = @"C:\Apps\%PACKAGE%", + InteractiveInstallation = true, + }; + + InstallOptionsFactory.SaveForManager(managerOptions, manager); + InstallOptionsFactory.SaveForPackage(new InstallOptions(), package); + + var resolved = InstallOptionsFactory.LoadApplicable(package); + + Assert.Equal( + $@"C:\Apps\{CoreTools.MakeValidFileName(package.Id)}", + resolved.CustomInstallLocation + ); + Assert.True(resolved.InteractiveInstallation); + } + + [Fact] + public void LoadApplicable_AppliesExplicitOverridesAndRemovesDisallowedSecureOptions() + { + var manager = new PackageManagerBuilder().WithName($"Manager{Guid.NewGuid():N}").Build(); + var package = new PackageBuilder().WithManager(manager).WithId($"Pkg{Guid.NewGuid():N}").Build(); + var packageOptions = new InstallOptions + { + OverridesNextLevelOpts = true, + CustomParameters_Install = ["--keep&drop|;<>\n"], + CustomParameters_Update = ["--update"], + CustomParameters_Uninstall = ["--remove"], + PreInstallCommand = "echo pre", + PostInstallCommand = "echo post", + PreUpdateCommand = "echo pre-update", + PostUpdateCommand = "echo post-update", + PreUninstallCommand = "echo pre-uninstall", + PostUninstallCommand = "echo post-uninstall", + }; + + InstallOptionsFactory.SaveForPackage(packageOptions, package); + + var resolved = InstallOptionsFactory.LoadApplicable( + package, + elevated: true, + interactive: true, + no_integrity: true, + remove_data: true + ); + + Assert.True(resolved.RunAsAdministrator); + Assert.True(resolved.InteractiveInstallation); + Assert.True(resolved.SkipHashCheck); + Assert.True(resolved.RemoveDataOnUninstall); + Assert.Empty(resolved.CustomParameters_Install); + Assert.Empty(resolved.CustomParameters_Update); + Assert.Empty(resolved.CustomParameters_Uninstall); + Assert.Equal("", resolved.PreInstallCommand); + Assert.Equal("", resolved.PostInstallCommand); + Assert.Equal("", resolved.PreUpdateCommand); + Assert.Equal("", resolved.PostUpdateCommand); + Assert.Equal("", resolved.PreUninstallCommand); + Assert.Equal("", resolved.PostUninstallCommand); + } + + [Fact] + public void LoadApplicable_SanitizesCustomParametersWhenCliArgumentsAreAllowed() + { + var manager = new PackageManagerBuilder().WithName($"Manager{Guid.NewGuid():N}").Build(); + var package = new PackageBuilder().WithManager(manager).WithId($"Pkg{Guid.NewGuid():N}").Build(); + var packageOptions = new InstallOptions + { + OverridesNextLevelOpts = true, + CustomParameters_Install = ["--keep&drop|;<>\n"], + }; + + SecureSettings.ApplyForUser(Environment.UserName, SecureSettings.ResolveKey(SecureSettings.K.AllowCLIArguments), true); + InstallOptionsFactory.SaveForPackage(packageOptions, package); + + var resolved = InstallOptionsFactory.LoadApplicable(package); + + Assert.Equal(["--keepdrop"], resolved.CustomParameters_Install); + } + + [Fact] + public void SaveAndLoadForPackage_RoundTripsPersistedOptions() + { + var manager = new PackageManagerBuilder().WithName($"Manager{Guid.NewGuid():N}").Build(); + var package = new PackageBuilder().WithManager(manager).WithId($"Pkg{Guid.NewGuid():N}").Build(); + var expected = new InstallOptions + { + OverridesNextLevelOpts = true, + Architecture = "x64", + CustomInstallLocation = @"D:\Tools", + InteractiveInstallation = true, + SkipMinorUpdates = true, + }; + expected.CustomParameters_Install.Add("--quiet"); + + InstallOptionsFactory.SaveForPackage(expected, package); + + var actual = InstallOptionsFactory.LoadForPackage(package); + + Assert.True(actual.OverridesNextLevelOpts); + Assert.Equal("x64", actual.Architecture); + Assert.Equal(@"D:\Tools", actual.CustomInstallLocation); + Assert.True(actual.InteractiveInstallation); + Assert.True(actual.SkipMinorUpdates); + Assert.Equal(["--quiet"], actual.CustomParameters_Install); + } +} diff --git a/src/UniGetUI.PackageEngine.Tests/NpmManagerTests.cs b/src/UniGetUI.PackageEngine.Tests/NpmManagerTests.cs new file mode 100644 index 0000000000..6b89681c58 --- /dev/null +++ b/src/UniGetUI.PackageEngine.Tests/NpmManagerTests.cs @@ -0,0 +1,166 @@ +using UniGetUI.PackageEngine.Enums; +using UniGetUI.PackageEngine.Managers.NpmManager; +using UniGetUI.PackageEngine.Serializable; +using UniGetUI.PackageEngine.Structs; +using UniGetUI.PackageEngine.Tests.Infrastructure.Assertions; +using UniGetUI.PackageEngine.Tests.Infrastructure.Builders; +using UniGetUI.PackageEngine.Tests.Infrastructure.Helpers; + +namespace UniGetUI.PackageEngine.Tests; + +public sealed class NpmManagerTests +{ + [Fact] + public void ParseSearchOutputParsesJsonArrayAfterWarningPrefix() + { + var manager = new Npm(); + + var packages = Npm.ParseSearchOutput( + PackageEngineFixtureFiles.ReadAllText(@"Npm\search-array-with-warning.txt"), + manager.DefaultSource, + manager + ); + + var packageList = packages.ToArray(); + Assert.Equal(2, packageList.Length); + PackageAssert.BelongsTo(packageList[0], manager, manager.DefaultSource); + Assert.Equal("left-pad", packageList[0].Id); + Assert.Equal("1.3.0", packageList[0].VersionString); + PackageAssert.BelongsTo(packageList[1], manager, manager.DefaultSource); + Assert.Equal("@types/node", packageList[1].Id); + Assert.Equal("24.0.0", packageList[1].VersionString); + } + + [Fact] + public void ParseSearchOutputFallsBackToNdjsonAndSkipsInvalidEntries() + { + var manager = new Npm(); + + var packages = Npm.ParseSearchOutput( + PackageEngineFixtureFiles.ReadAllText(@"Npm\search-ndjson.txt"), + manager.DefaultSource, + manager + ); + + var packageList = packages.ToArray(); + Assert.Equal(2, packageList.Length); + Assert.Equal("chalk", packageList[0].Id); + Assert.Equal("5.4.1", packageList[0].VersionString); + Assert.Equal("npm-check-updates", packageList[1].Id); + Assert.Equal("17.1.1", packageList[1].VersionString); + } + + [Fact] + public void ParseAvailableUpdatesOutputCreatesPackagesWithRequestedScope() + { + var manager = new Npm(); + + var packages = Npm.ParseAvailableUpdatesOutput( + PackageEngineFixtureFiles.ReadAllText(@"Npm\outdated.json"), + manager.DefaultSource, + manager, + new OverridenInstallationOptions(PackageScope.Global) + ); + + var package = Assert.Single(packages); + PackageAssert.BelongsTo(package, manager, manager.DefaultSource); + Assert.Equal("npm", package.Id); + Assert.Equal("10.9.0", package.VersionString); + Assert.Equal("11.0.0", package.NewVersionString); + Assert.Equal(PackageScope.Global, package.OverridenOptions.Scope); + } + + [Fact] + public void ParseInstalledPackagesOutputCreatesPackagesWithRequestedScope() + { + var manager = new Npm(); + + var packages = Npm.ParseInstalledPackagesOutput( + PackageEngineFixtureFiles.ReadAllText(@"Npm\installed.json"), + manager.DefaultSource, + manager, + new OverridenInstallationOptions(PackageScope.Local) + ); + + var package = Assert.Single(packages); + PackageAssert.BelongsTo(package, manager, manager.DefaultSource); + Assert.Equal("rimraf", package.Id); + Assert.Equal("6.0.1", package.VersionString); + Assert.Equal(PackageScope.Local, package.OverridenOptions.Scope); + } + + [Fact] + public void OperationHelperBuildsInstallParametersFromOptions() + { + var manager = new Npm(); + var package = new PackageBuilder() + .WithManager(manager) + .WithId("contoso-tool") + .WithVersion("1.0.0") + .Build(); + var options = new InstallOptions + { + Version = "2.0.0", + InstallationScope = PackageScope.Global, + PreRelease = true, + }; + options.CustomParameters_Install.Add("--foreground-scripts"); + + var parameters = manager.OperationHelper.GetParameters(package, options, OperationType.Install); + + Assert.Equal( + [ + "install", + OperatingSystem.IsWindows() ? "'contoso-tool@2.0.0'" : "contoso-tool@2.0.0", + "--global", + "--include", + "dev", + "--foreground-scripts", + ], + parameters + ); + } + + [Fact] + public void OperationHelperLetsPackageScopeOverrideUpdateScope() + { + var manager = new Npm(); + var package = new PackageBuilder() + .WithManager(manager) + .WithId("contoso-tool") + .WithVersion("1.0.0") + .WithNewVersion("3.0.0") + .WithOptions(new OverridenInstallationOptions(PackageScope.Global)) + .Build(); + var options = new InstallOptions + { + InstallationScope = PackageScope.Local, + }; + options.CustomParameters_Update.Add("--audit"); + + var parameters = manager.OperationHelper.GetParameters(package, options, OperationType.Update); + + Assert.Equal( + [ + "install", + OperatingSystem.IsWindows() ? "'contoso-tool@3.0.0'" : "contoso-tool@3.0.0", + "--global", + "--audit", + ], + parameters + ); + } + + [Fact] + public void OperationHelperReturnsSuccessOnlyForZeroExitCode() + { + var manager = new Npm(); + var package = new PackageBuilder().WithManager(manager).Build(); + + var success = manager.OperationHelper.GetResult(package, OperationType.Install, [], 0); + var failure = manager.OperationHelper.GetResult(package, OperationType.Install, [], 1); + + Assert.Equal(OperationVeredict.Success, success); + Assert.Equal(OperationVeredict.Failure, failure); + } +} diff --git a/src/UniGetUI.PackageEngine.Tests/NuGetManifestLoaderTests.cs b/src/UniGetUI.PackageEngine.Tests/NuGetManifestLoaderTests.cs new file mode 100644 index 0000000000..383baf25d8 --- /dev/null +++ b/src/UniGetUI.PackageEngine.Tests/NuGetManifestLoaderTests.cs @@ -0,0 +1,84 @@ +using UniGetUI.PackageEngine.Managers.Generic.NuGet; +using UniGetUI.PackageEngine.Managers.Generic.NuGet.Internal; +using UniGetUI.PackageEngine.Managers.PowerShellManager; +using UniGetUI.PackageEngine.Tests.Infrastructure.Builders; +using UniGetUI.PackageEngine.Tests.Infrastructure.Helpers; + +namespace UniGetUI.PackageEngine.Tests; + +public sealed class NuGetManifestLoaderTests +{ + [Fact] + public void GetManifestUrlAndNuPkgUrl_UsePackageSourceAndVersion() + { + var manager = new PackageManagerBuilder().Build(); + var source = new SourceBuilder() + .WithManager(manager) + .WithUrl("https://packages.example.test/feed") + .Build(); + var package = new PackageBuilder() + .WithManager(manager) + .WithSource(source) + .WithId("Contoso.Tool") + .WithVersion("1.2.3") + .Build(); + + Assert.Equal( + "https://packages.example.test/feed/Packages(Id='Contoso.Tool',Version='1.2.3')", + NuGetManifestLoader.GetManifestUrl(package).ToString() + ); + Assert.Equal( + "https://packages.example.test/feed/package/Contoso.Tool/1.2.3", + NuGetManifestLoader.GetNuPkgUrl(package).ToString() + ); + } + + [Fact] + public void GetManifestContent_UsesCachedManifestWhenAvailable() + { + BaseNuGet.Manifests.Clear(); + var manager = new PackageManagerBuilder().Build(); + var package = new PackageBuilder().WithManager(manager).WithId("Contoso.Tool").Build(); + BaseNuGet.Manifests[package.GetHash()] = ""; + + Assert.Equal("", NuGetManifestLoader.GetManifestContent(package)); + } + + [Fact] + public void GetManifestContent_FallsBackWhenTrailingZeroVersionReturnsNotFound() + { + BaseNuGet.Manifests.Clear(); + using var server = new TestHttpServer(request => + { + string rawUrl = request.RawUrl ?? string.Empty; + return rawUrl switch + { + string value when value.Contains("Version='1.0.0'") || value.Contains("Version=%271.0.0%27") + => (404, string.Empty, "application/xml"), + string value when value.Contains("Version='1.0'") || value.Contains("Version=%271.0%27") + => (200, "manifest", "application/xml"), + _ => (500, string.Empty, "text/plain"), + }; + }); + var manager = new PackageManagerBuilder().Build(); + var source = new SourceBuilder() + .WithManager(manager) + .WithUrl(server.BaseUri.AbsoluteUri.TrimEnd('/')) + .Build(); + var package = new PackageBuilder() + .WithManager(manager) + .WithSource(source) + .WithId("Contoso.Tool") + .WithVersion("1.0.0") + .Build(); + + var manifest = NuGetManifestLoader.GetManifestContent(package); + + Assert.Equal("manifest", manifest); + Assert.Equal(2, server.RequestPaths.Count); + Assert.Contains("1.0.0", server.RequestPaths[0]); + Assert.True( + server.RequestPaths[1].Contains("Version='1.0'") || server.RequestPaths[1].Contains("Version=%271.0%27") + ); + } +} diff --git a/src/UniGetUI.PackageEngine.Tests/PackageLoaderPipelineTests.cs b/src/UniGetUI.PackageEngine.Tests/PackageLoaderPipelineTests.cs new file mode 100644 index 0000000000..658ad8a0be --- /dev/null +++ b/src/UniGetUI.PackageEngine.Tests/PackageLoaderPipelineTests.cs @@ -0,0 +1,191 @@ +using UniGetUI.PackageEngine.Interfaces; +using UniGetUI.PackageEngine.Tests.Infrastructure.Builders; +using UniGetUI.PackageEngine.Tests.Infrastructure.Fakes; +using UniGetUI.PackageEngine.Tests.Infrastructure.Helpers; + +namespace UniGetUI.PackageEngine.Tests; + +public sealed class PackageLoaderPipelineTests +{ + [Fact] + public async Task AddForeign_DeduplicatesByPackageHash_WhenVersionAwareIdentityIsDisabled() + { + var manager = new PackageManagerBuilder().Build(); + var loader = new TestPackageLoader([manager], allowMultiplePackageVersions: false); + var packageV1 = new PackageBuilder() + .WithManager(manager) + .WithId("Contoso.Tool") + .WithVersion("1.0.0") + .Build(); + var packageV2 = new PackageBuilder() + .WithManager(manager) + .WithId("Contoso.Tool") + .WithVersion("2.0.0") + .Build(); + + await loader.AddForeign(packageV1); + await loader.AddForeign(packageV2); + + Assert.Equal(1, loader.Count()); + Assert.True(loader.Contains(packageV1)); + Assert.True(loader.Contains(packageV2)); + Assert.Same(packageV1, loader.GetEquivalentPackage(packageV2)); + Assert.Single(loader.GetEquivalentPackages(packageV1)); + } + + [Fact] + public async Task AddForeign_KeepsDistinctVersions_WhenVersionAwareIdentityIsEnabled() + { + var manager = new PackageManagerBuilder().Build(); + var loader = new TestPackageLoader([manager], allowMultiplePackageVersions: true); + var packageV1 = new PackageBuilder() + .WithManager(manager) + .WithId("Contoso.Tool") + .WithVersion("1.0.0") + .Build(); + var packageV2 = new PackageBuilder() + .WithManager(manager) + .WithId("Contoso.Tool") + .WithVersion("2.0.0") + .Build(); + + await loader.AddForeign(packageV1); + await loader.AddForeign(packageV2); + + Assert.Equal(2, loader.Count()); + Assert.Same(packageV1, loader.GetEquivalentPackage(packageV1)); + Assert.Same(packageV2, loader.GetEquivalentPackage(packageV2)); + Assert.Equal( + ["1.0.0", "2.0.0"], + loader.GetEquivalentPackages(packageV1).Select(package => package.VersionString).Order() + ); + } + + [Fact] + public async Task LookupAndRemovalApisUseLoaderIdentityAndSourceFiltering() + { + var manager = new PackageManagerBuilder() + .WithSources(testManager => + [ + new SourceBuilder() + .WithManager(testManager) + .WithName("stable") + .WithUrl("https://example.test/stable") + .Build(), + new SourceBuilder() + .WithManager(testManager) + .WithName("beta") + .WithUrl("https://example.test/beta") + .Build(), + ]) + .Build(); + var stableSource = manager.SourcesHelper.GetSources().Single(source => source.Name == "stable"); + var betaSource = manager.SourcesHelper.GetSources().Single(source => source.Name == "beta"); + var loader = new TestPackageLoader([manager]); + var uniquePackage = new PackageBuilder() + .WithManager(manager) + .WithSource(stableSource) + .WithId("Contoso.Unique") + .Build(); + var stablePackage = new PackageBuilder() + .WithManager(manager) + .WithSource(stableSource) + .WithId("Contoso.Shared") + .Build(); + var betaPackage = new PackageBuilder() + .WithManager(manager) + .WithSource(betaSource) + .WithId("Contoso.Shared") + .Build(); + var recorder = new LoaderEventRecorder(loader); + + await loader.AddForeign(uniquePackage); + await loader.AddForeign(stablePackage); + await loader.AddForeign(betaPackage); + loader.Remove(betaPackage); + + Assert.True(loader.Contains(stablePackage)); + Assert.False(loader.Contains(betaPackage)); + Assert.Same(uniquePackage, loader.GetPackageForId("Contoso.Unique")); + Assert.Same(stablePackage, loader.GetPackageForId("Contoso.Shared", "stable")); + Assert.Null(loader.GetPackageForId("Contoso.Shared", "beta")); + Assert.Single(loader.GetEquivalentPackages(stablePackage)); + Assert.Null(loader.GetEquivalentPackage(betaPackage)); + Assert.Contains(recorder.RemovedPackages, package => package.Id == "Contoso.Shared"); + } + + [Fact] + public async Task ClearPackagesAndStopLoading_EmitExpectedEvents() + { + var manager = new PackageManagerBuilder().Build(); + var loader = new TestPackageLoader([manager]); + var recorder = new LoaderEventRecorder(loader); + var package = new PackageBuilder().WithManager(manager).WithId("Contoso.Tool").Build(); + + await loader.AddForeign(package); + loader.StopLoading(emitFinishSignal: false); + loader.ClearPackages(); + + Assert.Equal(0, loader.Count()); + Assert.Equal(1, recorder.FinishedLoadingCount); + Assert.Equal(2, recorder.Changes.Count); + Assert.False(recorder.Changes[1].ProceduralChange); + Assert.Empty(recorder.Changes[1].AddedPackages); + Assert.Empty(recorder.Changes[1].RemovedPackages); + } + + [Fact] + public async Task ReloadPackages_ClearsExistingPackages_FiltersInvalidOnes_AndRaisesLifecycleEvents() + { + var manager = new PackageManagerBuilder() + .WithInstalledPackages(testManager => + [ + new PackageBuilder() + .WithManager(testManager) + .WithId("Contoso.Accepted") + .WithVersion("1.0.0") + .Build(), + new PackageBuilder() + .WithManager(testManager) + .WithId("Contoso.Accepted") + .WithVersion("1.0.0") + .Build(), + new PackageBuilder() + .WithManager(testManager) + .WithId("Contoso.Rejected") + .WithVersion("1.0.0") + .Build(), + ]) + .Build(); + var loader = new TestPackageLoader( + [manager], + loadPackages: testManager => testManager.GetInstalledPackages(), + isPackageValid: package => Task.FromResult(package.Id != "Contoso.Rejected") + ); + var stalePackage = new PackageBuilder() + .WithManager(manager) + .WithId("Contoso.Stale") + .WithVersion("0.9.0") + .Build(); + var recorder = new LoaderEventRecorder(loader); + + await loader.AddForeign(stalePackage); + await loader.ReloadPackages(); + + var changeEvents = recorder.Changes.Skip(1).ToArray(); + + Assert.True(recorder.StartedLoading); + Assert.True(recorder.FinishedLoading); + Assert.Equal(1, recorder.StartedLoadingCount); + Assert.Equal(1, recorder.FinishedLoadingCount); + Assert.True(loader.IsLoaded); + Assert.False(loader.IsLoading); + Assert.Equal(["Contoso.Accepted"], loader.Packages.Select(package => package.Id).Distinct()); + Assert.Single(loader.Packages); + Assert.Equal(2, changeEvents.Length); + Assert.False(changeEvents[0].ProceduralChange); + Assert.Empty(changeEvents[0].AddedPackages); + Assert.True(changeEvents[1].ProceduralChange); + Assert.Equal(["Contoso.Accepted"], changeEvents[1].AddedPackages.Select(package => package.Id).Distinct()); + } +} diff --git a/src/UniGetUI.PackageEngine.Tests/PackageManagerTests.cs b/src/UniGetUI.PackageEngine.Tests/PackageManagerTests.cs new file mode 100644 index 0000000000..b9d8a7a249 --- /dev/null +++ b/src/UniGetUI.PackageEngine.Tests/PackageManagerTests.cs @@ -0,0 +1,334 @@ +using UniGetUI.Core.Data; +using UniGetUI.Core.SettingsEngine; +using UniGetUI.Core.SettingsEngine.SecureSettings; +using UniGetUI.Core.Tools; +using UniGetUI.PackageEngine.PackageClasses; +using UniGetUI.PackageEngine.Tests.Infrastructure.Builders; +using UniGetUI.PackageEngine.Tests.Infrastructure.Fakes; + +namespace UniGetUI.PackageEngine.Tests; + +public sealed class PackageManagerTests : IDisposable +{ + private readonly string _testRoot; + + public PackageManagerTests() + { + _testRoot = Path.Combine( + AppContext.BaseDirectory, + nameof(PackageManagerTests), + Guid.NewGuid().ToString("N") + ); + var secureSettingsRoot = Path.Combine(_testRoot, "SecureSettings"); + + CoreData.TEST_DataDirectoryOverride = Path.Combine(_testRoot, "Data"); + SecureSettings.TEST_SecureSettingsRootOverride = secureSettingsRoot; + + Directory.CreateDirectory(CoreData.UniGetUIUserConfigurationDirectory); + Directory.CreateDirectory(secureSettingsRoot); + + Settings.ResetSettings(); + Settings.SetDictionary(Settings.K.DisabledManagers, new Dictionary()); + Settings.SetDictionary(Settings.K.ManagerPaths, new Dictionary()); + SecureSettings.ApplyForUser( + Environment.UserName, + SecureSettings.ResolveKey(SecureSettings.K.AllowCustomManagerPaths), + false + ); + } + + public void Dispose() + { + Settings.SetDictionary(Settings.K.DisabledManagers, new Dictionary()); + Settings.SetDictionary(Settings.K.ManagerPaths, new Dictionary()); + SecureSettings.ApplyForUser( + Environment.UserName, + SecureSettings.ResolveKey(SecureSettings.K.AllowCustomManagerPaths), + false + ); + + CoreData.TEST_DataDirectoryOverride = null; + SecureSettings.TEST_SecureSettingsRootOverride = null; + + if (Directory.Exists(_testRoot)) + { + Directory.Delete(_testRoot, true); + } + } + + [Fact] + public void InitializeDisabledManagerSkipsLoadingAndStaysNotReady() + { + var manager = CreateManager(); + Settings.SetDictionaryItem(Settings.K.DisabledManagers, manager.Name, true); + + manager.Initialize(); + + Assert.False(manager.IsEnabled()); + Assert.False(manager.IsReady()); + Assert.False(manager.Status.Found); + Assert.Equal(string.Empty, manager.Status.ExecutablePath); + Assert.Equal( + CoreTools.Translate("{0} is disabled", manager.DisplayName), + manager.Status.Version + ); + } + + [Fact] + public void InitializeEnabledManagerWithoutExecutableStaysNotReady() + { + var manager = CreateManager(); + manager.ExecutableFound = false; + manager.ExecutablePath = CreateExecutable("missing-manager.exe"); + + manager.Initialize(); + + Assert.True(manager.IsEnabled()); + Assert.False(manager.IsReady()); + Assert.False(manager.Status.Found); + Assert.Equal(manager.ExecutablePath, manager.Status.ExecutablePath); + Assert.Equal( + CoreTools.Translate("{pm} was not found!").Replace("{pm}", manager.DisplayName).Trim('!'), + manager.Status.Version + ); + } + + [Fact] + public void InitializeEnabledManagerWithExecutableBecomesReady() + { + var manager = CreateManager(); + manager.ExecutablePath = CreateExecutable("ready-manager.exe"); + manager.LoadedVersion = "9.9.9-test"; + + manager.Initialize(); + + Assert.True(manager.IsEnabled()); + Assert.True(manager.IsReady()); + Assert.True(manager.Status.Found); + Assert.Equal(manager.ExecutablePath, manager.Status.ExecutablePath); + Assert.Equal("9.9.9-test", manager.Status.Version); + } + + [Fact] + public void IsReadyTracksEnablementChangesAfterInitialization() + { + var manager = CreateManager(); + manager.ExecutablePath = CreateExecutable("toggle-manager.exe"); + + Assert.True(manager.IsEnabled()); + Assert.False(manager.IsReady()); + + manager.Initialize(); + + Assert.True(manager.IsReady()); + + Settings.SetDictionaryItem(Settings.K.DisabledManagers, manager.Name, true); + + Assert.False(manager.IsEnabled()); + Assert.False(manager.IsReady()); + + Settings.SetDictionaryItem(Settings.K.DisabledManagers, manager.Name, false); + + Assert.True(manager.IsEnabled()); + Assert.True(manager.IsReady()); + } + + [Fact] + public void GetExecutableFileReturnsFalseWhenNoCandidatesExist() + { + var manager = CreateManager(); + manager.SetCandidateExecutableFiles(); + + var executable = manager.GetExecutableFile(); + + Assert.False(executable.Item1); + Assert.Equal(string.Empty, executable.Item2); + } + + [Fact] + public void GetExecutableFileIgnoresSavedPathWhenCustomPathsAreDisabled() + { + var manager = CreateManager(); + var first = CreateExecutable("candidate-a.exe"); + var second = CreateExecutable("candidate-b.exe"); + manager.SetCandidateExecutableFiles(first, second); + Settings.SetDictionaryItem(Settings.K.ManagerPaths, manager.Name, second); + + var executable = manager.GetExecutableFile(); + + Assert.True(executable.Item1); + Assert.Equal(first, executable.Item2); + } + + [Fact] + public void GetExecutableFileUsesSavedCandidateWhenCustomPathsAreEnabled() + { + var manager = CreateManager(); + var first = CreateExecutable("enabled-a.exe"); + var second = CreateExecutable("enabled-b.exe"); + manager.SetCandidateExecutableFiles(first, second); + EnableCustomManagerPaths(); + Settings.SetDictionaryItem(Settings.K.ManagerPaths, manager.Name, second); + + var executable = manager.GetExecutableFile(); + + Assert.True(executable.Item1); + Assert.Equal(second, executable.Item2); + } + + [Fact] + public void GetExecutableFileFallsBackWhenSavedPathDoesNotExist() + { + var manager = CreateManager(); + var first = CreateExecutable("fallback-a.exe"); + var second = CreateExecutable("fallback-b.exe"); + manager.SetCandidateExecutableFiles(first, second); + EnableCustomManagerPaths(); + Settings.SetDictionaryItem( + Settings.K.ManagerPaths, + manager.Name, + Path.Combine(_testRoot, "missing.exe") + ); + + var executable = manager.GetExecutableFile(); + + Assert.True(executable.Item1); + Assert.Equal(first, executable.Item2); + } + + [Fact] + public void GetExecutableFileFallsBackWhenSavedPathIsNotACandidate() + { + var manager = CreateManager(); + var first = CreateExecutable("outside-a.exe"); + var second = CreateExecutable("outside-b.exe"); + var outsideCandidateList = CreateExecutable("outside-c.exe"); + manager.SetCandidateExecutableFiles(first, second); + EnableCustomManagerPaths(); + Settings.SetDictionaryItem( + Settings.K.ManagerPaths, + manager.Name, + outsideCandidateList + ); + + var executable = manager.GetExecutableFile(); + + Assert.True(executable.Item1); + Assert.Equal(first, executable.Item2); + } + + [Fact] + public void FindPackagesReturnsEmptyWhenManagerIsNotReady() + { + var manager = CreateManager(); + + var packages = manager.FindPackages("tool"); + + Assert.Empty(packages); + Assert.Null(manager.LastQuery); + Assert.Equal(0, manager.AttemptFastRepairCalls); + } + + [Fact] + public void FindPackagesRetriesOnceAfterFailure() + { + var manager = CreateReadyManager(); + var attempts = 0; + manager.SetFindPackages(query => + { + attempts++; + return attempts == 1 + ? throw new InvalidOperationException("search failed") + : [CreatePackage(manager, "Contoso.Search", "Contoso Search")]; + }); + + var packages = manager.FindPackages("Contoso"); + + var package = Assert.Single(packages); + Assert.Equal("Contoso.Search", package.Id); + Assert.Equal("Contoso", manager.LastQuery); + Assert.Equal(1, manager.AttemptFastRepairCalls); + } + + [Fact] + public void GetAvailableUpdatesRetriesOnceAndRefreshesIndexesPerAttempt() + { + var manager = CreateReadyManager(); + var attempts = 0; + manager.SetAvailableUpdates(() => + { + attempts++; + return attempts == 1 + ? throw new InvalidOperationException("updates failed") + : [CreatePackage(manager, "Contoso.Update", "Contoso Update", "1.0.0", "2.0.0")]; + }); + + var packages = manager.GetAvailableUpdates(); + + var package = Assert.Single(packages); + Assert.Equal("Contoso.Update", package.Id); + Assert.Equal(1, manager.AttemptFastRepairCalls); + Assert.Equal(2, manager.RefreshPackageIndexesCalls); + } + + [Fact] + public void GetInstalledPackagesReturnsEmptyAfterSecondFailure() + { + var manager = CreateReadyManager(); + manager.SetInstalledPackages(() => throw new InvalidOperationException("installed failed")); + + var packages = manager.GetInstalledPackages(); + + Assert.Empty(packages); + Assert.Equal(1, manager.AttemptFastRepairCalls); + } + + private static TestPackageManager CreateManager() + { + return new PackageManagerBuilder() + .WithName($"TestManager-{Guid.NewGuid():N}") + .WithDisplayName("Test Manager") + .Build(initialize: false); + } + + private TestPackageManager CreateReadyManager() + { + var manager = CreateManager(); + manager.ExecutablePath = CreateExecutable($"{manager.Name}.exe"); + manager.Initialize(); + Assert.True(manager.IsReady()); + return manager; + } + + private static void EnableCustomManagerPaths() + { + Assert.Equal( + 0, + SecureSettings.ApplyForUser( + Environment.UserName, + SecureSettings.ResolveKey(SecureSettings.K.AllowCustomManagerPaths), + true + ) + ); + } + + private string CreateExecutable(string fileName) + { + var path = Path.Combine(_testRoot, "Executables", fileName); + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + File.WriteAllText(path, string.Empty); + return path; + } + + private static Package CreatePackage( + TestPackageManager manager, + string id, + string name, + string version = "1.0.0", + string? newVersion = null + ) + { + var builder = new PackageBuilder().WithManager(manager).WithId(id).WithName(name).WithVersion(version); + return newVersion is null ? builder.Build() : builder.WithNewVersion(newVersion).Build(); + } +} diff --git a/src/UniGetUI.PackageEngine.Tests/PackageOperationsTests.cs b/src/UniGetUI.PackageEngine.Tests/PackageOperationsTests.cs new file mode 100644 index 0000000000..a2ca9ec9c1 --- /dev/null +++ b/src/UniGetUI.PackageEngine.Tests/PackageOperationsTests.cs @@ -0,0 +1,356 @@ +using System.Diagnostics; +using System.Reflection; +using UniGetUI.Core.Tools; +using UniGetUI.Interface.Enums; +using UniGetUI.PackageEngine.Enums; +using UniGetUI.PackageEngine.Interfaces; +using UniGetUI.PackageEngine.Operations; +using UniGetUI.PackageEngine.PackageLoader; +using UniGetUI.PackageEngine.Serializable; +using UniGetUI.PackageEngine.Structs; +using UniGetUI.PackageEngine.Tests.Infrastructure.Builders; +using UniGetUI.PackageOperations; + +namespace UniGetUI.PackageEngine.Tests; + +[CollectionDefinition(nameof(OperationOrchestrationTestCollection), DisableParallelization = true)] +public sealed class OperationOrchestrationTestCollection; + +[Collection(nameof(OperationOrchestrationTestCollection))] +public sealed class PackageOperationsTests +{ + [Fact] + public void RetryModesMutateInstallOptionsAndMetadata() + { + var package = CreatePackage(); + var options = new InstallOptions(); + var operation = new InspectableInstallPackageOperation(package, options); + + operation.Retry(AbstractOperation.RetryMode.Retry_AsAdmin); + operation.Retry(AbstractOperation.RetryMode.Retry_Interactive); + operation.Retry(AbstractOperation.RetryMode.Retry_SkipIntegrity); + + Assert.True(options.RunAsAdministrator); + Assert.True(options.InteractiveInstallation); + Assert.True(options.SkipHashCheck); + Assert.Contains("Retried package operation", operation.Metadata.OperationInformation); + Assert.Contains(package.Id, operation.Metadata.OperationInformation); + Assert.Throws(() => operation.Retry("InvalidRetryMode")); + } + + [Fact] + public void InstallOperationBuildsPrerequisitesKillListAndPreCommand() + { + var package = CreatePackage(); + var options = new InstallOptions + { + PreInstallCommand = "echo before install", + AbortOnPreInstallFail = false, + }; + options.KillBeforeOperation.Add("proc-one"); + options.KillBeforeOperation.Add("proc-two"); + using var prerequisite = new StubOperation(); + using var operation = new InspectableInstallPackageOperation(package, options, req: prerequisite); + + var preOperations = GetInnerOperations(operation, "PreOperations"); + + Assert.Collection( + preOperations, + inner => + { + Assert.Same(prerequisite, inner.Operation); + Assert.True(inner.MustSucceed); + }, + inner => + { + Assert.IsType(inner.Operation); + Assert.False(inner.MustSucceed); + }, + inner => + { + Assert.IsType(inner.Operation); + Assert.False(inner.MustSucceed); + }, + inner => + { + var preCommand = Assert.IsType(inner.Operation); + Assert.False(inner.MustSucceed); + Assert.Contains("echo before install", preCommand.Metadata.Status); + } + ); + } + + [Fact] + public async Task UpdateOperationBuildsPostOperationsForCommandAndPreviousVersions() + { + var manager = CreateManager(); + var package = new PackageBuilder() + .WithManager(manager) + .WithId("Contoso.Tool") + .WithVersion("1.0.0") + .WithNewVersion("3.0.0") + .Build(); + var olderInstalledVersion = new PackageBuilder() + .WithManager(manager) + .WithId("Contoso.Tool") + .WithVersion("2.0.0") + .Build(); + var newerInstalledVersion = new PackageBuilder() + .WithManager(manager) + .WithId("Contoso.Tool") + .WithVersion("3.1.0") + .Build(); + InitializeLoaders(); + await InstalledPackagesLoader.Instance.AddForeign(olderInstalledVersion); + await InstalledPackagesLoader.Instance.AddForeign(newerInstalledVersion); + var options = new InstallOptions + { + PostUpdateCommand = "echo after update", + UninstallPreviousVersionsOnUpdate = true, + }; + + using var operation = new UpdatePackageOperation(package, options); + var postOperations = GetInnerOperations(operation, "PostOperations"); + + Assert.Collection( + postOperations, + inner => + { + var postCommand = Assert.IsType(inner.Operation); + Assert.False(inner.MustSucceed); + Assert.Contains("echo after update", postCommand.Metadata.Status); + }, + inner => + { + var uninstall = Assert.IsType(inner.Operation); + Assert.False(inner.MustSucceed); + Assert.Equal("2.0.0", uninstall.Package.VersionString); + } + ); + Assert.Contains("1.0.0 -> 3.0.0", operation.Metadata.OperationInformation); + } + + [Fact] + public void UninstallOperationBuildsPreAndPostCommandsForUninstallPath() + { + var package = CreatePackage(); + var options = new InstallOptions + { + PreUninstallCommand = "echo before uninstall", + AbortOnPreUninstallFail = false, + PostUninstallCommand = "echo after uninstall", + }; + using var operation = new UninstallPackageOperation(package, options); + + var preOperations = GetInnerOperations(operation, "PreOperations"); + var postOperations = GetInnerOperations(operation, "PostOperations"); + + var preOperation = Assert.Single(preOperations); + var preCommand = Assert.IsType(preOperation.Operation); + Assert.False(preOperation.MustSucceed); + Assert.Contains("echo before uninstall", preCommand.Metadata.Status); + + var postOperation = Assert.Single(postOperations); + var postCommand = Assert.IsType(postOperation.Operation); + Assert.False(postOperation.MustSucceed); + Assert.Contains("echo after uninstall", postCommand.Metadata.Status); + } + + [Fact] + public void InstallOperationPrepareProcessStartInfoUsesManagerCommandLineAndSetsBadges() + { + var manager = new PackageManagerBuilder() + .ConfigureManager(manager => + { + manager.ExecutablePath = "C:\\tools\\pkgmgr.exe"; + manager.ExecutableArguments = "--cli"; + }) + .ConfigureOperation(helper => + helper.ParametersFactory = (package, _, operation) => + [ + operation.ToString().ToLowerInvariant(), + package.Id, + ]) + .Build(); + var package = new PackageBuilder() + .WithManager(manager) + .WithOptions(new OverridenInstallationOptions(scope: PackageScope.Machine)) + .Build(); + var options = new InstallOptions + { + InstallationScope = PackageScope.User, + InteractiveInstallation = true, + SkipHashCheck = true, + }; + using var operation = new InspectableInstallPackageOperation(package, options); + AbstractOperation.BadgeCollection? badges = null; + operation.BadgesChanged += (_, updatedBadges) => badges = updatedBadges; + + var startInfo = operation.PrepareProcessStartInfoForTests(); + + Assert.Equal("C:\\tools\\pkgmgr.exe", startInfo.FileName); + Assert.Equal("--cli install Contoso.Test", startInfo.Arguments.Trim()); + Assert.Equal(PackageTag.OnQueue, package.Tag); + Assert.NotNull(badges); + Assert.Equal(CoreTools.IsAdministrator(), badges!.AsAdministrator); + Assert.True(badges.Interactive); + Assert.True(badges.SkipHashCheck); + Assert.Equal(PackageScope.Machine, badges.Scope); + } + + [Fact] + public async Task InstallOperationSuccessfulRunSetsPackageTagAndAddsInstalledCopy() + { + var package = CreatePackage(); + InitializeLoaders(); + using var operation = new SimulatedInstallPackageOperation( + package, + new InstallOptions(), + OperationVeredict.Success + ); + + await operation.MainThread(); + await WaitForAsync(() => InstalledPackagesLoader.Instance.GetEquivalentPackage(package) is not null); + + Assert.Equal(PackageTag.AlreadyInstalled, package.Tag); + Assert.NotNull(InstalledPackagesLoader.Instance.GetEquivalentPackage(package)); + } + + private static IReadOnlyList GetInnerOperations( + AbstractOperation operation, + string fieldName + ) + { + var field = typeof(AbstractOperation).GetField( + fieldName, + BindingFlags.Instance | BindingFlags.NonPublic + ); + + return Assert.IsAssignableFrom>( + field?.GetValue(operation) + ); + } + + private static IPackage CreatePackage() + { + var manager = CreateManager(); + return new PackageBuilder().WithManager(manager).Build(); + } + + private static IPackageManager CreateManager() + { + return new PackageManagerBuilder() + .ConfigureManager(manager => + { + manager.ExecutablePath = "C:\\test-tools\\manager.exe"; + manager.ExecutableArguments = "--test"; + }) + .ConfigureOperation(helper => + helper.ParametersFactory = (package, _, operation) => + [ + operation.ToString().ToLowerInvariant(), + package.Id, + ]) + .Build(); + } + + private static void InitializeLoaders() + { + _ = new DiscoverablePackagesLoader([]); + _ = new UpgradablePackagesLoader([]); + _ = new InstalledPackagesLoader([]); + } + + private static async Task WaitForAsync(Func condition) + { + for (var attempt = 0; attempt < 20; attempt++) + { + if (condition()) + return; + + await Task.Delay(25); + } + } + + private class InspectableInstallPackageOperation : InstallPackageOperation + { + public InspectableInstallPackageOperation( + IPackage package, + InstallOptions options, + bool ignoreParallelInstalls = true, + AbstractOperation? req = null + ) + : base(package, options, ignoreParallelInstalls, req) { } + + public ProcessStartInfo PrepareProcessStartInfoForTests() + { + InitializeProcessStartInfoDefaults(); + PrepareProcessStartInfo(); + return process.StartInfo; + } + + private void InitializeProcessStartInfoDefaults() + { + process.StartInfo.UseShellExecute = false; + process.StartInfo.RedirectStandardOutput = true; + process.StartInfo.RedirectStandardInput = true; + process.StartInfo.RedirectStandardError = true; + process.StartInfo.CreateNoWindow = true; + process.StartInfo.StandardOutputEncoding = System.Text.Encoding.UTF8; + process.StartInfo.StandardErrorEncoding = System.Text.Encoding.UTF8; + process.StartInfo.StandardInputEncoding = System.Text.Encoding.UTF8; + process.StartInfo.WorkingDirectory = Environment.GetFolderPath( + Environment.SpecialFolder.UserProfile + ); + process.StartInfo.FileName = "lol"; + process.StartInfo.Arguments = "lol"; + } + } + + private sealed class SimulatedInstallPackageOperation : InspectableInstallPackageOperation + { + private readonly OperationVeredict _veredict; + + public SimulatedInstallPackageOperation( + IPackage package, + InstallOptions options, + OperationVeredict veredict + ) + : base(package, options) + { + _veredict = veredict; + } + + protected override Task PerformOperation() + { + return Task.FromResult(_veredict); + } + } + + private sealed class StubOperation : AbstractOperation + { + public StubOperation() + : base(queue_enabled: false) + { + Metadata.Status = "Stub status"; + Metadata.Title = "Stub title"; + Metadata.OperationInformation = "Stub info"; + Metadata.SuccessTitle = "Stub success"; + Metadata.SuccessMessage = "Stub success"; + Metadata.FailureTitle = "Stub failure"; + Metadata.FailureMessage = "Stub failure"; + } + + protected override void ApplyRetryAction(string retryMode) { } + + protected override Task PerformOperation() + { + return Task.FromResult(OperationVeredict.Success); + } + + public override Task GetOperationIcon() + { + return Task.FromResult(new Uri("about:blank")); + } + } +} diff --git a/src/UniGetUI.PackageEngine.Tests/PipManagerTests.cs b/src/UniGetUI.PackageEngine.Tests/PipManagerTests.cs new file mode 100644 index 0000000000..67aa1d1398 --- /dev/null +++ b/src/UniGetUI.PackageEngine.Tests/PipManagerTests.cs @@ -0,0 +1,222 @@ +using UniGetUI.Core.Data; +using UniGetUI.Core.SettingsEngine; +using UniGetUI.PackageEngine.Enums; +using UniGetUI.PackageEngine.Managers.PipManager; +using UniGetUI.PackageEngine.Serializable; +using UniGetUI.PackageEngine.Structs; +using UniGetUI.PackageEngine.Tests.Infrastructure.Assertions; +using UniGetUI.PackageEngine.Tests.Infrastructure.Builders; +using UniGetUI.PackageEngine.Tests.Infrastructure.Helpers; + +namespace UniGetUI.PackageEngine.Tests; + +[CollectionDefinition("Pip manager tests", DisableParallelization = true)] +public sealed class PipManagerTestCollection +{ + public const string Name = "Pip manager tests"; +} + +[Collection(PipManagerTestCollection.Name)] +public sealed class PipManagerTests : IDisposable +{ + private readonly string _testRoot = Path.Combine( + AppContext.BaseDirectory, + nameof(PipManagerTests), + Guid.NewGuid().ToString("N") + ); + + public PipManagerTests() + { + Directory.CreateDirectory(_testRoot); + CoreData.TEST_DataDirectoryOverride = Path.Combine(_testRoot, "Data"); + Directory.CreateDirectory(CoreData.UniGetUIUserConfigurationDirectory); + Settings.ResetSettings(); + Settings.Set(Settings.K.EnableProxy, false); + Settings.Set(Settings.K.EnableProxyAuth, false); + Settings.SetValue(Settings.K.ProxyURL, ""); + } + + public void Dispose() + { + Settings.ResetSettings(); + CoreData.TEST_DataDirectoryOverride = null; + if (Directory.Exists(_testRoot)) + { + Directory.Delete(_testRoot, recursive: true); + } + } + + [Fact] + public void SearchHelpersParseSimpleIndexAndRankPrefixMatchesFirst() + { + var names = Pip.ParseSimpleIndexProjectNames( + PackageEngineFixtureFiles.ReadAllText(Path.Combine("Pip", "simple-index.json")) + ); + + var matches = Pip.SelectSearchMatches("req", names); + + Assert.Equal( + ["req", "requests", "requestium", "requests-cache", "django-reqtools"], + matches + ); + } + + [Fact] + public void ParseAvailableUpdatesBuildsPackagesFromFixture() + { + var manager = new Pip(); + + var packages = Pip.ParseAvailableUpdates( + File.ReadLines(PackageEngineFixtureFiles.GetPath(Path.Combine("Pip", "outdated-list.txt"))), + manager.DefaultSource, + manager + ); + + Assert.Collection( + packages, + package => + { + PackageAssert.Matches(package, "Requests", "requests", "2.31.0", "2.32.3"); + PackageAssert.BelongsTo(package, manager, manager.DefaultSource); + }, + package => + { + PackageAssert.Matches( + package, + "Django Reqtools", + "django-reqtools", + "1.0.0", + "1.1.0" + ); + PackageAssert.BelongsTo(package, manager, manager.DefaultSource); + } + ); + } + + [Fact] + public void ParseInstalledPackagesBuildsPackagesFromFixture() + { + var manager = new Pip(); + + var packages = Pip.ParseInstalledPackages( + File.ReadLines(PackageEngineFixtureFiles.GetPath(Path.Combine("Pip", "installed-list.txt"))), + manager.DefaultSource, + manager + ); + + Assert.Collection( + packages, + package => + { + PackageAssert.Matches(package, "Requests", "requests", "2.31.0"); + PackageAssert.BelongsTo(package, manager, manager.DefaultSource); + }, + package => + { + PackageAssert.Matches(package, "Django Reqtools", "django-reqtools", "1.0.0"); + PackageAssert.BelongsTo(package, manager, manager.DefaultSource); + } + ); + } + + [Fact] + public void OperationHelperBuildsInstallAndUninstallParameters() + { + Settings.Set(Settings.K.EnableProxy, true); + Settings.Set(Settings.K.EnableProxyAuth, false); + Settings.SetValue(Settings.K.ProxyURL, "http://proxy.example.test:3128/"); + var manager = new Pip(); + var overridenOptions = new OverridenInstallationOptions(); + overridenOptions.Pip_BreakSystemPackages = true; + var package = new PackageBuilder() + .WithManager(manager) + .WithId("requests") + .WithOptions(overridenOptions) + .Build(); + var installOptions = new InstallOptions + { + Version = "2.31.0", + InstallationScope = PackageScope.User, + PreRelease = true, + CustomParameters_Install = ["--quiet"], + }; + var uninstallOptions = new InstallOptions + { + CustomParameters_Uninstall = ["--verbose"], + }; + + var installParameters = manager.OperationHelper.GetParameters( + package, + installOptions, + OperationType.Install + ); + var uninstallParameters = manager.OperationHelper.GetParameters( + package, + uninstallOptions, + OperationType.Uninstall + ); + + Assert.Equal( + [ + "install", + "requests==2.31.0", + "--no-input", + "--no-color", + "--no-cache", + "--pre", + "--user", + "--break-system-packages", + "--proxy http://proxy.example.test:3128/", + "--quiet", + ], + installParameters + ); + Assert.Equal( + [ + "uninstall", + "requests", + "--no-input", + "--no-color", + "--no-cache", + "--yes", + "--break-system-packages", + "--proxy http://proxy.example.test:3128/", + "--verbose", + ], + uninstallParameters + ); + } + + [Fact] + public void OperationHelperUsesAutoRetryMutationsForKnownPipFailures() + { + var manager = new Pip(); + var breakSystemPackage = new PackageBuilder().WithManager(manager).WithId("requests").Build(); + var userScopedPackage = new PackageBuilder().WithManager(manager).WithId("requests").Build(); + + var externallyManagedResult = manager.OperationHelper.GetResult( + breakSystemPackage, + OperationType.Install, + ["error: externally-managed-environment"], + 1 + ); + var userScopeResult = manager.OperationHelper.GetResult( + userScopedPackage, + OperationType.Install, + ["hint: try again with --user"], + 1 + ); + var failureResult = manager.OperationHelper.GetResult( + new PackageBuilder().WithManager(manager).WithId("requests").Build(), + OperationType.Install, + ["boom"], + 1 + ); + + Assert.Equal(OperationVeredict.AutoRetry, externallyManagedResult); + Assert.True(breakSystemPackage.OverridenOptions.Pip_BreakSystemPackages); + Assert.Equal(OperationVeredict.AutoRetry, userScopeResult); + Assert.Equal(PackageScope.User, userScopedPackage.OverridenOptions.Scope); + Assert.Equal(OperationVeredict.Failure, failureResult); + } +} diff --git a/src/UniGetUI.PackageEngine.Tests/PowerShellManagerTests.cs b/src/UniGetUI.PackageEngine.Tests/PowerShellManagerTests.cs new file mode 100644 index 0000000000..351e014300 --- /dev/null +++ b/src/UniGetUI.PackageEngine.Tests/PowerShellManagerTests.cs @@ -0,0 +1,59 @@ +#if WINDOWS +using UniGetUI.PackageEngine.Managers.PowerShellManager; + +namespace UniGetUI.PackageEngine.Tests; + +public sealed class PowerShellManagerTests +{ + [Fact] + public void ParseInstalledPackages_BuildsPackagesFromModuleTable() + { + var manager = new PowerShell(); + var packages = PowerShell.ParseInstalledPackages( + [ + "Version Name Repository Description", + "------- ---- ---------- -----------", + "5.5.0 Pester PSGallery Test framework", + "2.2.5 PSReadLine PSGallery Command line editing", + ], + manager + ); + + Assert.Collection( + packages, + package => + { + Assert.Equal("Pester", package.Id); + Assert.Equal("5.5.0", package.VersionString); + Assert.Equal("PSGallery", package.Source.Name); + }, + package => + { + Assert.Equal("PSReadLine", package.Id); + Assert.Equal("2.2.5", package.VersionString); + Assert.Equal("PSGallery", package.Source.Name); + } + ); + } + + [Fact] + public void ParseInstalledPackages_SkipsMalformedLines() + { + var manager = new PowerShell(); + + var package = Assert.Single( + PowerShell.ParseInstalledPackages( + [ + "Version Name Repository Description", + "------- ---- ---------- -----------", + "not-enough-columns", + "5.5.0 Pester PSGallery Test framework", + ], + manager + ) + ); + + Assert.Equal("Pester", package.Id); + } +} +#endif diff --git a/src/UniGetUI.PackageEngine.Tests/ScoopManagerTests.cs b/src/UniGetUI.PackageEngine.Tests/ScoopManagerTests.cs new file mode 100644 index 0000000000..309693608b --- /dev/null +++ b/src/UniGetUI.PackageEngine.Tests/ScoopManagerTests.cs @@ -0,0 +1,328 @@ +#if WINDOWS +using UniGetUI.Core.Data; +using UniGetUI.Core.SettingsEngine; +using UniGetUI.PackageEngine.Enums; +using UniGetUI.PackageEngine.Interfaces; +using UniGetUI.PackageEngine.Managers.ScoopManager; +using UniGetUI.PackageEngine.Serializable; +using UniGetUI.PackageEngine.Structs; +using UniGetUI.PackageEngine.Tests.Infrastructure.Assertions; +using UniGetUI.PackageEngine.Tests.Infrastructure.Builders; +using UniGetUI.PackageEngine.Tests.Infrastructure.Helpers; +using Architecture = UniGetUI.PackageEngine.Enums.Architecture; + +namespace UniGetUI.PackageEngine.Tests; + +[CollectionDefinition("Scoop manager tests", DisableParallelization = true)] +public sealed class ScoopManagerTestCollection +{ + public const string Name = "Scoop manager tests"; +} + +[Collection(ScoopManagerTestCollection.Name)] +public sealed class ScoopManagerTests : IDisposable +{ + private readonly string _testRoot = Path.Combine( + AppContext.BaseDirectory, + nameof(ScoopManagerTests), + Guid.NewGuid().ToString("N") + ); + + public ScoopManagerTests() + { + Directory.CreateDirectory(_testRoot); + CoreData.TEST_DataDirectoryOverride = Path.Combine(_testRoot, "Data"); + Directory.CreateDirectory(CoreData.UniGetUIUserConfigurationDirectory); + Settings.ResetSettings(); + } + + public void Dispose() + { + Settings.ResetSettings(); + CoreData.TEST_DataDirectoryOverride = null; + if (Directory.Exists(_testRoot)) + { + Directory.Delete(_testRoot, recursive: true); + } + } + + [Fact] + public void ParseSearchOutputBuildsPackagesFromBucketSections() + { + var manager = CreateManagerWithKnownSources("main", "versions"); + + var packages = manager.ParseSearchOutput(ReadFixtureLines(@"Scoop\search-output.txt")); + + Assert.Collection( + packages, + package => + { + PackageAssert.Matches(package, "7zip", "7zip", "24.09"); + PackageAssert.BelongsTo(package, manager, manager.SourcesHelper.Factory.GetSourceOrDefault("main")); + }, + package => + { + PackageAssert.Matches(package, "Python310", "python310", "3.10.11"); + PackageAssert.BelongsTo( + package, + manager, + manager.SourcesHelper.Factory.GetSourceOrDefault("versions") + ); + } + ); + } + + [Fact] + public void ParseInstalledPackagesBuildsPackagesAndScopesFromFixture() + { + var manager = CreateManagerWithKnownSources("main", "versions"); + + var packages = manager.ParseInstalledPackages(ReadFixtureLines(@"Scoop\list-output.txt")); + + Assert.Collection( + packages, + package => + { + PackageAssert.Matches(package, "Git", "git", "2.47.1"); + PackageAssert.BelongsTo(package, manager, manager.SourcesHelper.Factory.GetSourceOrDefault("main")); + Assert.Equal(PackageScope.User, package.OverridenOptions.Scope); + }, + package => + { + PackageAssert.Matches(package, "Pwsh", "pwsh", "7.4.6"); + PackageAssert.BelongsTo( + package, + manager, + manager.SourcesHelper.Factory.GetSourceOrDefault("versions") + ); + Assert.Equal(PackageScope.Global, package.OverridenOptions.Scope); + } + ); + } + + [Fact] + public void ParseAvailableUpdatesPreservesInstalledSourceAndScope() + { + var manager = CreateManagerWithKnownSources("main", "versions"); + var installedPackages = manager.ParseInstalledPackages(ReadFixtureLines(@"Scoop\list-output.txt")); + + var packages = manager.ParseAvailableUpdates( + ReadFixtureLines(@"Scoop\status-output.txt"), + installedPackages + ); + + Assert.Collection( + packages, + package => + { + PackageAssert.Matches(package, "Git", "git", "2.47.1", "2.48.1"); + PackageAssert.BelongsTo(package, manager, manager.SourcesHelper.Factory.GetSourceOrDefault("main")); + Assert.Equal(PackageScope.User, package.OverridenOptions.Scope); + }, + package => + { + PackageAssert.Matches(package, "Pwsh", "pwsh", "7.4.6", "7.5.0"); + PackageAssert.BelongsTo( + package, + manager, + manager.SourcesHelper.Factory.GetSourceOrDefault("versions") + ); + Assert.Equal(PackageScope.Global, package.OverridenOptions.Scope); + } + ); + } + + [Fact] + public void ParseSourcesNormalizesGitUrlsAndLocalBuckets() + { + var manager = new Scoop(); + var helper = Assert.IsType(manager.SourcesHelper); + + var sources = helper.ParseSources(ReadFixtureLines(@"Scoop\bucket-list-output.txt")); + + Assert.Collection( + sources, + source => + { + Assert.Equal("main", source.Name); + Assert.Equal(new Uri("https://github.com/ScoopInstaller/Main"), source.Url); + Assert.Equal(1234, source.PackageCount); + Assert.Equal("2024-02-01 12:34:56", source.UpdateDate); + }, + source => + { + Assert.Equal("extras", source.Name); + Assert.Equal( + new Uri( + Path.Join( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + "scoop", + "buckets", + "extras" + ) + ), + source.Url + ); + Assert.Equal(321, source.PackageCount); + Assert.Equal("2024-02-02 09:08:07", source.UpdateDate); + } + ); + } + + [Fact] + public void SourceHelperBuildsBucketCommandsAndMapsExitCodes() + { + var manager = new Scoop(); + var source = new SourceBuilder() + .WithManager(manager) + .WithName("extras") + .WithUrl("https://github.com/ScoopInstaller/Extras") + .Build(); + + Assert.Equal( + ["bucket", "add", "extras", "https://github.com/ScoopInstaller/Extras"], + manager.SourcesHelper.GetAddSourceParameters(source) + ); + Assert.Equal(["bucket", "rm", "extras"], manager.SourcesHelper.GetRemoveSourceParameters(source)); + Assert.Equal( + OperationVeredict.Success, + manager.SourcesHelper.GetAddOperationVeredict(source, 0, []) + ); + Assert.Equal( + OperationVeredict.Failure, + manager.SourcesHelper.GetRemoveOperationVeredict(source, 1, []) + ); + } + + [Fact] + public void InstallParametersIncludeSourceScopeArchitectureAndHashFlags() + { + var manager = new Scoop(); + var source = new SourceBuilder() + .WithManager(manager) + .WithName("extras") + .WithUrl("https://github.com/ScoopInstaller/Extras") + .Build(); + manager.SourcesHelper.Factory.AddSource(source); + var package = new PackageBuilder().WithManager(manager).WithSource(source).WithId("git").Build(); + var options = new InstallOptions + { + InstallationScope = PackageScope.Global, + Architecture = Architecture.x64, + SkipHashCheck = true, + CustomParameters_Install = ["--no-cache"], + }; + + var parameters = manager.OperationHelper.GetParameters(package, options, OperationType.Install); + + OperationAssert.HasParameters( + parameters, + "install", + "extras/git", + "--global", + "--no-cache", + "--skip-hash-check", + "--arch", + "64bit" + ); + Assert.True(package.OverridenOptions.RunAsAdministrator); + } + + [Fact] + public void UninstallParametersOmitLocalSourcePrefixAndAppendPurge() + { + var manager = new Scoop(); + var source = new SourceBuilder() + .WithManager(manager) + .WithName(@"C:\Buckets\custom") + .WithUrl("https://example.test/custom") + .Build(); + var package = new PackageBuilder() + .WithManager(manager) + .WithSource(source) + .WithId("custom-tool") + .Build(); + var options = new InstallOptions + { + RemoveDataOnUninstall = true, + CustomParameters_Uninstall = ["--verbose"], + }; + + var parameters = manager.OperationHelper.GetParameters(package, options, OperationType.Uninstall); + + OperationAssert.HasParameters( + parameters, + "uninstall", + "custom-tool", + "--verbose", + "--purge" + ); + } + + [Fact] + public void OperationResultPromotesGlobalRetryWhenScoopRequestsGlobalFlag() + { + var manager = new Scoop(); + var package = new PackageBuilder().WithManager(manager).Build(); + + var veredict = manager.OperationHelper.GetResult( + package, + OperationType.Install, + ["Try again with the --global (or -g) flag instead"], + 1 + ); + + OperationAssert.HasVeredict(veredict, OperationVeredict.AutoRetry); + Assert.Equal(PackageScope.Global, package.OverridenOptions.Scope); + Assert.True(package.OverridenOptions.RunAsAdministrator); + } + + [Fact] + public void OperationResultPromotesElevationRetryBeforeReturningFailure() + { + var manager = new Scoop(); + var package = new PackageBuilder() + .WithManager(manager) + .WithOptions(new OverridenInstallationOptions(runAsAdministrator: false)) + .Build(); + + var retry = manager.OperationHelper.GetResult( + package, + OperationType.Install, + ["package requires administrator rights"], + 1 + ); + var failure = manager.OperationHelper.GetResult(package, OperationType.Install, ["ERROR: failed"], 1); + var success = manager.OperationHelper.GetResult(package, OperationType.Install, ["done"], 0); + + OperationAssert.HasVeredict(retry, OperationVeredict.AutoRetry); + Assert.True(package.OverridenOptions.RunAsAdministrator); + OperationAssert.HasVeredict(failure, OperationVeredict.Failure); + OperationAssert.HasVeredict(success, OperationVeredict.Success); + } + + private static Scoop CreateManagerWithKnownSources(params string[] sourceNames) + { + var manager = new Scoop(); + manager.SourcesHelper.Factory.AddSource(manager.DefaultSource); + foreach (string sourceName in sourceNames) + { + IManagerSource source = + manager.Properties.KnownSources.FirstOrDefault(source => source.Name == sourceName) + ?? new SourceBuilder() + .WithManager(manager) + .WithName(sourceName) + .WithUrl($"https://example.test/{sourceName}") + .Build(); + manager.SourcesHelper.Factory.AddSource(source); + } + + return manager; + } + + private static string[] ReadFixtureLines(string relativePath) + { + return PackageEngineFixtureFiles.ReadAllText(relativePath).Replace("\r\n", "\n").Split('\n'); + } +} +#endif diff --git a/src/UniGetUI.PackageEngine.Tests/SourceOperationsTests.cs b/src/UniGetUI.PackageEngine.Tests/SourceOperationsTests.cs new file mode 100644 index 0000000000..938d95b41a --- /dev/null +++ b/src/UniGetUI.PackageEngine.Tests/SourceOperationsTests.cs @@ -0,0 +1,180 @@ +using System.Diagnostics; +using UniGetUI.Core.Data; +using UniGetUI.Core.SettingsEngine; +using UniGetUI.PackageEngine.Enums; +using UniGetUI.PackageEngine.Interfaces; +using UniGetUI.PackageEngine.Operations; +using UniGetUI.PackageEngine.Tests.Infrastructure.Builders; +using UniGetUI.PackageOperations; + +namespace UniGetUI.PackageEngine.Tests; + +[Collection(nameof(OperationOrchestrationTestCollection))] +public sealed class SourceOperationsTests +{ + [Fact] + public void RetryAsAdminSetsForceAsAdministrator() + { + var source = CreateSource(); + using var operation = new InspectableAddSourceOperation(source); + + operation.Retry(AbstractOperation.RetryMode.Retry_AsAdmin); + + Assert.True(operation.ForceAsAdministrator); + Assert.Throws( + () => operation.Retry(AbstractOperation.RetryMode.Retry_Interactive) + ); + } + + [Fact] + public void CreateInstallAndUninstallPreOpsSkipNonWingetManagers() + { + var source = CreateSource(); + + Assert.Empty(SourceOperation.CreateInstallPreOps(source, forceLocalWinGet: false)); + Assert.Empty(SourceOperation.CreateInstallPreOps(source, forceLocalWinGet: true)); + Assert.Empty(SourceOperation.CreateUninstallPreOps(source, forceLocalWinGet: false)); + Assert.Empty(SourceOperation.CreateUninstallPreOps(source, forceLocalWinGet: true)); + } + + [Fact] + public void AddSourcePrepareProcessStartInfoUsesManagerExecutableWithoutAdmin() + { + var manager = new PackageManagerBuilder() + .ConfigureManager(manager => + { + manager.ExecutablePath = "C:\\tools\\sources.exe"; + manager.ExecutableArguments = "--cli"; + }) + .ConfigureSources(helper => helper.AddParametersFactory = source => ["source", "add", source.Name]) + .Build(); + var source = new SourceBuilder().WithManager(manager).WithName("community").Build(); + using var operation = new InspectableAddSourceOperation(source); + AbstractOperation.BadgeCollection? badges = null; + operation.BadgesChanged += (_, updatedBadges) => badges = updatedBadges; + + var startInfo = operation.PrepareProcessStartInfoForTests(); + + Assert.Equal("C:\\tools\\sources.exe", startInfo.FileName); + Assert.Equal("--cli source add community", startInfo.Arguments.Trim()); + Assert.NotNull(badges); + Assert.False(badges!.AsAdministrator); + } + + [Fact] + public void RemoveSourcePrepareProcessStartInfoUsesElevatorWhenAdminRequired() + { + using var prohibitElevation = new BooleanSettingScope(Settings.K.ProhibitElevation, false); + var manager = new PackageManagerBuilder() + .ConfigureCapabilities(capabilities => + { + var sourceCapabilities = capabilities.Sources; + sourceCapabilities.MustBeInstalledAsAdmin = true; + capabilities.Sources = sourceCapabilities; + return capabilities; + }) + .ConfigureManager(manager => + { + manager.ExecutablePath = "C:\\tools\\sources.exe"; + manager.ExecutableArguments = "--cli"; + }) + .ConfigureSources(helper => + helper.RemoveParametersFactory = source => ["source", "remove", source.Name] + ) + .Build(); + var source = new SourceBuilder().WithManager(manager).WithName("community").Build(); + using var operation = new InspectableRemoveSourceOperation(source); + AbstractOperation.BadgeCollection? badges = null; + operation.BadgesChanged += (_, updatedBadges) => badges = updatedBadges; + + var startInfo = operation.PrepareProcessStartInfoForTests(); + + Assert.Equal(CoreData.ElevatorPath, startInfo.FileName); + Assert.Equal("\"C:\\tools\\sources.exe\" --cli source remove community", startInfo.Arguments); + Assert.NotNull(badges); + Assert.True(badges!.AsAdministrator); + } + + private static IManagerSource CreateSource() + { + return new SourceBuilder().WithManager(new PackageManagerBuilder().Build()).Build(); + } + + private sealed class InspectableAddSourceOperation : AddSourceOperation + { + public InspectableAddSourceOperation(IManagerSource source, bool forceLocalWinGet = false) + : base(source, forceLocalWinGet) { } + + public ProcessStartInfo PrepareProcessStartInfoForTests() + { + InitializeProcessStartInfoDefaults(); + PrepareProcessStartInfo(); + return process.StartInfo; + } + + private void InitializeProcessStartInfoDefaults() + { + process.StartInfo.UseShellExecute = false; + process.StartInfo.RedirectStandardOutput = true; + process.StartInfo.RedirectStandardInput = true; + process.StartInfo.RedirectStandardError = true; + process.StartInfo.CreateNoWindow = true; + process.StartInfo.StandardOutputEncoding = System.Text.Encoding.UTF8; + process.StartInfo.StandardErrorEncoding = System.Text.Encoding.UTF8; + process.StartInfo.StandardInputEncoding = System.Text.Encoding.UTF8; + process.StartInfo.WorkingDirectory = Environment.GetFolderPath( + Environment.SpecialFolder.UserProfile + ); + process.StartInfo.FileName = "lol"; + process.StartInfo.Arguments = "lol"; + } + } + + private sealed class InspectableRemoveSourceOperation : RemoveSourceOperation + { + public InspectableRemoveSourceOperation(IManagerSource source, bool forceLocalWinGet = false) + : base(source, forceLocalWinGet) { } + + public ProcessStartInfo PrepareProcessStartInfoForTests() + { + InitializeProcessStartInfoDefaults(); + PrepareProcessStartInfo(); + return process.StartInfo; + } + + private void InitializeProcessStartInfoDefaults() + { + process.StartInfo.UseShellExecute = false; + process.StartInfo.RedirectStandardOutput = true; + process.StartInfo.RedirectStandardInput = true; + process.StartInfo.RedirectStandardError = true; + process.StartInfo.CreateNoWindow = true; + process.StartInfo.StandardOutputEncoding = System.Text.Encoding.UTF8; + process.StartInfo.StandardErrorEncoding = System.Text.Encoding.UTF8; + process.StartInfo.StandardInputEncoding = System.Text.Encoding.UTF8; + process.StartInfo.WorkingDirectory = Environment.GetFolderPath( + Environment.SpecialFolder.UserProfile + ); + process.StartInfo.FileName = "lol"; + process.StartInfo.Arguments = "lol"; + } + } + + private sealed class BooleanSettingScope : IDisposable + { + private readonly Settings.K _key; + private readonly bool _originalValue; + + public BooleanSettingScope(Settings.K key, bool value) + { + _key = key; + _originalValue = Settings.Get(key); + Settings.Set(key, value); + } + + public void Dispose() + { + Settings.Set(_key, _originalValue); + } + } +} diff --git a/src/UniGetUI.PackageEngine.Tests/TestAssembly.cs b/src/UniGetUI.PackageEngine.Tests/TestAssembly.cs new file mode 100644 index 0000000000..217120083b --- /dev/null +++ b/src/UniGetUI.PackageEngine.Tests/TestAssembly.cs @@ -0,0 +1,3 @@ +using Xunit; + +[assembly: CollectionBehavior(DisableTestParallelization = true)] diff --git a/src/UniGetUI.PackageEngine.Tests/UniGetUI.PackageEngine.Tests.csproj b/src/UniGetUI.PackageEngine.Tests/UniGetUI.PackageEngine.Tests.csproj new file mode 100644 index 0000000000..e2e837201a --- /dev/null +++ b/src/UniGetUI.PackageEngine.Tests/UniGetUI.PackageEngine.Tests.csproj @@ -0,0 +1,55 @@ + + + + $(SharedTargetFrameworks) + false + true + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/UniGetUI.PackageEngine.Tests/UpgradablePackagesLoaderTests.cs b/src/UniGetUI.PackageEngine.Tests/UpgradablePackagesLoaderTests.cs new file mode 100644 index 0000000000..f1aa6816e0 --- /dev/null +++ b/src/UniGetUI.PackageEngine.Tests/UpgradablePackagesLoaderTests.cs @@ -0,0 +1,144 @@ +using UniGetUI.Core.Data; +using UniGetUI.Core.SettingsEngine; +using UniGetUI.Core.SettingsEngine.SecureSettings; +using UniGetUI.Interface.Enums; +using UniGetUI.PackageEngine.PackageClasses; +using UniGetUI.PackageEngine.PackageLoader; +using UniGetUI.PackageEngine.Serializable; +using UniGetUI.PackageEngine.Structs; +using UniGetUI.PackageEngine.Tests.Infrastructure.Builders; +using UniGetUI.PackageEngine.Tests.Infrastructure.Fakes; + +namespace UniGetUI.PackageEngine.Tests; + +public sealed class UpgradablePackagesLoaderTests : IDisposable +{ + private readonly string _testRoot = Path.Combine( + Path.GetTempPath(), + nameof(UpgradablePackagesLoaderTests), + Guid.NewGuid().ToString("N") + ); + + public UpgradablePackagesLoaderTests() + { + Directory.CreateDirectory(_testRoot); + CoreData.TEST_DataDirectoryOverride = Path.Combine(_testRoot, "Data"); + SecureSettings.TEST_SecureSettingsRootOverride = Path.Combine(_testRoot, "SecureSettings"); + Directory.CreateDirectory(CoreData.UniGetUIUserConfigurationDirectory); + Directory.CreateDirectory(CoreData.UniGetUIInstallationOptionsDirectory); + Settings.ResetSettings(); + } + + public void Dispose() + { + Settings.ResetSettings(); + CoreData.TEST_DataDirectoryOverride = null; + SecureSettings.TEST_SecureSettingsRootOverride = null; + if (Directory.Exists(_testRoot)) + { + Directory.Delete(_testRoot, recursive: true); + } + } + + [Fact] + public async Task EvaluatePackageAsync_SkipsMinorUpdatesWhenConfigured() + { + var manager = new PackageManagerBuilder().Build(); + _ = new InstalledPackagesLoader([manager]); + _ = new DiscoverablePackagesLoader([manager]); + var loader = new TestUpgradablePackagesLoader([manager]); + var package = new PackageBuilder() + .WithManager(manager) + .WithId("Contoso.Tool") + .WithVersion("1.0.0") + .WithNewVersion("1.0.1") + .Build(); + + InstallOptionsFactory.SaveForPackage( + new InstallOptions + { + OverridesNextLevelOpts = true, + SkipMinorUpdates = true, + }, + package + ); + + Assert.False(await loader.EvaluatePackageAsync(package)); + } + + [Fact] + public async Task EvaluatePackageAsync_SkipsIgnoredAndSupersededPackages() + { + var manager = new PackageManagerBuilder().Build(); + var installedLoader = new InstalledPackagesLoader([manager]); + _ = new DiscoverablePackagesLoader([manager]); + var loader = new TestUpgradablePackagesLoader([manager]); + var ignoredPackage = new PackageBuilder() + .WithManager(manager) + .WithId("Contoso.Ignored") + .WithVersion("1.0.0") + .WithNewVersion("2.0.0") + .Build(); + var supersededPackage = new PackageBuilder() + .WithManager(manager) + .WithId("Contoso.Superseded") + .WithVersion("1.0.0") + .WithNewVersion("2.0.0") + .Build(); + + await ignoredPackage.AddToIgnoredUpdatesAsync("2.0.0"); + await installedLoader.AddForeign( + new PackageBuilder() + .WithManager(manager) + .WithId("Contoso.Superseded") + .WithVersion("3.0.0") + .Build() + ); + + Assert.False(await loader.EvaluatePackageAsync(ignoredPackage)); + Assert.Contains("Contoso.Ignored", loader.IgnoredPackages.Keys); + Assert.False(await loader.EvaluatePackageAsync(supersededPackage)); + } + + [Fact] + public async Task ApplyWhenAddingPackageAsync_UpdatesDiscoverableAndInstalledTags() + { + var manager = new PackageManagerBuilder().Build(); + var installedLoader = new InstalledPackagesLoader([manager]); + var discoverableLoader = new DiscoverablePackagesLoader([manager]); + var loader = new TestUpgradablePackagesLoader([manager]); + var availablePackage = new PackageBuilder().WithManager(manager).WithId("Contoso.Tagged").Build(); + var installedPackage = new PackageBuilder().WithManager(manager).WithId("Contoso.Tagged").Build(); + var upgradablePackage = new PackageBuilder() + .WithManager(manager) + .WithId("Contoso.Tagged") + .WithVersion("1.0.0") + .WithNewVersion("2.0.0") + .Build(); + + await discoverableLoader.AddForeign(availablePackage); + await installedLoader.AddForeign(installedPackage); + + await loader.ApplyWhenAddingPackageAsync(upgradablePackage); + + Assert.Equal(PackageTag.IsUpgradable, availablePackage.Tag); + Assert.Equal(PackageTag.IsUpgradable, installedPackage.Tag); + } + + [Theory] + [InlineData("120", 120000)] + [InlineData("not-a-number", 3600000)] + public void StartTimer_UsesConfiguredIntervalOrDefault(string configuredValue, double expectedInterval) + { + var manager = new PackageManagerBuilder().Build(); + _ = new InstalledPackagesLoader([manager]); + _ = new DiscoverablePackagesLoader([manager]); + var loader = new TestUpgradablePackagesLoader([manager]); + Settings.Set(Settings.K.DisableAutoCheckforUpdates, false); + Settings.SetValue(Settings.K.UpdatesCheckInterval, configuredValue); + + loader.StartTimer(); + + Assert.Equal(expectedInterval, loader.GetTimerIntervalMilliseconds()); + } +} diff --git a/src/UniGetUI.PackageEngine.Tests/WinGetManagerTests.cs b/src/UniGetUI.PackageEngine.Tests/WinGetManagerTests.cs new file mode 100644 index 0000000000..6ed59ba048 --- /dev/null +++ b/src/UniGetUI.PackageEngine.Tests/WinGetManagerTests.cs @@ -0,0 +1,192 @@ +#if WINDOWS +using UniGetUI.Core.Data; +using UniGetUI.Core.SettingsEngine; +using UniGetUI.PackageEngine.Classes.Manager; +using UniGetUI.PackageEngine.Interfaces; +using UniGetUI.PackageEngine.Managers.WingetManager; +using UniGetUI.PackageEngine.PackageClasses; +using UniGetUI.PackageEngine.Tests.Infrastructure.Assertions; +using UniGetUI.PackageEngine.Tests.Infrastructure.Builders; + +namespace UniGetUI.PackageEngine.Tests; + +[CollectionDefinition("WinGet manager tests", DisableParallelization = true)] +public sealed class WinGetManagerTestCollection +{ + public const string Name = "WinGet manager tests"; +} + +[Collection(WinGetManagerTestCollection.Name)] +public sealed class WinGetManagerTests : IDisposable +{ + private readonly string _testRoot = Path.Combine( + AppContext.BaseDirectory, + "WinGetManagerTests", + Guid.NewGuid().ToString("N") + ); + + public WinGetManagerTests() + { + Directory.CreateDirectory(_testRoot); + CoreData.TEST_DataDirectoryOverride = Path.Combine(_testRoot, "Data"); + SetNoPackagesHaveBeenLoaded(false); + Settings.Set(Settings.K.EnableProxy, false); + Settings.Set(Settings.K.EnableProxyAuth, false); + Settings.SetValue(Settings.K.ProxyURL, ""); + } + + public void Dispose() + { + SetNoPackagesHaveBeenLoaded(false); + WinGetHelper.Instance = null!; + CoreData.TEST_DataDirectoryOverride = null; + if (Directory.Exists(_testRoot)) + { + Directory.Delete(_testRoot, recursive: true); + } + } + + [Fact] + public void GetProxyArgumentReturnsEmptyStringWhenProxyIsDisabled() + { + Settings.Set(Settings.K.EnableProxy, false); + Settings.SetValue(Settings.K.ProxyURL, "http://proxy.example.test:3128/"); + + Assert.Equal("", WinGet.GetProxyArgument()); + } + + [Fact] + public void GetProxyArgumentReturnsTrimmedProxyArgumentWhenProxyIsEnabled() + { + Settings.Set(Settings.K.EnableProxy, true); + Settings.Set(Settings.K.EnableProxyAuth, false); + Settings.SetValue(Settings.K.ProxyURL, "http://proxy.example.test:3128/"); + + Assert.Equal("--proxy http://proxy.example.test:3128", WinGet.GetProxyArgument()); + } + + [Fact] + public void GetProxyArgumentReturnsEmptyStringWhenProxyAuthIsEnabled() + { + Settings.Set(Settings.K.EnableProxy, true); + Settings.Set(Settings.K.EnableProxyAuth, true); + Settings.SetValue(Settings.K.ProxyURL, "http://proxy.example.test:3128/"); + + Assert.Equal("", WinGet.GetProxyArgument()); + } + + [Theory] + [MemberData(nameof(LocalSourceCases))] + public void GetLocalSourceClassifiesKnownSourceFamilies( + string id, + LocalSourceKind expectedSourceKind + ) + { + var manager = new WinGet(); + + var source = manager.GetLocalSource(id); + + Assert.Same(GetExpectedSource(manager, expectedSourceKind), source); + } + + public static TheoryData LocalSourceCases => + new() + { + { "MSIX\\Microsoft.WindowsStore_8wekyb3d8bbwe", LocalSourceKind.MicrosoftStore }, + { "Programs\\{12345678-1234-1234-1234-123456789ABC}", LocalSourceKind.LocalPc }, + { "Apps\\com.example.android.app", LocalSourceKind.Android }, + { "Games\\Steam", LocalSourceKind.Steam }, + { "Games\\Steam App 12345", LocalSourceKind.Steam }, + { "Games\\Uplay", LocalSourceKind.Ubisoft }, + { "Games\\Uplay Install 12345", LocalSourceKind.Ubisoft }, + { "Games\\123456789_is1", LocalSourceKind.Gog }, + { "Programs\\Contoso.App", LocalSourceKind.LocalPc }, + }; + + [Fact] + public void GetInstalledPackagesUpdatesNoPackagesFlagForFailureAndRecovery() + { + var manager = new TestableWinGet(); + var expectedPackage = new PackageBuilder() + .WithManager(manager) + .WithName("Contoso Tool") + .WithId("Contoso.Tool") + .WithVersion("1.2.3") + .Build(); + var helper = new TestWinGetManagerHelper + { + GetInstalledPackagesHandler = () => throw new InvalidOperationException("boom"), + }; + WinGetHelper.Instance = helper; + + Assert.Throws(manager.InvokeGetInstalledPackages); + Assert.True(WinGet.NO_PACKAGES_HAVE_BEEN_LOADED); + + helper.GetInstalledPackagesHandler = () => [expectedPackage]; + + var packages = manager.InvokeGetInstalledPackages(); + + Assert.False(WinGet.NO_PACKAGES_HAVE_BEEN_LOADED); + PackageAssert.Matches(Assert.Single(packages), "Contoso Tool", "Contoso.Tool", "1.2.3"); + } + + private sealed class TestableWinGet : WinGet + { + public IReadOnlyList InvokeGetInstalledPackages() => base.GetInstalledPackages_UnSafe(); + } + + private static IManagerSource GetExpectedSource(WinGet manager, LocalSourceKind expectedSourceKind) => + expectedSourceKind switch + { + LocalSourceKind.MicrosoftStore => manager.MicrosoftStoreSource, + LocalSourceKind.LocalPc => manager.LocalPcSource, + LocalSourceKind.Android => manager.AndroidSubsystemSource, + LocalSourceKind.Steam => manager.SteamSource, + LocalSourceKind.Ubisoft => manager.UbisoftConnectSource, + LocalSourceKind.Gog => manager.GOGSource, + _ => throw new ArgumentOutOfRangeException(nameof(expectedSourceKind)), + }; + + private static void SetNoPackagesHaveBeenLoaded(bool value) + { + typeof(WinGet) + .GetProperty(nameof(WinGet.NO_PACKAGES_HAVE_BEEN_LOADED))! + .GetSetMethod(nonPublic: true)! + .Invoke(null, [value]); + } + + public enum LocalSourceKind + { + MicrosoftStore, + LocalPc, + Android, + Steam, + Ubisoft, + Gog, + } + + private sealed class TestWinGetManagerHelper : IWinGetManagerHelper + { + public Func> GetAvailableUpdatesHandler { get; set; } = static () => []; + public Func> GetInstalledPackagesHandler { get; set; } = static () => []; + public Func> FindPackagesHandler { get; set; } = static _ => []; + public Func> GetSourcesHandler { get; set; } = static () => []; + public Func> GetInstallableVersionsHandler { get; set; } = + static _ => []; + public Action GetPackageDetailsHandler { get; set; } = static _ => { }; + + public IReadOnlyList GetAvailableUpdates_UnSafe() => GetAvailableUpdatesHandler(); + + public IReadOnlyList GetInstalledPackages_UnSafe() => GetInstalledPackagesHandler(); + + public IReadOnlyList FindPackages_UnSafe(string query) => FindPackagesHandler(query); + + public IReadOnlyList GetSources_UnSafe() => GetSourcesHandler(); + + public IReadOnlyList GetInstallableVersions_Unsafe(IPackage package) => + GetInstallableVersionsHandler(package); + + public void GetPackageDetails_UnSafe(IPackageDetails details) => GetPackageDetailsHandler(details); + } +} +#endif diff --git a/src/UniGetUI.Tests/AutoUpdaterTests.cs b/src/UniGetUI.Tests/AutoUpdaterTests.cs new file mode 100644 index 0000000000..56efe77213 --- /dev/null +++ b/src/UniGetUI.Tests/AutoUpdaterTests.cs @@ -0,0 +1,99 @@ +using System.Runtime.InteropServices; +using Microsoft.Win32; + +namespace UniGetUI.Tests; + +public sealed class AutoUpdaterTests +{ + [Theory] + [InlineData("https://devolutions.net/productinfo.json", false, true)] + [InlineData("https://github.com/Devolutions/UniGetUI/releases", false, true)] + [InlineData("http://devolutions.net/productinfo.json", false, false)] + [InlineData("http://contoso.invalid/file.exe", true, true)] + public void IsSourceUrlAllowed_RestrictsUnsafeOrUnexpectedHosts( + string url, + bool allowUnsafeUrls, + bool expected + ) + { + Assert.Equal(expected, AutoUpdater.IsSourceUrlAllowed(url, allowUnsafeUrls)); + } + + [Fact] + public void SelectInstallerFile_PrefersExecutableForCurrentArchitecture() + { + string targetArch = RuntimeInformation.ProcessArchitecture switch + { + Architecture.Arm64 => "arm64", + Architecture.X64 => "x64", + _ => "x64", + }; + var preferred = new AutoUpdater.ProductInfoFile + { + Arch = targetArch, + Type = "exe", + Url = "https://example.test/app.exe", + Hash = "hash-exe", + }; + + var selected = AutoUpdater.SelectInstallerFile( + [ + new AutoUpdater.ProductInfoFile + { + Arch = "Any", + Type = "exe", + Url = "https://example.test/any.exe", + Hash = "hash-any", + }, + new AutoUpdater.ProductInfoFile + { + Arch = targetArch, + Type = "msi", + Url = "https://example.test/app.msi", + Hash = "hash-msi", + }, + preferred, + ] + ); + + Assert.Same(preferred, selected); + } + + [Fact] + public void ParseVersionOrFallback_ParsesTrimmedVersionsAndFallsBackForInvalidInput() + { + Version fallback = new(9, 9, 9, 9); + + Assert.Equal(new Version(1, 2, 3), AutoUpdater.ParseVersionOrFallback("v1.2.3", fallback)); + Assert.Equal(fallback, AutoUpdater.ParseVersionOrFallback("not-a-version", fallback)); + } + + [Fact] + public void NormalizeThumbprint_RemovesNonHexCharactersAndLowercases() + { + Assert.Equal("abcdef1234", AutoUpdater.NormalizeThumbprint("AB:CD ef-12_34")); + } + +#if DEBUG + [Fact] + public void RegistryHelpers_ParseTrimmedStringsAndTruthyValues() + { + string keyPath = $@"Software\Devolutions\UniGetUI.Tests\{Guid.NewGuid():N}"; + using RegistryKey key = Registry.CurrentUser.CreateSubKey(keyPath)!; + key.SetValue("ProductInfoUrl", " https://devolutions.net/custom.json "); + key.SetValue("AllowUnsafe", "yes"); + + try + { + Assert.Equal("https://devolutions.net/custom.json", AutoUpdater.GetRegistryString(key, "ProductInfoUrl")); + Assert.True(AutoUpdater.GetRegistryBool(key, "AllowUnsafe")); + Assert.Null(AutoUpdater.GetRegistryString(key, "Missing")); + Assert.False(AutoUpdater.GetRegistryBool(key, "Missing")); + } + finally + { + Registry.CurrentUser.DeleteSubKeyTree(keyPath, throwOnMissingSubKey: false); + } + } +#endif +} diff --git a/src/UniGetUI.Tests/CLIHandlerTests.cs b/src/UniGetUI.Tests/CLIHandlerTests.cs new file mode 100644 index 0000000000..4f011be11e --- /dev/null +++ b/src/UniGetUI.Tests/CLIHandlerTests.cs @@ -0,0 +1,118 @@ +using System.Text.Json; +using UniGetUI.Core.Data; +using UniGetUI.Core.SettingsEngine; +using UniGetUI.Core.SettingsEngine.SecureSettings; + +namespace UniGetUI.Tests; + +public sealed class CLIHandlerTests : IDisposable +{ + private readonly string _testRoot = Path.Combine( + Path.GetTempPath(), + nameof(CLIHandlerTests), + Guid.NewGuid().ToString("N") + ); + private readonly string _secureSettingsRoot; + + public CLIHandlerTests() + { + Directory.CreateDirectory(_testRoot); + CoreData.TEST_DataDirectoryOverride = Path.Combine(_testRoot, "Data"); + _secureSettingsRoot = Path.Combine(_testRoot, "SecureSettings"); + SecureSettings.TEST_SecureSettingsRootOverride = _secureSettingsRoot; + Directory.CreateDirectory(CoreData.UniGetUIUserConfigurationDirectory); + Settings.ResetSettings(); + } + + public void Dispose() + { + Settings.ResetSettings(); + CoreData.TEST_DataDirectoryOverride = null; + SecureSettings.TEST_SecureSettingsRootOverride = null; + if (Directory.Exists(_testRoot)) + { + Directory.Delete(_testRoot, recursive: true); + } + } + + [Fact] + public void ImportSettings_ReturnsNoSuchFileWhenInputIsMissing() + { + int result = CLIHandler.ImportSettings(["unigetui", CLIHandler.IMPORT_SETTINGS, Path.Combine(_testRoot, "missing.json")]); + + Assert.Equal(-1073741809, result); + } + + [Fact] + public void ExportAndImportSettings_RoundTripConfiguration() + { + string exportPath = Path.Combine(_testRoot, "settings.json"); + Settings.Set(Settings.K.FreshBoolSetting, true); + Settings.SetValue(Settings.K.FreshValue, "before-export"); + + Assert.Equal(0, CLIHandler.ExportSettings(["unigetui", CLIHandler.EXPORT_SETTINGS, exportPath])); + + Settings.Set(Settings.K.FreshBoolSetting, false); + Settings.SetValue(Settings.K.FreshValue, "after-export"); + + Assert.Equal(0, CLIHandler.ImportSettings(["unigetui", CLIHandler.IMPORT_SETTINGS, exportPath])); + Assert.True(Settings.Get(Settings.K.FreshBoolSetting)); + Assert.Equal("before-export", Settings.GetValue(Settings.K.FreshValue)); + + var exported = JsonSerializer.Deserialize>(File.ReadAllText(exportPath)); + Assert.NotNull(exported); + Assert.Equal("before-export", exported[Settings.ResolveKey(Settings.K.FreshValue)]); + } + + [Fact] + public void EnableDisableAndSetValue_MutateSettings() + { + Assert.Equal(0, CLIHandler.EnableSetting(["unigetui", CLIHandler.ENABLE_SETTING, nameof(Settings.K.Test1)])); + Assert.True(Settings.Get(Settings.K.Test1)); + + Assert.Equal(0, CLIHandler.SetSettingsValue(["unigetui", CLIHandler.SET_SETTING_VAL, nameof(Settings.K.FreshValue), "cli-value"])); + Assert.Equal("cli-value", Settings.GetValue(Settings.K.FreshValue)); + + Assert.Equal(0, CLIHandler.DisableSetting(["unigetui", CLIHandler.DISABLE_SETTING, nameof(Settings.K.Test1)])); + Assert.False(Settings.Get(Settings.K.Test1)); + } + + [Fact] + public void EnableAndDisableSecureSettingForUser_MutateSecureSettings() + { + string user = "cli-user"; + string setting = "AllowCLIArguments"; + + Assert.Equal( + 0, + CLIHandler.EnableSecureSettingForUser( + ["unigetui", CLIHandler.ENABLE_SECURE_SETTING_FOR_USER, user, setting] + ) + ); + Assert.True( + File.Exists( + Path.Combine( + _secureSettingsRoot, + user, + setting + ) + ) + ); + + Assert.Equal( + 0, + CLIHandler.DisableSecureSettingForUser( + ["unigetui", CLIHandler.DISABLE_SECURE_SETTING_FOR_USER, user, setting] + ) + ); + Assert.False( + File.Exists( + Path.Combine( + _secureSettingsRoot, + user, + setting + ) + ) + ); + } +} diff --git a/src/UniGetUI.Tests/TestAssembly.cs b/src/UniGetUI.Tests/TestAssembly.cs new file mode 100644 index 0000000000..217120083b --- /dev/null +++ b/src/UniGetUI.Tests/TestAssembly.cs @@ -0,0 +1,3 @@ +using Xunit; + +[assembly: CollectionBehavior(DisableTestParallelization = true)] diff --git a/src/UniGetUI.Tests/UniGetUI.Tests.csproj b/src/UniGetUI.Tests/UniGetUI.Tests.csproj new file mode 100644 index 0000000000..1a205ca3b8 --- /dev/null +++ b/src/UniGetUI.Tests/UniGetUI.Tests.csproj @@ -0,0 +1,49 @@ + + + $(WindowsTargetFramework) + + false + true + x64 + win-x64 + false + false + false + false + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + + + + + + + + diff --git a/src/UniGetUI.sln b/src/UniGetUI.sln index 88508377d4..d64b90697c 100644 --- a/src/UniGetUI.sln +++ b/src/UniGetUI.sln @@ -109,350 +109,746 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "UniGetUI.PackageEngine.Seri EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "UniGetUI.Core.SecureSettings", "UniGetUI.Core.SecureSettings\UniGetUI.Core.SecureSettings.csproj", "{B0E59327-933E-4DB0-BD2D-FB16EB9B4194}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "UniGetUI.PackageEngine.Tests", "UniGetUI.PackageEngine.Tests\UniGetUI.PackageEngine.Tests.csproj", "{181E4491-B96D-4E33-8102-AC91420D6123}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "UniGetUI.Interface.Telemetry.Tests", "UniGetUI.Interface.Telemetry.Tests\UniGetUI.Interface.Telemetry.Tests.csproj", "{991DBBC6-3E13-4A95-BCDC-F99512F87DC3}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "UniGetUI.Core.Logger", "UniGetUI.Core.Logger", "{8531793E-F891-2283-BD05-9B92EE2A6C73}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "UniGetUI.Tests", "UniGetUI.Tests\UniGetUI.Tests.csproj", "{8CDDF549-38D0-45DB-9494-3B9B3376E7C0}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|x64 = Debug|x64 Debug|arm64 = Debug|arm64 + Debug|Any CPU = Debug|Any CPU + Debug|x86 = Debug|x86 Release|x64 = Release|x64 Release|arm64 = Release|arm64 + Release|Any CPU = Release|Any CPU + Release|x86 = Release|x86 EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {80305A17-2534-48DC-8F75-41F70FCCEAAF}.Debug|x64.ActiveCfg = Debug|x64 - {80305A17-2534-48DC-8F75-41F70FCCEAAF}.Debug|arm64.ActiveCfg = Debug|arm64 {80305A17-2534-48DC-8F75-41F70FCCEAAF}.Debug|x64.Build.0 = Debug|x64 + {80305A17-2534-48DC-8F75-41F70FCCEAAF}.Debug|arm64.ActiveCfg = Debug|arm64 {80305A17-2534-48DC-8F75-41F70FCCEAAF}.Debug|arm64.Build.0 = Debug|arm64 + {80305A17-2534-48DC-8F75-41F70FCCEAAF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {80305A17-2534-48DC-8F75-41F70FCCEAAF}.Debug|Any CPU.Build.0 = Debug|Any CPU + {80305A17-2534-48DC-8F75-41F70FCCEAAF}.Debug|x86.ActiveCfg = Debug|Any CPU + {80305A17-2534-48DC-8F75-41F70FCCEAAF}.Debug|x86.Build.0 = Debug|Any CPU {80305A17-2534-48DC-8F75-41F70FCCEAAF}.Release|x64.ActiveCfg = Release|x64 - {80305A17-2534-48DC-8F75-41F70FCCEAAF}.Release|arm64.ActiveCfg = Release|arm64 {80305A17-2534-48DC-8F75-41F70FCCEAAF}.Release|x64.Build.0 = Release|x64 + {80305A17-2534-48DC-8F75-41F70FCCEAAF}.Release|arm64.ActiveCfg = Release|arm64 {80305A17-2534-48DC-8F75-41F70FCCEAAF}.Release|arm64.Build.0 = Release|arm64 + {80305A17-2534-48DC-8F75-41F70FCCEAAF}.Release|Any CPU.ActiveCfg = Release|Any CPU + {80305A17-2534-48DC-8F75-41F70FCCEAAF}.Release|Any CPU.Build.0 = Release|Any CPU + {80305A17-2534-48DC-8F75-41F70FCCEAAF}.Release|x86.ActiveCfg = Release|Any CPU + {80305A17-2534-48DC-8F75-41F70FCCEAAF}.Release|x86.Build.0 = Release|Any CPU {52AC982E-7382-4746-BB66-4003698FCC02}.Debug|x64.ActiveCfg = Debug|x64 - {52AC982E-7382-4746-BB66-4003698FCC02}.Debug|arm64.ActiveCfg = Debug|arm64 {52AC982E-7382-4746-BB66-4003698FCC02}.Debug|x64.Build.0 = Debug|x64 + {52AC982E-7382-4746-BB66-4003698FCC02}.Debug|arm64.ActiveCfg = Debug|arm64 {52AC982E-7382-4746-BB66-4003698FCC02}.Debug|arm64.Build.0 = Debug|arm64 + {52AC982E-7382-4746-BB66-4003698FCC02}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {52AC982E-7382-4746-BB66-4003698FCC02}.Debug|Any CPU.Build.0 = Debug|Any CPU + {52AC982E-7382-4746-BB66-4003698FCC02}.Debug|x86.ActiveCfg = Debug|Any CPU + {52AC982E-7382-4746-BB66-4003698FCC02}.Debug|x86.Build.0 = Debug|Any CPU {52AC982E-7382-4746-BB66-4003698FCC02}.Release|x64.ActiveCfg = Release|x64 - {52AC982E-7382-4746-BB66-4003698FCC02}.Release|arm64.ActiveCfg = Release|arm64 {52AC982E-7382-4746-BB66-4003698FCC02}.Release|x64.Build.0 = Release|x64 + {52AC982E-7382-4746-BB66-4003698FCC02}.Release|arm64.ActiveCfg = Release|arm64 {52AC982E-7382-4746-BB66-4003698FCC02}.Release|arm64.Build.0 = Release|arm64 + {52AC982E-7382-4746-BB66-4003698FCC02}.Release|Any CPU.ActiveCfg = Release|Any CPU + {52AC982E-7382-4746-BB66-4003698FCC02}.Release|Any CPU.Build.0 = Release|Any CPU + {52AC982E-7382-4746-BB66-4003698FCC02}.Release|x86.ActiveCfg = Release|Any CPU + {52AC982E-7382-4746-BB66-4003698FCC02}.Release|x86.Build.0 = Release|Any CPU {5F5EF76B-D755-4C12-ADAE-11F08CE3D936}.Debug|x64.ActiveCfg = Debug|x64 - {5F5EF76B-D755-4C12-ADAE-11F08CE3D936}.Debug|arm64.ActiveCfg = Debug|arm64 {5F5EF76B-D755-4C12-ADAE-11F08CE3D936}.Debug|x64.Build.0 = Debug|x64 + {5F5EF76B-D755-4C12-ADAE-11F08CE3D936}.Debug|arm64.ActiveCfg = Debug|arm64 {5F5EF76B-D755-4C12-ADAE-11F08CE3D936}.Debug|arm64.Build.0 = Debug|arm64 + {5F5EF76B-D755-4C12-ADAE-11F08CE3D936}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5F5EF76B-D755-4C12-ADAE-11F08CE3D936}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5F5EF76B-D755-4C12-ADAE-11F08CE3D936}.Debug|x86.ActiveCfg = Debug|Any CPU + {5F5EF76B-D755-4C12-ADAE-11F08CE3D936}.Debug|x86.Build.0 = Debug|Any CPU {5F5EF76B-D755-4C12-ADAE-11F08CE3D936}.Release|x64.ActiveCfg = Release|x64 - {5F5EF76B-D755-4C12-ADAE-11F08CE3D936}.Release|arm64.ActiveCfg = Release|arm64 {5F5EF76B-D755-4C12-ADAE-11F08CE3D936}.Release|x64.Build.0 = Release|x64 + {5F5EF76B-D755-4C12-ADAE-11F08CE3D936}.Release|arm64.ActiveCfg = Release|arm64 {5F5EF76B-D755-4C12-ADAE-11F08CE3D936}.Release|arm64.Build.0 = Release|arm64 + {5F5EF76B-D755-4C12-ADAE-11F08CE3D936}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5F5EF76B-D755-4C12-ADAE-11F08CE3D936}.Release|Any CPU.Build.0 = Release|Any CPU + {5F5EF76B-D755-4C12-ADAE-11F08CE3D936}.Release|x86.ActiveCfg = Release|Any CPU + {5F5EF76B-D755-4C12-ADAE-11F08CE3D936}.Release|x86.Build.0 = Release|Any CPU {B70A6F17-08C8-4194-BBE8-668CA920CFF3}.Debug|x64.ActiveCfg = Debug|x64 - {B70A6F17-08C8-4194-BBE8-668CA920CFF3}.Debug|arm64.ActiveCfg = Debug|arm64 {B70A6F17-08C8-4194-BBE8-668CA920CFF3}.Debug|x64.Build.0 = Debug|x64 + {B70A6F17-08C8-4194-BBE8-668CA920CFF3}.Debug|arm64.ActiveCfg = Debug|arm64 {B70A6F17-08C8-4194-BBE8-668CA920CFF3}.Debug|arm64.Build.0 = Debug|arm64 + {B70A6F17-08C8-4194-BBE8-668CA920CFF3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B70A6F17-08C8-4194-BBE8-668CA920CFF3}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B70A6F17-08C8-4194-BBE8-668CA920CFF3}.Debug|x86.ActiveCfg = Debug|Any CPU + {B70A6F17-08C8-4194-BBE8-668CA920CFF3}.Debug|x86.Build.0 = Debug|Any CPU {B70A6F17-08C8-4194-BBE8-668CA920CFF3}.Release|x64.ActiveCfg = Release|x64 - {B70A6F17-08C8-4194-BBE8-668CA920CFF3}.Release|arm64.ActiveCfg = Release|arm64 {B70A6F17-08C8-4194-BBE8-668CA920CFF3}.Release|x64.Build.0 = Release|x64 + {B70A6F17-08C8-4194-BBE8-668CA920CFF3}.Release|arm64.ActiveCfg = Release|arm64 {B70A6F17-08C8-4194-BBE8-668CA920CFF3}.Release|arm64.Build.0 = Release|arm64 + {B70A6F17-08C8-4194-BBE8-668CA920CFF3}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B70A6F17-08C8-4194-BBE8-668CA920CFF3}.Release|Any CPU.Build.0 = Release|Any CPU + {B70A6F17-08C8-4194-BBE8-668CA920CFF3}.Release|x86.ActiveCfg = Release|Any CPU + {B70A6F17-08C8-4194-BBE8-668CA920CFF3}.Release|x86.Build.0 = Release|Any CPU {72180B0C-3D20-4AAD-B015-A9337B91406E}.Debug|x64.ActiveCfg = Debug|x64 - {72180B0C-3D20-4AAD-B015-A9337B91406E}.Debug|arm64.ActiveCfg = Debug|arm64 {72180B0C-3D20-4AAD-B015-A9337B91406E}.Debug|x64.Build.0 = Debug|x64 + {72180B0C-3D20-4AAD-B015-A9337B91406E}.Debug|arm64.ActiveCfg = Debug|arm64 {72180B0C-3D20-4AAD-B015-A9337B91406E}.Debug|arm64.Build.0 = Debug|arm64 + {72180B0C-3D20-4AAD-B015-A9337B91406E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {72180B0C-3D20-4AAD-B015-A9337B91406E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {72180B0C-3D20-4AAD-B015-A9337B91406E}.Debug|x86.ActiveCfg = Debug|Any CPU + {72180B0C-3D20-4AAD-B015-A9337B91406E}.Debug|x86.Build.0 = Debug|Any CPU {72180B0C-3D20-4AAD-B015-A9337B91406E}.Release|x64.ActiveCfg = Release|x64 - {72180B0C-3D20-4AAD-B015-A9337B91406E}.Release|arm64.ActiveCfg = Release|arm64 {72180B0C-3D20-4AAD-B015-A9337B91406E}.Release|x64.Build.0 = Release|x64 + {72180B0C-3D20-4AAD-B015-A9337B91406E}.Release|arm64.ActiveCfg = Release|arm64 {72180B0C-3D20-4AAD-B015-A9337B91406E}.Release|arm64.Build.0 = Release|arm64 + {72180B0C-3D20-4AAD-B015-A9337B91406E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {72180B0C-3D20-4AAD-B015-A9337B91406E}.Release|Any CPU.Build.0 = Release|Any CPU + {72180B0C-3D20-4AAD-B015-A9337B91406E}.Release|x86.ActiveCfg = Release|Any CPU + {72180B0C-3D20-4AAD-B015-A9337B91406E}.Release|x86.Build.0 = Release|Any CPU {1977360F-2E42-45E6-9369-AB1EE59CC5C5}.Debug|x64.ActiveCfg = Debug|x64 - {1977360F-2E42-45E6-9369-AB1EE59CC5C5}.Debug|arm64.ActiveCfg = Debug|arm64 {1977360F-2E42-45E6-9369-AB1EE59CC5C5}.Debug|x64.Build.0 = Debug|x64 + {1977360F-2E42-45E6-9369-AB1EE59CC5C5}.Debug|arm64.ActiveCfg = Debug|arm64 {1977360F-2E42-45E6-9369-AB1EE59CC5C5}.Debug|arm64.Build.0 = Debug|arm64 + {1977360F-2E42-45E6-9369-AB1EE59CC5C5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1977360F-2E42-45E6-9369-AB1EE59CC5C5}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1977360F-2E42-45E6-9369-AB1EE59CC5C5}.Debug|x86.ActiveCfg = Debug|Any CPU + {1977360F-2E42-45E6-9369-AB1EE59CC5C5}.Debug|x86.Build.0 = Debug|Any CPU {1977360F-2E42-45E6-9369-AB1EE59CC5C5}.Release|x64.ActiveCfg = Release|x64 - {1977360F-2E42-45E6-9369-AB1EE59CC5C5}.Release|arm64.ActiveCfg = Release|arm64 {1977360F-2E42-45E6-9369-AB1EE59CC5C5}.Release|x64.Build.0 = Release|x64 + {1977360F-2E42-45E6-9369-AB1EE59CC5C5}.Release|arm64.ActiveCfg = Release|arm64 {1977360F-2E42-45E6-9369-AB1EE59CC5C5}.Release|arm64.Build.0 = Release|arm64 + {1977360F-2E42-45E6-9369-AB1EE59CC5C5}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1977360F-2E42-45E6-9369-AB1EE59CC5C5}.Release|Any CPU.Build.0 = Release|Any CPU + {1977360F-2E42-45E6-9369-AB1EE59CC5C5}.Release|x86.ActiveCfg = Release|Any CPU + {1977360F-2E42-45E6-9369-AB1EE59CC5C5}.Release|x86.Build.0 = Release|Any CPU {25C6CE64-2D61-4832-B6D2-45AFC52E2447}.Debug|x64.ActiveCfg = Debug|x64 - {25C6CE64-2D61-4832-B6D2-45AFC52E2447}.Debug|arm64.ActiveCfg = Debug|arm64 {25C6CE64-2D61-4832-B6D2-45AFC52E2447}.Debug|x64.Build.0 = Debug|x64 + {25C6CE64-2D61-4832-B6D2-45AFC52E2447}.Debug|arm64.ActiveCfg = Debug|arm64 {25C6CE64-2D61-4832-B6D2-45AFC52E2447}.Debug|arm64.Build.0 = Debug|arm64 + {25C6CE64-2D61-4832-B6D2-45AFC52E2447}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {25C6CE64-2D61-4832-B6D2-45AFC52E2447}.Debug|Any CPU.Build.0 = Debug|Any CPU + {25C6CE64-2D61-4832-B6D2-45AFC52E2447}.Debug|x86.ActiveCfg = Debug|Any CPU + {25C6CE64-2D61-4832-B6D2-45AFC52E2447}.Debug|x86.Build.0 = Debug|Any CPU {25C6CE64-2D61-4832-B6D2-45AFC52E2447}.Release|x64.ActiveCfg = Release|x64 - {25C6CE64-2D61-4832-B6D2-45AFC52E2447}.Release|arm64.ActiveCfg = Release|arm64 {25C6CE64-2D61-4832-B6D2-45AFC52E2447}.Release|x64.Build.0 = Release|x64 + {25C6CE64-2D61-4832-B6D2-45AFC52E2447}.Release|arm64.ActiveCfg = Release|arm64 {25C6CE64-2D61-4832-B6D2-45AFC52E2447}.Release|arm64.Build.0 = Release|arm64 + {25C6CE64-2D61-4832-B6D2-45AFC52E2447}.Release|Any CPU.ActiveCfg = Release|Any CPU + {25C6CE64-2D61-4832-B6D2-45AFC52E2447}.Release|Any CPU.Build.0 = Release|Any CPU + {25C6CE64-2D61-4832-B6D2-45AFC52E2447}.Release|x86.ActiveCfg = Release|Any CPU + {25C6CE64-2D61-4832-B6D2-45AFC52E2447}.Release|x86.Build.0 = Release|Any CPU {8156B6D8-BD7E-4201-BD8B-8C9B00177F88}.Debug|x64.ActiveCfg = Debug|x64 - {8156B6D8-BD7E-4201-BD8B-8C9B00177F88}.Debug|arm64.ActiveCfg = Debug|arm64 {8156B6D8-BD7E-4201-BD8B-8C9B00177F88}.Debug|x64.Build.0 = Debug|x64 + {8156B6D8-BD7E-4201-BD8B-8C9B00177F88}.Debug|arm64.ActiveCfg = Debug|arm64 {8156B6D8-BD7E-4201-BD8B-8C9B00177F88}.Debug|arm64.Build.0 = Debug|arm64 + {8156B6D8-BD7E-4201-BD8B-8C9B00177F88}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8156B6D8-BD7E-4201-BD8B-8C9B00177F88}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8156B6D8-BD7E-4201-BD8B-8C9B00177F88}.Debug|x86.ActiveCfg = Debug|Any CPU + {8156B6D8-BD7E-4201-BD8B-8C9B00177F88}.Debug|x86.Build.0 = Debug|Any CPU {8156B6D8-BD7E-4201-BD8B-8C9B00177F88}.Release|x64.ActiveCfg = Release|x64 - {8156B6D8-BD7E-4201-BD8B-8C9B00177F88}.Release|arm64.ActiveCfg = Release|arm64 {8156B6D8-BD7E-4201-BD8B-8C9B00177F88}.Release|x64.Build.0 = Release|x64 + {8156B6D8-BD7E-4201-BD8B-8C9B00177F88}.Release|arm64.ActiveCfg = Release|arm64 {8156B6D8-BD7E-4201-BD8B-8C9B00177F88}.Release|arm64.Build.0 = Release|arm64 + {8156B6D8-BD7E-4201-BD8B-8C9B00177F88}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8156B6D8-BD7E-4201-BD8B-8C9B00177F88}.Release|Any CPU.Build.0 = Release|Any CPU + {8156B6D8-BD7E-4201-BD8B-8C9B00177F88}.Release|x86.ActiveCfg = Release|Any CPU + {8156B6D8-BD7E-4201-BD8B-8C9B00177F88}.Release|x86.Build.0 = Release|Any CPU {990F5AFF-ABF6-4019-865D-604D2B23DE2C}.Debug|x64.ActiveCfg = Debug|x64 - {990F5AFF-ABF6-4019-865D-604D2B23DE2C}.Debug|arm64.ActiveCfg = Debug|arm64 {990F5AFF-ABF6-4019-865D-604D2B23DE2C}.Debug|x64.Build.0 = Debug|x64 + {990F5AFF-ABF6-4019-865D-604D2B23DE2C}.Debug|arm64.ActiveCfg = Debug|arm64 {990F5AFF-ABF6-4019-865D-604D2B23DE2C}.Debug|arm64.Build.0 = Debug|arm64 + {990F5AFF-ABF6-4019-865D-604D2B23DE2C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {990F5AFF-ABF6-4019-865D-604D2B23DE2C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {990F5AFF-ABF6-4019-865D-604D2B23DE2C}.Debug|x86.ActiveCfg = Debug|Any CPU + {990F5AFF-ABF6-4019-865D-604D2B23DE2C}.Debug|x86.Build.0 = Debug|Any CPU {990F5AFF-ABF6-4019-865D-604D2B23DE2C}.Release|x64.ActiveCfg = Release|x64 - {990F5AFF-ABF6-4019-865D-604D2B23DE2C}.Release|arm64.ActiveCfg = Release|arm64 {990F5AFF-ABF6-4019-865D-604D2B23DE2C}.Release|x64.Build.0 = Release|x64 + {990F5AFF-ABF6-4019-865D-604D2B23DE2C}.Release|arm64.ActiveCfg = Release|arm64 {990F5AFF-ABF6-4019-865D-604D2B23DE2C}.Release|arm64.Build.0 = Release|arm64 + {990F5AFF-ABF6-4019-865D-604D2B23DE2C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {990F5AFF-ABF6-4019-865D-604D2B23DE2C}.Release|Any CPU.Build.0 = Release|Any CPU + {990F5AFF-ABF6-4019-865D-604D2B23DE2C}.Release|x86.ActiveCfg = Release|Any CPU + {990F5AFF-ABF6-4019-865D-604D2B23DE2C}.Release|x86.Build.0 = Release|Any CPU {380E9F5A-23DE-4F5A-9644-EFA51AD1D8E8}.Debug|x64.ActiveCfg = Debug|x64 - {380E9F5A-23DE-4F5A-9644-EFA51AD1D8E8}.Debug|arm64.ActiveCfg = Debug|arm64 {380E9F5A-23DE-4F5A-9644-EFA51AD1D8E8}.Debug|x64.Build.0 = Debug|x64 + {380E9F5A-23DE-4F5A-9644-EFA51AD1D8E8}.Debug|arm64.ActiveCfg = Debug|arm64 {380E9F5A-23DE-4F5A-9644-EFA51AD1D8E8}.Debug|arm64.Build.0 = Debug|arm64 + {380E9F5A-23DE-4F5A-9644-EFA51AD1D8E8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {380E9F5A-23DE-4F5A-9644-EFA51AD1D8E8}.Debug|Any CPU.Build.0 = Debug|Any CPU + {380E9F5A-23DE-4F5A-9644-EFA51AD1D8E8}.Debug|x86.ActiveCfg = Debug|Any CPU + {380E9F5A-23DE-4F5A-9644-EFA51AD1D8E8}.Debug|x86.Build.0 = Debug|Any CPU {380E9F5A-23DE-4F5A-9644-EFA51AD1D8E8}.Release|x64.ActiveCfg = Release|x64 - {380E9F5A-23DE-4F5A-9644-EFA51AD1D8E8}.Release|arm64.ActiveCfg = Release|arm64 {380E9F5A-23DE-4F5A-9644-EFA51AD1D8E8}.Release|x64.Build.0 = Release|x64 + {380E9F5A-23DE-4F5A-9644-EFA51AD1D8E8}.Release|arm64.ActiveCfg = Release|arm64 {380E9F5A-23DE-4F5A-9644-EFA51AD1D8E8}.Release|arm64.Build.0 = Release|arm64 + {380E9F5A-23DE-4F5A-9644-EFA51AD1D8E8}.Release|Any CPU.ActiveCfg = Release|Any CPU + {380E9F5A-23DE-4F5A-9644-EFA51AD1D8E8}.Release|Any CPU.Build.0 = Release|Any CPU + {380E9F5A-23DE-4F5A-9644-EFA51AD1D8E8}.Release|x86.ActiveCfg = Release|Any CPU + {380E9F5A-23DE-4F5A-9644-EFA51AD1D8E8}.Release|x86.Build.0 = Release|Any CPU {5A48C2FD-16E4-4B44-BC2C-D793C50E66F2}.Debug|x64.ActiveCfg = Debug|x64 - {5A48C2FD-16E4-4B44-BC2C-D793C50E66F2}.Debug|arm64.ActiveCfg = Debug|arm64 {5A48C2FD-16E4-4B44-BC2C-D793C50E66F2}.Debug|x64.Build.0 = Debug|x64 + {5A48C2FD-16E4-4B44-BC2C-D793C50E66F2}.Debug|arm64.ActiveCfg = Debug|arm64 {5A48C2FD-16E4-4B44-BC2C-D793C50E66F2}.Debug|arm64.Build.0 = Debug|arm64 + {5A48C2FD-16E4-4B44-BC2C-D793C50E66F2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5A48C2FD-16E4-4B44-BC2C-D793C50E66F2}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5A48C2FD-16E4-4B44-BC2C-D793C50E66F2}.Debug|x86.ActiveCfg = Debug|Any CPU + {5A48C2FD-16E4-4B44-BC2C-D793C50E66F2}.Debug|x86.Build.0 = Debug|Any CPU {5A48C2FD-16E4-4B44-BC2C-D793C50E66F2}.Release|x64.ActiveCfg = Release|x64 - {5A48C2FD-16E4-4B44-BC2C-D793C50E66F2}.Release|arm64.ActiveCfg = Release|arm64 {5A48C2FD-16E4-4B44-BC2C-D793C50E66F2}.Release|x64.Build.0 = Release|x64 + {5A48C2FD-16E4-4B44-BC2C-D793C50E66F2}.Release|arm64.ActiveCfg = Release|arm64 {5A48C2FD-16E4-4B44-BC2C-D793C50E66F2}.Release|arm64.Build.0 = Release|arm64 + {5A48C2FD-16E4-4B44-BC2C-D793C50E66F2}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5A48C2FD-16E4-4B44-BC2C-D793C50E66F2}.Release|Any CPU.Build.0 = Release|Any CPU + {5A48C2FD-16E4-4B44-BC2C-D793C50E66F2}.Release|x86.ActiveCfg = Release|Any CPU + {5A48C2FD-16E4-4B44-BC2C-D793C50E66F2}.Release|x86.Build.0 = Release|Any CPU {9AD1DEC9-1561-4753-AB4B-E81FBDBA5C9E}.Debug|x64.ActiveCfg = Debug|x64 - {9AD1DEC9-1561-4753-AB4B-E81FBDBA5C9E}.Debug|arm64.ActiveCfg = Debug|arm64 {9AD1DEC9-1561-4753-AB4B-E81FBDBA5C9E}.Debug|x64.Build.0 = Debug|x64 + {9AD1DEC9-1561-4753-AB4B-E81FBDBA5C9E}.Debug|arm64.ActiveCfg = Debug|arm64 {9AD1DEC9-1561-4753-AB4B-E81FBDBA5C9E}.Debug|arm64.Build.0 = Debug|arm64 + {9AD1DEC9-1561-4753-AB4B-E81FBDBA5C9E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9AD1DEC9-1561-4753-AB4B-E81FBDBA5C9E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9AD1DEC9-1561-4753-AB4B-E81FBDBA5C9E}.Debug|x86.ActiveCfg = Debug|Any CPU + {9AD1DEC9-1561-4753-AB4B-E81FBDBA5C9E}.Debug|x86.Build.0 = Debug|Any CPU {9AD1DEC9-1561-4753-AB4B-E81FBDBA5C9E}.Release|x64.ActiveCfg = Release|x64 - {9AD1DEC9-1561-4753-AB4B-E81FBDBA5C9E}.Release|arm64.ActiveCfg = Release|arm64 {9AD1DEC9-1561-4753-AB4B-E81FBDBA5C9E}.Release|x64.Build.0 = Release|x64 + {9AD1DEC9-1561-4753-AB4B-E81FBDBA5C9E}.Release|arm64.ActiveCfg = Release|arm64 {9AD1DEC9-1561-4753-AB4B-E81FBDBA5C9E}.Release|arm64.Build.0 = Release|arm64 + {9AD1DEC9-1561-4753-AB4B-E81FBDBA5C9E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9AD1DEC9-1561-4753-AB4B-E81FBDBA5C9E}.Release|Any CPU.Build.0 = Release|Any CPU + {9AD1DEC9-1561-4753-AB4B-E81FBDBA5C9E}.Release|x86.ActiveCfg = Release|Any CPU + {9AD1DEC9-1561-4753-AB4B-E81FBDBA5C9E}.Release|x86.Build.0 = Release|Any CPU {E40BFCBB-7A02-4E2C-AFDB-A717359EF4FC}.Debug|x64.ActiveCfg = Debug|x64 - {E40BFCBB-7A02-4E2C-AFDB-A717359EF4FC}.Debug|arm64.ActiveCfg = Debug|arm64 {E40BFCBB-7A02-4E2C-AFDB-A717359EF4FC}.Debug|x64.Build.0 = Debug|x64 + {E40BFCBB-7A02-4E2C-AFDB-A717359EF4FC}.Debug|arm64.ActiveCfg = Debug|arm64 {E40BFCBB-7A02-4E2C-AFDB-A717359EF4FC}.Debug|arm64.Build.0 = Debug|arm64 + {E40BFCBB-7A02-4E2C-AFDB-A717359EF4FC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E40BFCBB-7A02-4E2C-AFDB-A717359EF4FC}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E40BFCBB-7A02-4E2C-AFDB-A717359EF4FC}.Debug|x86.ActiveCfg = Debug|Any CPU + {E40BFCBB-7A02-4E2C-AFDB-A717359EF4FC}.Debug|x86.Build.0 = Debug|Any CPU {E40BFCBB-7A02-4E2C-AFDB-A717359EF4FC}.Release|x64.ActiveCfg = Release|x64 - {E40BFCBB-7A02-4E2C-AFDB-A717359EF4FC}.Release|arm64.ActiveCfg = Release|arm64 {E40BFCBB-7A02-4E2C-AFDB-A717359EF4FC}.Release|x64.Build.0 = Release|x64 + {E40BFCBB-7A02-4E2C-AFDB-A717359EF4FC}.Release|arm64.ActiveCfg = Release|arm64 {E40BFCBB-7A02-4E2C-AFDB-A717359EF4FC}.Release|arm64.Build.0 = Release|arm64 + {E40BFCBB-7A02-4E2C-AFDB-A717359EF4FC}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E40BFCBB-7A02-4E2C-AFDB-A717359EF4FC}.Release|Any CPU.Build.0 = Release|Any CPU + {E40BFCBB-7A02-4E2C-AFDB-A717359EF4FC}.Release|x86.ActiveCfg = Release|Any CPU + {E40BFCBB-7A02-4E2C-AFDB-A717359EF4FC}.Release|x86.Build.0 = Release|Any CPU {562B4814-2A78-4692-90BE-A727AABCEC85}.Debug|x64.ActiveCfg = Debug|x64 - {562B4814-2A78-4692-90BE-A727AABCEC85}.Debug|arm64.ActiveCfg = Debug|arm64 {562B4814-2A78-4692-90BE-A727AABCEC85}.Debug|x64.Build.0 = Debug|x64 + {562B4814-2A78-4692-90BE-A727AABCEC85}.Debug|arm64.ActiveCfg = Debug|arm64 {562B4814-2A78-4692-90BE-A727AABCEC85}.Debug|arm64.Build.0 = Debug|arm64 + {562B4814-2A78-4692-90BE-A727AABCEC85}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {562B4814-2A78-4692-90BE-A727AABCEC85}.Debug|Any CPU.Build.0 = Debug|Any CPU + {562B4814-2A78-4692-90BE-A727AABCEC85}.Debug|x86.ActiveCfg = Debug|Any CPU + {562B4814-2A78-4692-90BE-A727AABCEC85}.Debug|x86.Build.0 = Debug|Any CPU {562B4814-2A78-4692-90BE-A727AABCEC85}.Release|x64.ActiveCfg = Release|x64 - {562B4814-2A78-4692-90BE-A727AABCEC85}.Release|arm64.ActiveCfg = Release|arm64 {562B4814-2A78-4692-90BE-A727AABCEC85}.Release|x64.Build.0 = Release|x64 + {562B4814-2A78-4692-90BE-A727AABCEC85}.Release|arm64.ActiveCfg = Release|arm64 {562B4814-2A78-4692-90BE-A727AABCEC85}.Release|arm64.Build.0 = Release|arm64 + {562B4814-2A78-4692-90BE-A727AABCEC85}.Release|Any CPU.ActiveCfg = Release|Any CPU + {562B4814-2A78-4692-90BE-A727AABCEC85}.Release|Any CPU.Build.0 = Release|Any CPU + {562B4814-2A78-4692-90BE-A727AABCEC85}.Release|x86.ActiveCfg = Release|Any CPU + {562B4814-2A78-4692-90BE-A727AABCEC85}.Release|x86.Build.0 = Release|Any CPU {1A51EA31-6D78-4E98-B767-41A02C6E34D8}.Debug|x64.ActiveCfg = Debug|x64 - {1A51EA31-6D78-4E98-B767-41A02C6E34D8}.Debug|arm64.ActiveCfg = Debug|arm64 {1A51EA31-6D78-4E98-B767-41A02C6E34D8}.Debug|x64.Build.0 = Debug|x64 + {1A51EA31-6D78-4E98-B767-41A02C6E34D8}.Debug|arm64.ActiveCfg = Debug|arm64 {1A51EA31-6D78-4E98-B767-41A02C6E34D8}.Debug|arm64.Build.0 = Debug|arm64 + {1A51EA31-6D78-4E98-B767-41A02C6E34D8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1A51EA31-6D78-4E98-B767-41A02C6E34D8}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1A51EA31-6D78-4E98-B767-41A02C6E34D8}.Debug|x86.ActiveCfg = Debug|Any CPU + {1A51EA31-6D78-4E98-B767-41A02C6E34D8}.Debug|x86.Build.0 = Debug|Any CPU {1A51EA31-6D78-4E98-B767-41A02C6E34D8}.Release|x64.ActiveCfg = Release|x64 - {1A51EA31-6D78-4E98-B767-41A02C6E34D8}.Release|arm64.ActiveCfg = Release|arm64 {1A51EA31-6D78-4E98-B767-41A02C6E34D8}.Release|x64.Build.0 = Release|x64 + {1A51EA31-6D78-4E98-B767-41A02C6E34D8}.Release|arm64.ActiveCfg = Release|arm64 {1A51EA31-6D78-4E98-B767-41A02C6E34D8}.Release|arm64.Build.0 = Release|arm64 + {1A51EA31-6D78-4E98-B767-41A02C6E34D8}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1A51EA31-6D78-4E98-B767-41A02C6E34D8}.Release|Any CPU.Build.0 = Release|Any CPU + {1A51EA31-6D78-4E98-B767-41A02C6E34D8}.Release|x86.ActiveCfg = Release|Any CPU + {1A51EA31-6D78-4E98-B767-41A02C6E34D8}.Release|x86.Build.0 = Release|Any CPU {230BF08C-C039-473B-933F-3BF647440E0E}.Debug|x64.ActiveCfg = Debug|x64 - {230BF08C-C039-473B-933F-3BF647440E0E}.Debug|arm64.ActiveCfg = Debug|arm64 {230BF08C-C039-473B-933F-3BF647440E0E}.Debug|x64.Build.0 = Debug|x64 + {230BF08C-C039-473B-933F-3BF647440E0E}.Debug|arm64.ActiveCfg = Debug|arm64 {230BF08C-C039-473B-933F-3BF647440E0E}.Debug|arm64.Build.0 = Debug|arm64 + {230BF08C-C039-473B-933F-3BF647440E0E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {230BF08C-C039-473B-933F-3BF647440E0E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {230BF08C-C039-473B-933F-3BF647440E0E}.Debug|x86.ActiveCfg = Debug|Any CPU + {230BF08C-C039-473B-933F-3BF647440E0E}.Debug|x86.Build.0 = Debug|Any CPU {230BF08C-C039-473B-933F-3BF647440E0E}.Release|x64.ActiveCfg = Release|x64 - {230BF08C-C039-473B-933F-3BF647440E0E}.Release|arm64.ActiveCfg = Release|arm64 {230BF08C-C039-473B-933F-3BF647440E0E}.Release|x64.Build.0 = Release|x64 + {230BF08C-C039-473B-933F-3BF647440E0E}.Release|arm64.ActiveCfg = Release|arm64 {230BF08C-C039-473B-933F-3BF647440E0E}.Release|arm64.Build.0 = Release|arm64 + {230BF08C-C039-473B-933F-3BF647440E0E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {230BF08C-C039-473B-933F-3BF647440E0E}.Release|Any CPU.Build.0 = Release|Any CPU + {230BF08C-C039-473B-933F-3BF647440E0E}.Release|x86.ActiveCfg = Release|Any CPU + {230BF08C-C039-473B-933F-3BF647440E0E}.Release|x86.Build.0 = Release|Any CPU {C55F4BA7-BBDD-42A4-88C1-FD3C411EB234}.Debug|x64.ActiveCfg = Debug|x64 - {C55F4BA7-BBDD-42A4-88C1-FD3C411EB234}.Debug|arm64.ActiveCfg = Debug|arm64 {C55F4BA7-BBDD-42A4-88C1-FD3C411EB234}.Debug|x64.Build.0 = Debug|x64 + {C55F4BA7-BBDD-42A4-88C1-FD3C411EB234}.Debug|arm64.ActiveCfg = Debug|arm64 {C55F4BA7-BBDD-42A4-88C1-FD3C411EB234}.Debug|arm64.Build.0 = Debug|arm64 + {C55F4BA7-BBDD-42A4-88C1-FD3C411EB234}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C55F4BA7-BBDD-42A4-88C1-FD3C411EB234}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C55F4BA7-BBDD-42A4-88C1-FD3C411EB234}.Debug|x86.ActiveCfg = Debug|Any CPU + {C55F4BA7-BBDD-42A4-88C1-FD3C411EB234}.Debug|x86.Build.0 = Debug|Any CPU {C55F4BA7-BBDD-42A4-88C1-FD3C411EB234}.Release|x64.ActiveCfg = Release|x64 - {C55F4BA7-BBDD-42A4-88C1-FD3C411EB234}.Release|arm64.ActiveCfg = Release|arm64 {C55F4BA7-BBDD-42A4-88C1-FD3C411EB234}.Release|x64.Build.0 = Release|x64 + {C55F4BA7-BBDD-42A4-88C1-FD3C411EB234}.Release|arm64.ActiveCfg = Release|arm64 {C55F4BA7-BBDD-42A4-88C1-FD3C411EB234}.Release|arm64.Build.0 = Release|arm64 + {C55F4BA7-BBDD-42A4-88C1-FD3C411EB234}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C55F4BA7-BBDD-42A4-88C1-FD3C411EB234}.Release|Any CPU.Build.0 = Release|Any CPU + {C55F4BA7-BBDD-42A4-88C1-FD3C411EB234}.Release|x86.ActiveCfg = Release|Any CPU + {C55F4BA7-BBDD-42A4-88C1-FD3C411EB234}.Release|x86.Build.0 = Release|Any CPU {2979E556-5859-4E88-A1D4-EAB72F82294E}.Debug|x64.ActiveCfg = Debug|x64 - {2979E556-5859-4E88-A1D4-EAB72F82294E}.Debug|arm64.ActiveCfg = Debug|arm64 {2979E556-5859-4E88-A1D4-EAB72F82294E}.Debug|x64.Build.0 = Debug|x64 + {2979E556-5859-4E88-A1D4-EAB72F82294E}.Debug|arm64.ActiveCfg = Debug|arm64 {2979E556-5859-4E88-A1D4-EAB72F82294E}.Debug|arm64.Build.0 = Debug|arm64 + {2979E556-5859-4E88-A1D4-EAB72F82294E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2979E556-5859-4E88-A1D4-EAB72F82294E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2979E556-5859-4E88-A1D4-EAB72F82294E}.Debug|x86.ActiveCfg = Debug|Any CPU + {2979E556-5859-4E88-A1D4-EAB72F82294E}.Debug|x86.Build.0 = Debug|Any CPU {2979E556-5859-4E88-A1D4-EAB72F82294E}.Release|x64.ActiveCfg = Release|x64 - {2979E556-5859-4E88-A1D4-EAB72F82294E}.Release|arm64.ActiveCfg = Release|arm64 {2979E556-5859-4E88-A1D4-EAB72F82294E}.Release|x64.Build.0 = Release|x64 + {2979E556-5859-4E88-A1D4-EAB72F82294E}.Release|arm64.ActiveCfg = Release|arm64 {2979E556-5859-4E88-A1D4-EAB72F82294E}.Release|arm64.Build.0 = Release|arm64 + {2979E556-5859-4E88-A1D4-EAB72F82294E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2979E556-5859-4E88-A1D4-EAB72F82294E}.Release|Any CPU.Build.0 = Release|Any CPU + {2979E556-5859-4E88-A1D4-EAB72F82294E}.Release|x86.ActiveCfg = Release|Any CPU + {2979E556-5859-4E88-A1D4-EAB72F82294E}.Release|x86.Build.0 = Release|Any CPU {7E098666-DE8C-4ABF-B709-4CE7B1A491B0}.Debug|x64.ActiveCfg = Debug|x64 - {7E098666-DE8C-4ABF-B709-4CE7B1A491B0}.Debug|arm64.ActiveCfg = Debug|arm64 {7E098666-DE8C-4ABF-B709-4CE7B1A491B0}.Debug|x64.Build.0 = Debug|x64 + {7E098666-DE8C-4ABF-B709-4CE7B1A491B0}.Debug|arm64.ActiveCfg = Debug|arm64 {7E098666-DE8C-4ABF-B709-4CE7B1A491B0}.Debug|arm64.Build.0 = Debug|arm64 + {7E098666-DE8C-4ABF-B709-4CE7B1A491B0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7E098666-DE8C-4ABF-B709-4CE7B1A491B0}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7E098666-DE8C-4ABF-B709-4CE7B1A491B0}.Debug|x86.ActiveCfg = Debug|Any CPU + {7E098666-DE8C-4ABF-B709-4CE7B1A491B0}.Debug|x86.Build.0 = Debug|Any CPU {7E098666-DE8C-4ABF-B709-4CE7B1A491B0}.Release|x64.ActiveCfg = Release|x64 - {7E098666-DE8C-4ABF-B709-4CE7B1A491B0}.Release|arm64.ActiveCfg = Release|arm64 {7E098666-DE8C-4ABF-B709-4CE7B1A491B0}.Release|x64.Build.0 = Release|x64 + {7E098666-DE8C-4ABF-B709-4CE7B1A491B0}.Release|arm64.ActiveCfg = Release|arm64 {7E098666-DE8C-4ABF-B709-4CE7B1A491B0}.Release|arm64.Build.0 = Release|arm64 + {7E098666-DE8C-4ABF-B709-4CE7B1A491B0}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7E098666-DE8C-4ABF-B709-4CE7B1A491B0}.Release|Any CPU.Build.0 = Release|Any CPU + {7E098666-DE8C-4ABF-B709-4CE7B1A491B0}.Release|x86.ActiveCfg = Release|Any CPU + {7E098666-DE8C-4ABF-B709-4CE7B1A491B0}.Release|x86.Build.0 = Release|Any CPU {D47CC16E-466B-4D58-A8FC-ECAE5C9606FC}.Debug|x64.ActiveCfg = Debug|x64 - {D47CC16E-466B-4D58-A8FC-ECAE5C9606FC}.Debug|arm64.ActiveCfg = Debug|arm64 {D47CC16E-466B-4D58-A8FC-ECAE5C9606FC}.Debug|x64.Build.0 = Debug|x64 + {D47CC16E-466B-4D58-A8FC-ECAE5C9606FC}.Debug|arm64.ActiveCfg = Debug|arm64 {D47CC16E-466B-4D58-A8FC-ECAE5C9606FC}.Debug|arm64.Build.0 = Debug|arm64 + {D47CC16E-466B-4D58-A8FC-ECAE5C9606FC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D47CC16E-466B-4D58-A8FC-ECAE5C9606FC}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D47CC16E-466B-4D58-A8FC-ECAE5C9606FC}.Debug|x86.ActiveCfg = Debug|Any CPU + {D47CC16E-466B-4D58-A8FC-ECAE5C9606FC}.Debug|x86.Build.0 = Debug|Any CPU {D47CC16E-466B-4D58-A8FC-ECAE5C9606FC}.Release|x64.ActiveCfg = Release|x64 - {D47CC16E-466B-4D58-A8FC-ECAE5C9606FC}.Release|arm64.ActiveCfg = Release|arm64 {D47CC16E-466B-4D58-A8FC-ECAE5C9606FC}.Release|x64.Build.0 = Release|x64 + {D47CC16E-466B-4D58-A8FC-ECAE5C9606FC}.Release|arm64.ActiveCfg = Release|arm64 {D47CC16E-466B-4D58-A8FC-ECAE5C9606FC}.Release|arm64.Build.0 = Release|arm64 + {D47CC16E-466B-4D58-A8FC-ECAE5C9606FC}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D47CC16E-466B-4D58-A8FC-ECAE5C9606FC}.Release|Any CPU.Build.0 = Release|Any CPU + {D47CC16E-466B-4D58-A8FC-ECAE5C9606FC}.Release|x86.ActiveCfg = Release|Any CPU + {D47CC16E-466B-4D58-A8FC-ECAE5C9606FC}.Release|x86.Build.0 = Release|Any CPU {1143176D-B7F0-477C-90BB-72289068D927}.Debug|x64.ActiveCfg = Debug|x64 - {1143176D-B7F0-477C-90BB-72289068D927}.Debug|arm64.ActiveCfg = Debug|arm64 {1143176D-B7F0-477C-90BB-72289068D927}.Debug|x64.Build.0 = Debug|x64 + {1143176D-B7F0-477C-90BB-72289068D927}.Debug|arm64.ActiveCfg = Debug|arm64 {1143176D-B7F0-477C-90BB-72289068D927}.Debug|arm64.Build.0 = Debug|arm64 + {1143176D-B7F0-477C-90BB-72289068D927}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1143176D-B7F0-477C-90BB-72289068D927}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1143176D-B7F0-477C-90BB-72289068D927}.Debug|x86.ActiveCfg = Debug|Any CPU + {1143176D-B7F0-477C-90BB-72289068D927}.Debug|x86.Build.0 = Debug|Any CPU {1143176D-B7F0-477C-90BB-72289068D927}.Release|x64.ActiveCfg = Release|x64 - {1143176D-B7F0-477C-90BB-72289068D927}.Release|arm64.ActiveCfg = Release|arm64 {1143176D-B7F0-477C-90BB-72289068D927}.Release|x64.Build.0 = Release|x64 + {1143176D-B7F0-477C-90BB-72289068D927}.Release|arm64.ActiveCfg = Release|arm64 {1143176D-B7F0-477C-90BB-72289068D927}.Release|arm64.Build.0 = Release|arm64 + {1143176D-B7F0-477C-90BB-72289068D927}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1143176D-B7F0-477C-90BB-72289068D927}.Release|Any CPU.Build.0 = Release|Any CPU + {1143176D-B7F0-477C-90BB-72289068D927}.Release|x86.ActiveCfg = Release|Any CPU + {1143176D-B7F0-477C-90BB-72289068D927}.Release|x86.Build.0 = Release|Any CPU + {A8B3C2D1-5E4F-4A7B-9C0D-E1F2A3B4C5D6}.Debug|x64.ActiveCfg = Debug|x64 + {A8B3C2D1-5E4F-4A7B-9C0D-E1F2A3B4C5D6}.Debug|x64.Build.0 = Debug|x64 + {A8B3C2D1-5E4F-4A7B-9C0D-E1F2A3B4C5D6}.Debug|arm64.ActiveCfg = Debug|arm64 + {A8B3C2D1-5E4F-4A7B-9C0D-E1F2A3B4C5D6}.Debug|arm64.Build.0 = Debug|arm64 + {A8B3C2D1-5E4F-4A7B-9C0D-E1F2A3B4C5D6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A8B3C2D1-5E4F-4A7B-9C0D-E1F2A3B4C5D6}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A8B3C2D1-5E4F-4A7B-9C0D-E1F2A3B4C5D6}.Debug|x86.ActiveCfg = Debug|Any CPU + {A8B3C2D1-5E4F-4A7B-9C0D-E1F2A3B4C5D6}.Debug|x86.Build.0 = Debug|Any CPU + {A8B3C2D1-5E4F-4A7B-9C0D-E1F2A3B4C5D6}.Release|x64.ActiveCfg = Release|x64 + {A8B3C2D1-5E4F-4A7B-9C0D-E1F2A3B4C5D6}.Release|x64.Build.0 = Release|x64 + {A8B3C2D1-5E4F-4A7B-9C0D-E1F2A3B4C5D6}.Release|arm64.ActiveCfg = Release|arm64 + {A8B3C2D1-5E4F-4A7B-9C0D-E1F2A3B4C5D6}.Release|arm64.Build.0 = Release|arm64 + {A8B3C2D1-5E4F-4A7B-9C0D-E1F2A3B4C5D6}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A8B3C2D1-5E4F-4A7B-9C0D-E1F2A3B4C5D6}.Release|Any CPU.Build.0 = Release|Any CPU + {A8B3C2D1-5E4F-4A7B-9C0D-E1F2A3B4C5D6}.Release|x86.ActiveCfg = Release|Any CPU + {A8B3C2D1-5E4F-4A7B-9C0D-E1F2A3B4C5D6}.Release|x86.Build.0 = Release|Any CPU {E454D3A5-C5C6-4291-BE96-220CF0D5CFFD}.Debug|x64.ActiveCfg = Debug|x64 - {E454D3A5-C5C6-4291-BE96-220CF0D5CFFD}.Debug|arm64.ActiveCfg = Debug|arm64 {E454D3A5-C5C6-4291-BE96-220CF0D5CFFD}.Debug|x64.Build.0 = Debug|x64 + {E454D3A5-C5C6-4291-BE96-220CF0D5CFFD}.Debug|arm64.ActiveCfg = Debug|arm64 {E454D3A5-C5C6-4291-BE96-220CF0D5CFFD}.Debug|arm64.Build.0 = Debug|arm64 + {E454D3A5-C5C6-4291-BE96-220CF0D5CFFD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E454D3A5-C5C6-4291-BE96-220CF0D5CFFD}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E454D3A5-C5C6-4291-BE96-220CF0D5CFFD}.Debug|x86.ActiveCfg = Debug|Any CPU + {E454D3A5-C5C6-4291-BE96-220CF0D5CFFD}.Debug|x86.Build.0 = Debug|Any CPU {E454D3A5-C5C6-4291-BE96-220CF0D5CFFD}.Release|x64.ActiveCfg = Release|x64 - {E454D3A5-C5C6-4291-BE96-220CF0D5CFFD}.Release|arm64.ActiveCfg = Release|arm64 {E454D3A5-C5C6-4291-BE96-220CF0D5CFFD}.Release|x64.Build.0 = Release|x64 + {E454D3A5-C5C6-4291-BE96-220CF0D5CFFD}.Release|arm64.ActiveCfg = Release|arm64 {E454D3A5-C5C6-4291-BE96-220CF0D5CFFD}.Release|arm64.Build.0 = Release|arm64 + {E454D3A5-C5C6-4291-BE96-220CF0D5CFFD}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E454D3A5-C5C6-4291-BE96-220CF0D5CFFD}.Release|Any CPU.Build.0 = Release|Any CPU + {E454D3A5-C5C6-4291-BE96-220CF0D5CFFD}.Release|x86.ActiveCfg = Release|Any CPU + {E454D3A5-C5C6-4291-BE96-220CF0D5CFFD}.Release|x86.Build.0 = Release|Any CPU {57D094C1-6913-46BF-A657-84A5F46D4EE7}.Debug|x64.ActiveCfg = Debug|x64 - {57D094C1-6913-46BF-A657-84A5F46D4EE7}.Debug|arm64.ActiveCfg = Debug|arm64 {57D094C1-6913-46BF-A657-84A5F46D4EE7}.Debug|x64.Build.0 = Debug|x64 + {57D094C1-6913-46BF-A657-84A5F46D4EE7}.Debug|arm64.ActiveCfg = Debug|arm64 {57D094C1-6913-46BF-A657-84A5F46D4EE7}.Debug|arm64.Build.0 = Debug|arm64 + {57D094C1-6913-46BF-A657-84A5F46D4EE7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {57D094C1-6913-46BF-A657-84A5F46D4EE7}.Debug|Any CPU.Build.0 = Debug|Any CPU + {57D094C1-6913-46BF-A657-84A5F46D4EE7}.Debug|x86.ActiveCfg = Debug|Any CPU + {57D094C1-6913-46BF-A657-84A5F46D4EE7}.Debug|x86.Build.0 = Debug|Any CPU {57D094C1-6913-46BF-A657-84A5F46D4EE7}.Release|x64.ActiveCfg = Release|x64 - {57D094C1-6913-46BF-A657-84A5F46D4EE7}.Release|arm64.ActiveCfg = Release|arm64 {57D094C1-6913-46BF-A657-84A5F46D4EE7}.Release|x64.Build.0 = Release|x64 + {57D094C1-6913-46BF-A657-84A5F46D4EE7}.Release|arm64.ActiveCfg = Release|arm64 {57D094C1-6913-46BF-A657-84A5F46D4EE7}.Release|arm64.Build.0 = Release|arm64 + {57D094C1-6913-46BF-A657-84A5F46D4EE7}.Release|Any CPU.ActiveCfg = Release|Any CPU + {57D094C1-6913-46BF-A657-84A5F46D4EE7}.Release|Any CPU.Build.0 = Release|Any CPU + {57D094C1-6913-46BF-A657-84A5F46D4EE7}.Release|x86.ActiveCfg = Release|Any CPU + {57D094C1-6913-46BF-A657-84A5F46D4EE7}.Release|x86.Build.0 = Release|Any CPU {740E2894-903D-4B94-9C32-B630593BEB16}.Debug|x64.ActiveCfg = Debug|x64 - {740E2894-903D-4B94-9C32-B630593BEB16}.Debug|arm64.ActiveCfg = Debug|arm64 {740E2894-903D-4B94-9C32-B630593BEB16}.Debug|x64.Build.0 = Debug|x64 + {740E2894-903D-4B94-9C32-B630593BEB16}.Debug|arm64.ActiveCfg = Debug|arm64 {740E2894-903D-4B94-9C32-B630593BEB16}.Debug|arm64.Build.0 = Debug|arm64 + {740E2894-903D-4B94-9C32-B630593BEB16}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {740E2894-903D-4B94-9C32-B630593BEB16}.Debug|Any CPU.Build.0 = Debug|Any CPU + {740E2894-903D-4B94-9C32-B630593BEB16}.Debug|x86.ActiveCfg = Debug|Any CPU + {740E2894-903D-4B94-9C32-B630593BEB16}.Debug|x86.Build.0 = Debug|Any CPU {740E2894-903D-4B94-9C32-B630593BEB16}.Release|x64.ActiveCfg = Release|x64 - {740E2894-903D-4B94-9C32-B630593BEB16}.Release|arm64.ActiveCfg = Release|arm64 {740E2894-903D-4B94-9C32-B630593BEB16}.Release|x64.Build.0 = Release|x64 + {740E2894-903D-4B94-9C32-B630593BEB16}.Release|arm64.ActiveCfg = Release|arm64 {740E2894-903D-4B94-9C32-B630593BEB16}.Release|arm64.Build.0 = Release|arm64 + {740E2894-903D-4B94-9C32-B630593BEB16}.Release|Any CPU.ActiveCfg = Release|Any CPU + {740E2894-903D-4B94-9C32-B630593BEB16}.Release|Any CPU.Build.0 = Release|Any CPU + {740E2894-903D-4B94-9C32-B630593BEB16}.Release|x86.ActiveCfg = Release|Any CPU + {740E2894-903D-4B94-9C32-B630593BEB16}.Release|x86.Build.0 = Release|Any CPU {D401F706-A182-46E3-A25C-B0BF5AA0D07E}.Debug|x64.ActiveCfg = Debug|x64 - {D401F706-A182-46E3-A25C-B0BF5AA0D07E}.Debug|arm64.ActiveCfg = Debug|arm64 {D401F706-A182-46E3-A25C-B0BF5AA0D07E}.Debug|x64.Build.0 = Debug|x64 + {D401F706-A182-46E3-A25C-B0BF5AA0D07E}.Debug|arm64.ActiveCfg = Debug|arm64 {D401F706-A182-46E3-A25C-B0BF5AA0D07E}.Debug|arm64.Build.0 = Debug|arm64 + {D401F706-A182-46E3-A25C-B0BF5AA0D07E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D401F706-A182-46E3-A25C-B0BF5AA0D07E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D401F706-A182-46E3-A25C-B0BF5AA0D07E}.Debug|x86.ActiveCfg = Debug|Any CPU + {D401F706-A182-46E3-A25C-B0BF5AA0D07E}.Debug|x86.Build.0 = Debug|Any CPU {D401F706-A182-46E3-A25C-B0BF5AA0D07E}.Release|x64.ActiveCfg = Release|x64 - {D401F706-A182-46E3-A25C-B0BF5AA0D07E}.Release|arm64.ActiveCfg = Release|arm64 {D401F706-A182-46E3-A25C-B0BF5AA0D07E}.Release|x64.Build.0 = Release|x64 + {D401F706-A182-46E3-A25C-B0BF5AA0D07E}.Release|arm64.ActiveCfg = Release|arm64 {D401F706-A182-46E3-A25C-B0BF5AA0D07E}.Release|arm64.Build.0 = Release|arm64 + {D401F706-A182-46E3-A25C-B0BF5AA0D07E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D401F706-A182-46E3-A25C-B0BF5AA0D07E}.Release|Any CPU.Build.0 = Release|Any CPU + {D401F706-A182-46E3-A25C-B0BF5AA0D07E}.Release|x86.ActiveCfg = Release|Any CPU + {D401F706-A182-46E3-A25C-B0BF5AA0D07E}.Release|x86.Build.0 = Release|Any CPU {0FFA3F96-A68A-453F-A5FE-0C281EC371C7}.Debug|x64.ActiveCfg = Debug|x64 - {0FFA3F96-A68A-453F-A5FE-0C281EC371C7}.Debug|arm64.ActiveCfg = Debug|arm64 {0FFA3F96-A68A-453F-A5FE-0C281EC371C7}.Debug|x64.Build.0 = Debug|x64 + {0FFA3F96-A68A-453F-A5FE-0C281EC371C7}.Debug|arm64.ActiveCfg = Debug|arm64 {0FFA3F96-A68A-453F-A5FE-0C281EC371C7}.Debug|arm64.Build.0 = Debug|arm64 + {0FFA3F96-A68A-453F-A5FE-0C281EC371C7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {0FFA3F96-A68A-453F-A5FE-0C281EC371C7}.Debug|Any CPU.Build.0 = Debug|Any CPU + {0FFA3F96-A68A-453F-A5FE-0C281EC371C7}.Debug|x86.ActiveCfg = Debug|Any CPU + {0FFA3F96-A68A-453F-A5FE-0C281EC371C7}.Debug|x86.Build.0 = Debug|Any CPU {0FFA3F96-A68A-453F-A5FE-0C281EC371C7}.Release|x64.ActiveCfg = Release|x64 - {0FFA3F96-A68A-453F-A5FE-0C281EC371C7}.Release|arm64.ActiveCfg = Release|arm64 {0FFA3F96-A68A-453F-A5FE-0C281EC371C7}.Release|x64.Build.0 = Release|x64 + {0FFA3F96-A68A-453F-A5FE-0C281EC371C7}.Release|arm64.ActiveCfg = Release|arm64 {0FFA3F96-A68A-453F-A5FE-0C281EC371C7}.Release|arm64.Build.0 = Release|arm64 + {0FFA3F96-A68A-453F-A5FE-0C281EC371C7}.Release|Any CPU.ActiveCfg = Release|Any CPU + {0FFA3F96-A68A-453F-A5FE-0C281EC371C7}.Release|Any CPU.Build.0 = Release|Any CPU + {0FFA3F96-A68A-453F-A5FE-0C281EC371C7}.Release|x86.ActiveCfg = Release|Any CPU + {0FFA3F96-A68A-453F-A5FE-0C281EC371C7}.Release|x86.Build.0 = Release|Any CPU {5FA79592-DE5B-46FF-9E05-34A2E72A7AF7}.Debug|x64.ActiveCfg = Debug|x64 - {5FA79592-DE5B-46FF-9E05-34A2E72A7AF7}.Debug|arm64.ActiveCfg = Debug|arm64 {5FA79592-DE5B-46FF-9E05-34A2E72A7AF7}.Debug|x64.Build.0 = Debug|x64 + {5FA79592-DE5B-46FF-9E05-34A2E72A7AF7}.Debug|arm64.ActiveCfg = Debug|arm64 {5FA79592-DE5B-46FF-9E05-34A2E72A7AF7}.Debug|arm64.Build.0 = Debug|arm64 + {5FA79592-DE5B-46FF-9E05-34A2E72A7AF7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5FA79592-DE5B-46FF-9E05-34A2E72A7AF7}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5FA79592-DE5B-46FF-9E05-34A2E72A7AF7}.Debug|x86.ActiveCfg = Debug|Any CPU + {5FA79592-DE5B-46FF-9E05-34A2E72A7AF7}.Debug|x86.Build.0 = Debug|Any CPU {5FA79592-DE5B-46FF-9E05-34A2E72A7AF7}.Release|x64.ActiveCfg = Release|x64 - {5FA79592-DE5B-46FF-9E05-34A2E72A7AF7}.Release|arm64.ActiveCfg = Release|arm64 {5FA79592-DE5B-46FF-9E05-34A2E72A7AF7}.Release|x64.Build.0 = Release|x64 + {5FA79592-DE5B-46FF-9E05-34A2E72A7AF7}.Release|arm64.ActiveCfg = Release|arm64 {5FA79592-DE5B-46FF-9E05-34A2E72A7AF7}.Release|arm64.Build.0 = Release|arm64 + {5FA79592-DE5B-46FF-9E05-34A2E72A7AF7}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5FA79592-DE5B-46FF-9E05-34A2E72A7AF7}.Release|Any CPU.Build.0 = Release|Any CPU + {5FA79592-DE5B-46FF-9E05-34A2E72A7AF7}.Release|x86.ActiveCfg = Release|Any CPU + {5FA79592-DE5B-46FF-9E05-34A2E72A7AF7}.Release|x86.Build.0 = Release|Any CPU {09FD3D3A-1EFC-4AEE-B3D7-096D238E0D1A}.Debug|x64.ActiveCfg = Debug|x64 - {09FD3D3A-1EFC-4AEE-B3D7-096D238E0D1A}.Debug|arm64.ActiveCfg = Debug|arm64 {09FD3D3A-1EFC-4AEE-B3D7-096D238E0D1A}.Debug|x64.Build.0 = Debug|x64 + {09FD3D3A-1EFC-4AEE-B3D7-096D238E0D1A}.Debug|arm64.ActiveCfg = Debug|arm64 {09FD3D3A-1EFC-4AEE-B3D7-096D238E0D1A}.Debug|arm64.Build.0 = Debug|arm64 + {09FD3D3A-1EFC-4AEE-B3D7-096D238E0D1A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {09FD3D3A-1EFC-4AEE-B3D7-096D238E0D1A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {09FD3D3A-1EFC-4AEE-B3D7-096D238E0D1A}.Debug|x86.ActiveCfg = Debug|Any CPU + {09FD3D3A-1EFC-4AEE-B3D7-096D238E0D1A}.Debug|x86.Build.0 = Debug|Any CPU {09FD3D3A-1EFC-4AEE-B3D7-096D238E0D1A}.Release|x64.ActiveCfg = Release|x64 - {09FD3D3A-1EFC-4AEE-B3D7-096D238E0D1A}.Release|arm64.ActiveCfg = Release|arm64 {09FD3D3A-1EFC-4AEE-B3D7-096D238E0D1A}.Release|x64.Build.0 = Release|x64 + {09FD3D3A-1EFC-4AEE-B3D7-096D238E0D1A}.Release|arm64.ActiveCfg = Release|arm64 {09FD3D3A-1EFC-4AEE-B3D7-096D238E0D1A}.Release|arm64.Build.0 = Release|arm64 + {09FD3D3A-1EFC-4AEE-B3D7-096D238E0D1A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {09FD3D3A-1EFC-4AEE-B3D7-096D238E0D1A}.Release|Any CPU.Build.0 = Release|Any CPU + {09FD3D3A-1EFC-4AEE-B3D7-096D238E0D1A}.Release|x86.ActiveCfg = Release|Any CPU + {09FD3D3A-1EFC-4AEE-B3D7-096D238E0D1A}.Release|x86.Build.0 = Release|Any CPU {BDB7A8F3-87A6-4B77-9E0F-6BC785CBCF2B}.Debug|x64.ActiveCfg = Debug|x64 - {BDB7A8F3-87A6-4B77-9E0F-6BC785CBCF2B}.Debug|arm64.ActiveCfg = Debug|arm64 {BDB7A8F3-87A6-4B77-9E0F-6BC785CBCF2B}.Debug|x64.Build.0 = Debug|x64 + {BDB7A8F3-87A6-4B77-9E0F-6BC785CBCF2B}.Debug|arm64.ActiveCfg = Debug|arm64 {BDB7A8F3-87A6-4B77-9E0F-6BC785CBCF2B}.Debug|arm64.Build.0 = Debug|arm64 + {BDB7A8F3-87A6-4B77-9E0F-6BC785CBCF2B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {BDB7A8F3-87A6-4B77-9E0F-6BC785CBCF2B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {BDB7A8F3-87A6-4B77-9E0F-6BC785CBCF2B}.Debug|x86.ActiveCfg = Debug|Any CPU + {BDB7A8F3-87A6-4B77-9E0F-6BC785CBCF2B}.Debug|x86.Build.0 = Debug|Any CPU {BDB7A8F3-87A6-4B77-9E0F-6BC785CBCF2B}.Release|x64.ActiveCfg = Release|x64 - {BDB7A8F3-87A6-4B77-9E0F-6BC785CBCF2B}.Release|arm64.ActiveCfg = Release|arm64 {BDB7A8F3-87A6-4B77-9E0F-6BC785CBCF2B}.Release|x64.Build.0 = Release|x64 + {BDB7A8F3-87A6-4B77-9E0F-6BC785CBCF2B}.Release|arm64.ActiveCfg = Release|arm64 {BDB7A8F3-87A6-4B77-9E0F-6BC785CBCF2B}.Release|arm64.Build.0 = Release|arm64 + {BDB7A8F3-87A6-4B77-9E0F-6BC785CBCF2B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {BDB7A8F3-87A6-4B77-9E0F-6BC785CBCF2B}.Release|Any CPU.Build.0 = Release|Any CPU + {BDB7A8F3-87A6-4B77-9E0F-6BC785CBCF2B}.Release|x86.ActiveCfg = Release|Any CPU + {BDB7A8F3-87A6-4B77-9E0F-6BC785CBCF2B}.Release|x86.Build.0 = Release|Any CPU {27E0B288-7DFF-468D-9360-035E8CE123CB}.Debug|x64.ActiveCfg = Debug|x64 - {27E0B288-7DFF-468D-9360-035E8CE123CB}.Debug|arm64.ActiveCfg = Debug|arm64 {27E0B288-7DFF-468D-9360-035E8CE123CB}.Debug|x64.Build.0 = Debug|x64 + {27E0B288-7DFF-468D-9360-035E8CE123CB}.Debug|arm64.ActiveCfg = Debug|arm64 {27E0B288-7DFF-468D-9360-035E8CE123CB}.Debug|arm64.Build.0 = Debug|arm64 + {27E0B288-7DFF-468D-9360-035E8CE123CB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {27E0B288-7DFF-468D-9360-035E8CE123CB}.Debug|Any CPU.Build.0 = Debug|Any CPU + {27E0B288-7DFF-468D-9360-035E8CE123CB}.Debug|x86.ActiveCfg = Debug|Any CPU + {27E0B288-7DFF-468D-9360-035E8CE123CB}.Debug|x86.Build.0 = Debug|Any CPU {27E0B288-7DFF-468D-9360-035E8CE123CB}.Release|x64.ActiveCfg = Release|x64 - {27E0B288-7DFF-468D-9360-035E8CE123CB}.Release|arm64.ActiveCfg = Release|arm64 {27E0B288-7DFF-468D-9360-035E8CE123CB}.Release|x64.Build.0 = Release|x64 + {27E0B288-7DFF-468D-9360-035E8CE123CB}.Release|arm64.ActiveCfg = Release|arm64 {27E0B288-7DFF-468D-9360-035E8CE123CB}.Release|arm64.Build.0 = Release|arm64 + {27E0B288-7DFF-468D-9360-035E8CE123CB}.Release|Any CPU.ActiveCfg = Release|Any CPU + {27E0B288-7DFF-468D-9360-035E8CE123CB}.Release|Any CPU.Build.0 = Release|Any CPU + {27E0B288-7DFF-468D-9360-035E8CE123CB}.Release|x86.ActiveCfg = Release|Any CPU + {27E0B288-7DFF-468D-9360-035E8CE123CB}.Release|x86.Build.0 = Release|Any CPU {20F43119-EA00-440D-B3B5-44F96592F4C8}.Debug|x64.ActiveCfg = Debug|x64 - {20F43119-EA00-440D-B3B5-44F96592F4C8}.Debug|arm64.ActiveCfg = Debug|arm64 {20F43119-EA00-440D-B3B5-44F96592F4C8}.Debug|x64.Build.0 = Debug|x64 + {20F43119-EA00-440D-B3B5-44F96592F4C8}.Debug|arm64.ActiveCfg = Debug|arm64 {20F43119-EA00-440D-B3B5-44F96592F4C8}.Debug|arm64.Build.0 = Debug|arm64 + {20F43119-EA00-440D-B3B5-44F96592F4C8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {20F43119-EA00-440D-B3B5-44F96592F4C8}.Debug|Any CPU.Build.0 = Debug|Any CPU + {20F43119-EA00-440D-B3B5-44F96592F4C8}.Debug|x86.ActiveCfg = Debug|Any CPU + {20F43119-EA00-440D-B3B5-44F96592F4C8}.Debug|x86.Build.0 = Debug|Any CPU {20F43119-EA00-440D-B3B5-44F96592F4C8}.Release|x64.ActiveCfg = Release|x64 - {20F43119-EA00-440D-B3B5-44F96592F4C8}.Release|arm64.ActiveCfg = Release|arm64 {20F43119-EA00-440D-B3B5-44F96592F4C8}.Release|x64.Build.0 = Release|x64 + {20F43119-EA00-440D-B3B5-44F96592F4C8}.Release|arm64.ActiveCfg = Release|arm64 {20F43119-EA00-440D-B3B5-44F96592F4C8}.Release|arm64.Build.0 = Release|arm64 + {20F43119-EA00-440D-B3B5-44F96592F4C8}.Release|Any CPU.ActiveCfg = Release|Any CPU + {20F43119-EA00-440D-B3B5-44F96592F4C8}.Release|Any CPU.Build.0 = Release|Any CPU + {20F43119-EA00-440D-B3B5-44F96592F4C8}.Release|x86.ActiveCfg = Release|Any CPU + {20F43119-EA00-440D-B3B5-44F96592F4C8}.Release|x86.Build.0 = Release|Any CPU {CC400751-216E-4BB2-8103-495B4E273477}.Debug|x64.ActiveCfg = Debug|x64 - {CC400751-216E-4BB2-8103-495B4E273477}.Debug|arm64.ActiveCfg = Debug|arm64 {CC400751-216E-4BB2-8103-495B4E273477}.Debug|x64.Build.0 = Debug|x64 + {CC400751-216E-4BB2-8103-495B4E273477}.Debug|arm64.ActiveCfg = Debug|arm64 {CC400751-216E-4BB2-8103-495B4E273477}.Debug|arm64.Build.0 = Debug|arm64 + {CC400751-216E-4BB2-8103-495B4E273477}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {CC400751-216E-4BB2-8103-495B4E273477}.Debug|Any CPU.Build.0 = Debug|Any CPU + {CC400751-216E-4BB2-8103-495B4E273477}.Debug|x86.ActiveCfg = Debug|Any CPU + {CC400751-216E-4BB2-8103-495B4E273477}.Debug|x86.Build.0 = Debug|Any CPU {CC400751-216E-4BB2-8103-495B4E273477}.Release|x64.ActiveCfg = Release|x64 - {CC400751-216E-4BB2-8103-495B4E273477}.Release|arm64.ActiveCfg = Release|arm64 {CC400751-216E-4BB2-8103-495B4E273477}.Release|x64.Build.0 = Release|x64 + {CC400751-216E-4BB2-8103-495B4E273477}.Release|arm64.ActiveCfg = Release|arm64 {CC400751-216E-4BB2-8103-495B4E273477}.Release|arm64.Build.0 = Release|arm64 + {CC400751-216E-4BB2-8103-495B4E273477}.Release|Any CPU.ActiveCfg = Release|Any CPU + {CC400751-216E-4BB2-8103-495B4E273477}.Release|Any CPU.Build.0 = Release|Any CPU + {CC400751-216E-4BB2-8103-495B4E273477}.Release|x86.ActiveCfg = Release|Any CPU + {CC400751-216E-4BB2-8103-495B4E273477}.Release|x86.Build.0 = Release|Any CPU {F4E7301D-9C8A-4A4A-92D2-35B896642BF6}.Debug|x64.ActiveCfg = Debug|x64 - {F4E7301D-9C8A-4A4A-92D2-35B896642BF6}.Debug|arm64.ActiveCfg = Debug|arm64 {F4E7301D-9C8A-4A4A-92D2-35B896642BF6}.Debug|x64.Build.0 = Debug|x64 + {F4E7301D-9C8A-4A4A-92D2-35B896642BF6}.Debug|arm64.ActiveCfg = Debug|arm64 {F4E7301D-9C8A-4A4A-92D2-35B896642BF6}.Debug|arm64.Build.0 = Debug|arm64 + {F4E7301D-9C8A-4A4A-92D2-35B896642BF6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F4E7301D-9C8A-4A4A-92D2-35B896642BF6}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F4E7301D-9C8A-4A4A-92D2-35B896642BF6}.Debug|x86.ActiveCfg = Debug|Any CPU + {F4E7301D-9C8A-4A4A-92D2-35B896642BF6}.Debug|x86.Build.0 = Debug|Any CPU {F4E7301D-9C8A-4A4A-92D2-35B896642BF6}.Release|x64.ActiveCfg = Release|x64 - {F4E7301D-9C8A-4A4A-92D2-35B896642BF6}.Release|arm64.ActiveCfg = Release|arm64 {F4E7301D-9C8A-4A4A-92D2-35B896642BF6}.Release|x64.Build.0 = Release|x64 + {F4E7301D-9C8A-4A4A-92D2-35B896642BF6}.Release|arm64.ActiveCfg = Release|arm64 {F4E7301D-9C8A-4A4A-92D2-35B896642BF6}.Release|arm64.Build.0 = Release|arm64 + {F4E7301D-9C8A-4A4A-92D2-35B896642BF6}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F4E7301D-9C8A-4A4A-92D2-35B896642BF6}.Release|Any CPU.Build.0 = Release|Any CPU + {F4E7301D-9C8A-4A4A-92D2-35B896642BF6}.Release|x86.ActiveCfg = Release|Any CPU + {F4E7301D-9C8A-4A4A-92D2-35B896642BF6}.Release|x86.Build.0 = Release|Any CPU {29450002-5F93-4886-922E-30350C9C3442}.Debug|x64.ActiveCfg = Debug|x64 - {29450002-5F93-4886-922E-30350C9C3442}.Debug|arm64.ActiveCfg = Debug|arm64 {29450002-5F93-4886-922E-30350C9C3442}.Debug|x64.Build.0 = Debug|x64 + {29450002-5F93-4886-922E-30350C9C3442}.Debug|arm64.ActiveCfg = Debug|arm64 {29450002-5F93-4886-922E-30350C9C3442}.Debug|arm64.Build.0 = Debug|arm64 + {29450002-5F93-4886-922E-30350C9C3442}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {29450002-5F93-4886-922E-30350C9C3442}.Debug|Any CPU.Build.0 = Debug|Any CPU + {29450002-5F93-4886-922E-30350C9C3442}.Debug|x86.ActiveCfg = Debug|Any CPU + {29450002-5F93-4886-922E-30350C9C3442}.Debug|x86.Build.0 = Debug|Any CPU {29450002-5F93-4886-922E-30350C9C3442}.Release|x64.ActiveCfg = Release|x64 - {29450002-5F93-4886-922E-30350C9C3442}.Release|arm64.ActiveCfg = Release|arm64 {29450002-5F93-4886-922E-30350C9C3442}.Release|x64.Build.0 = Release|x64 + {29450002-5F93-4886-922E-30350C9C3442}.Release|arm64.ActiveCfg = Release|arm64 {29450002-5F93-4886-922E-30350C9C3442}.Release|arm64.Build.0 = Release|arm64 + {29450002-5F93-4886-922E-30350C9C3442}.Release|Any CPU.ActiveCfg = Release|Any CPU + {29450002-5F93-4886-922E-30350C9C3442}.Release|Any CPU.Build.0 = Release|Any CPU + {29450002-5F93-4886-922E-30350C9C3442}.Release|x86.ActiveCfg = Release|Any CPU + {29450002-5F93-4886-922E-30350C9C3442}.Release|x86.Build.0 = Release|Any CPU {C396E5F6-C6D9-465D-9903-7E33D0841E6A}.Debug|x64.ActiveCfg = Debug|x64 - {C396E5F6-C6D9-465D-9903-7E33D0841E6A}.Debug|arm64.ActiveCfg = Debug|arm64 {C396E5F6-C6D9-465D-9903-7E33D0841E6A}.Debug|x64.Build.0 = Debug|x64 + {C396E5F6-C6D9-465D-9903-7E33D0841E6A}.Debug|arm64.ActiveCfg = Debug|arm64 {C396E5F6-C6D9-465D-9903-7E33D0841E6A}.Debug|arm64.Build.0 = Debug|arm64 + {C396E5F6-C6D9-465D-9903-7E33D0841E6A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C396E5F6-C6D9-465D-9903-7E33D0841E6A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C396E5F6-C6D9-465D-9903-7E33D0841E6A}.Debug|x86.ActiveCfg = Debug|Any CPU + {C396E5F6-C6D9-465D-9903-7E33D0841E6A}.Debug|x86.Build.0 = Debug|Any CPU {C396E5F6-C6D9-465D-9903-7E33D0841E6A}.Release|x64.ActiveCfg = Release|x64 - {C396E5F6-C6D9-465D-9903-7E33D0841E6A}.Release|arm64.ActiveCfg = Release|arm64 {C396E5F6-C6D9-465D-9903-7E33D0841E6A}.Release|x64.Build.0 = Release|x64 + {C396E5F6-C6D9-465D-9903-7E33D0841E6A}.Release|arm64.ActiveCfg = Release|arm64 {C396E5F6-C6D9-465D-9903-7E33D0841E6A}.Release|arm64.Build.0 = Release|arm64 + {C396E5F6-C6D9-465D-9903-7E33D0841E6A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C396E5F6-C6D9-465D-9903-7E33D0841E6A}.Release|Any CPU.Build.0 = Release|Any CPU + {C396E5F6-C6D9-465D-9903-7E33D0841E6A}.Release|x86.ActiveCfg = Release|Any CPU + {C396E5F6-C6D9-465D-9903-7E33D0841E6A}.Release|x86.Build.0 = Release|Any CPU {54DA0549-366F-4E70-B5D1-0B8891D0A2A5}.Debug|x64.ActiveCfg = Debug|x64 - {54DA0549-366F-4E70-B5D1-0B8891D0A2A5}.Debug|arm64.ActiveCfg = Debug|arm64 {54DA0549-366F-4E70-B5D1-0B8891D0A2A5}.Debug|x64.Build.0 = Debug|x64 + {54DA0549-366F-4E70-B5D1-0B8891D0A2A5}.Debug|arm64.ActiveCfg = Debug|arm64 {54DA0549-366F-4E70-B5D1-0B8891D0A2A5}.Debug|arm64.Build.0 = Debug|arm64 + {54DA0549-366F-4E70-B5D1-0B8891D0A2A5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {54DA0549-366F-4E70-B5D1-0B8891D0A2A5}.Debug|Any CPU.Build.0 = Debug|Any CPU + {54DA0549-366F-4E70-B5D1-0B8891D0A2A5}.Debug|x86.ActiveCfg = Debug|Any CPU + {54DA0549-366F-4E70-B5D1-0B8891D0A2A5}.Debug|x86.Build.0 = Debug|Any CPU {54DA0549-366F-4E70-B5D1-0B8891D0A2A5}.Release|x64.ActiveCfg = Release|x64 - {54DA0549-366F-4E70-B5D1-0B8891D0A2A5}.Release|arm64.ActiveCfg = Release|arm64 {54DA0549-366F-4E70-B5D1-0B8891D0A2A5}.Release|x64.Build.0 = Release|x64 + {54DA0549-366F-4E70-B5D1-0B8891D0A2A5}.Release|arm64.ActiveCfg = Release|arm64 {54DA0549-366F-4E70-B5D1-0B8891D0A2A5}.Release|arm64.Build.0 = Release|arm64 + {54DA0549-366F-4E70-B5D1-0B8891D0A2A5}.Release|Any CPU.ActiveCfg = Release|Any CPU + {54DA0549-366F-4E70-B5D1-0B8891D0A2A5}.Release|Any CPU.Build.0 = Release|Any CPU + {54DA0549-366F-4E70-B5D1-0B8891D0A2A5}.Release|x86.ActiveCfg = Release|Any CPU + {54DA0549-366F-4E70-B5D1-0B8891D0A2A5}.Release|x86.Build.0 = Release|Any CPU {E337A71E-3C30-4315-B8F1-57CBC5CF50A6}.Debug|x64.ActiveCfg = Debug|x64 - {E337A71E-3C30-4315-B8F1-57CBC5CF50A6}.Debug|arm64.ActiveCfg = Debug|arm64 {E337A71E-3C30-4315-B8F1-57CBC5CF50A6}.Debug|x64.Build.0 = Debug|x64 + {E337A71E-3C30-4315-B8F1-57CBC5CF50A6}.Debug|arm64.ActiveCfg = Debug|arm64 {E337A71E-3C30-4315-B8F1-57CBC5CF50A6}.Debug|arm64.Build.0 = Debug|arm64 + {E337A71E-3C30-4315-B8F1-57CBC5CF50A6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E337A71E-3C30-4315-B8F1-57CBC5CF50A6}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E337A71E-3C30-4315-B8F1-57CBC5CF50A6}.Debug|x86.ActiveCfg = Debug|Any CPU + {E337A71E-3C30-4315-B8F1-57CBC5CF50A6}.Debug|x86.Build.0 = Debug|Any CPU {E337A71E-3C30-4315-B8F1-57CBC5CF50A6}.Release|x64.ActiveCfg = Release|x64 - {E337A71E-3C30-4315-B8F1-57CBC5CF50A6}.Release|arm64.ActiveCfg = Release|arm64 {E337A71E-3C30-4315-B8F1-57CBC5CF50A6}.Release|x64.Build.0 = Release|x64 + {E337A71E-3C30-4315-B8F1-57CBC5CF50A6}.Release|arm64.ActiveCfg = Release|arm64 {E337A71E-3C30-4315-B8F1-57CBC5CF50A6}.Release|arm64.Build.0 = Release|arm64 + {E337A71E-3C30-4315-B8F1-57CBC5CF50A6}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E337A71E-3C30-4315-B8F1-57CBC5CF50A6}.Release|Any CPU.Build.0 = Release|Any CPU + {E337A71E-3C30-4315-B8F1-57CBC5CF50A6}.Release|x86.ActiveCfg = Release|Any CPU + {E337A71E-3C30-4315-B8F1-57CBC5CF50A6}.Release|x86.Build.0 = Release|Any CPU {727866B8-BBD5-43B9-933A-78199F65429C}.Debug|x64.ActiveCfg = Debug|x64 - {727866B8-BBD5-43B9-933A-78199F65429C}.Debug|arm64.ActiveCfg = Debug|arm64 {727866B8-BBD5-43B9-933A-78199F65429C}.Debug|x64.Build.0 = Debug|x64 + {727866B8-BBD5-43B9-933A-78199F65429C}.Debug|arm64.ActiveCfg = Debug|arm64 {727866B8-BBD5-43B9-933A-78199F65429C}.Debug|arm64.Build.0 = Debug|arm64 + {727866B8-BBD5-43B9-933A-78199F65429C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {727866B8-BBD5-43B9-933A-78199F65429C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {727866B8-BBD5-43B9-933A-78199F65429C}.Debug|x86.ActiveCfg = Debug|Any CPU + {727866B8-BBD5-43B9-933A-78199F65429C}.Debug|x86.Build.0 = Debug|Any CPU {727866B8-BBD5-43B9-933A-78199F65429C}.Release|x64.ActiveCfg = Release|x64 - {727866B8-BBD5-43B9-933A-78199F65429C}.Release|arm64.ActiveCfg = Release|arm64 {727866B8-BBD5-43B9-933A-78199F65429C}.Release|x64.Build.0 = Release|x64 + {727866B8-BBD5-43B9-933A-78199F65429C}.Release|arm64.ActiveCfg = Release|arm64 {727866B8-BBD5-43B9-933A-78199F65429C}.Release|arm64.Build.0 = Release|arm64 + {727866B8-BBD5-43B9-933A-78199F65429C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {727866B8-BBD5-43B9-933A-78199F65429C}.Release|Any CPU.Build.0 = Release|Any CPU + {727866B8-BBD5-43B9-933A-78199F65429C}.Release|x86.ActiveCfg = Release|Any CPU + {727866B8-BBD5-43B9-933A-78199F65429C}.Release|x86.Build.0 = Release|Any CPU {3C8BF564-B4B5-44A7-9D8C-102C2F820EAF}.Debug|x64.ActiveCfg = Debug|x64 - {3C8BF564-B4B5-44A7-9D8C-102C2F820EAF}.Debug|arm64.ActiveCfg = Debug|arm64 {3C8BF564-B4B5-44A7-9D8C-102C2F820EAF}.Debug|x64.Build.0 = Debug|x64 + {3C8BF564-B4B5-44A7-9D8C-102C2F820EAF}.Debug|arm64.ActiveCfg = Debug|arm64 {3C8BF564-B4B5-44A7-9D8C-102C2F820EAF}.Debug|arm64.Build.0 = Debug|arm64 + {3C8BF564-B4B5-44A7-9D8C-102C2F820EAF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3C8BF564-B4B5-44A7-9D8C-102C2F820EAF}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3C8BF564-B4B5-44A7-9D8C-102C2F820EAF}.Debug|x86.ActiveCfg = Debug|Any CPU + {3C8BF564-B4B5-44A7-9D8C-102C2F820EAF}.Debug|x86.Build.0 = Debug|Any CPU {3C8BF564-B4B5-44A7-9D8C-102C2F820EAF}.Release|x64.ActiveCfg = Release|x64 - {3C8BF564-B4B5-44A7-9D8C-102C2F820EAF}.Release|arm64.ActiveCfg = Release|arm64 {3C8BF564-B4B5-44A7-9D8C-102C2F820EAF}.Release|x64.Build.0 = Release|x64 + {3C8BF564-B4B5-44A7-9D8C-102C2F820EAF}.Release|arm64.ActiveCfg = Release|arm64 {3C8BF564-B4B5-44A7-9D8C-102C2F820EAF}.Release|arm64.Build.0 = Release|arm64 + {3C8BF564-B4B5-44A7-9D8C-102C2F820EAF}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3C8BF564-B4B5-44A7-9D8C-102C2F820EAF}.Release|Any CPU.Build.0 = Release|Any CPU + {3C8BF564-B4B5-44A7-9D8C-102C2F820EAF}.Release|x86.ActiveCfg = Release|Any CPU + {3C8BF564-B4B5-44A7-9D8C-102C2F820EAF}.Release|x86.Build.0 = Release|Any CPU {F1610A61-5444-4C11-9447-13CCA327887E}.Debug|x64.ActiveCfg = Debug|x64 - {F1610A61-5444-4C11-9447-13CCA327887E}.Debug|arm64.ActiveCfg = Debug|arm64 {F1610A61-5444-4C11-9447-13CCA327887E}.Debug|x64.Build.0 = Debug|x64 + {F1610A61-5444-4C11-9447-13CCA327887E}.Debug|arm64.ActiveCfg = Debug|arm64 {F1610A61-5444-4C11-9447-13CCA327887E}.Debug|arm64.Build.0 = Debug|arm64 + {F1610A61-5444-4C11-9447-13CCA327887E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F1610A61-5444-4C11-9447-13CCA327887E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F1610A61-5444-4C11-9447-13CCA327887E}.Debug|x86.ActiveCfg = Debug|Any CPU + {F1610A61-5444-4C11-9447-13CCA327887E}.Debug|x86.Build.0 = Debug|Any CPU {F1610A61-5444-4C11-9447-13CCA327887E}.Release|x64.ActiveCfg = Release|x64 - {F1610A61-5444-4C11-9447-13CCA327887E}.Release|arm64.ActiveCfg = Release|arm64 {F1610A61-5444-4C11-9447-13CCA327887E}.Release|x64.Build.0 = Release|x64 + {F1610A61-5444-4C11-9447-13CCA327887E}.Release|arm64.ActiveCfg = Release|arm64 {F1610A61-5444-4C11-9447-13CCA327887E}.Release|arm64.Build.0 = Release|arm64 + {F1610A61-5444-4C11-9447-13CCA327887E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F1610A61-5444-4C11-9447-13CCA327887E}.Release|Any CPU.Build.0 = Release|Any CPU + {F1610A61-5444-4C11-9447-13CCA327887E}.Release|x86.ActiveCfg = Release|Any CPU + {F1610A61-5444-4C11-9447-13CCA327887E}.Release|x86.Build.0 = Release|Any CPU {B0E59327-933E-4DB0-BD2D-FB16EB9B4194}.Debug|x64.ActiveCfg = Debug|x64 - {B0E59327-933E-4DB0-BD2D-FB16EB9B4194}.Debug|arm64.ActiveCfg = Debug|arm64 {B0E59327-933E-4DB0-BD2D-FB16EB9B4194}.Debug|x64.Build.0 = Debug|x64 + {B0E59327-933E-4DB0-BD2D-FB16EB9B4194}.Debug|arm64.ActiveCfg = Debug|arm64 {B0E59327-933E-4DB0-BD2D-FB16EB9B4194}.Debug|arm64.Build.0 = Debug|arm64 + {B0E59327-933E-4DB0-BD2D-FB16EB9B4194}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B0E59327-933E-4DB0-BD2D-FB16EB9B4194}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B0E59327-933E-4DB0-BD2D-FB16EB9B4194}.Debug|x86.ActiveCfg = Debug|Any CPU + {B0E59327-933E-4DB0-BD2D-FB16EB9B4194}.Debug|x86.Build.0 = Debug|Any CPU {B0E59327-933E-4DB0-BD2D-FB16EB9B4194}.Release|x64.ActiveCfg = Release|x64 - {B0E59327-933E-4DB0-BD2D-FB16EB9B4194}.Release|arm64.ActiveCfg = Release|arm64 {B0E59327-933E-4DB0-BD2D-FB16EB9B4194}.Release|x64.Build.0 = Release|x64 + {B0E59327-933E-4DB0-BD2D-FB16EB9B4194}.Release|arm64.ActiveCfg = Release|arm64 {B0E59327-933E-4DB0-BD2D-FB16EB9B4194}.Release|arm64.Build.0 = Release|arm64 - {A8B3C2D1-5E4F-4A7B-9C0D-E1F2A3B4C5D6}.Debug|x64.ActiveCfg = Debug|x64 - {A8B3C2D1-5E4F-4A7B-9C0D-E1F2A3B4C5D6}.Debug|arm64.ActiveCfg = Debug|arm64 - {A8B3C2D1-5E4F-4A7B-9C0D-E1F2A3B4C5D6}.Debug|x64.Build.0 = Debug|x64 - {A8B3C2D1-5E4F-4A7B-9C0D-E1F2A3B4C5D6}.Debug|arm64.Build.0 = Debug|arm64 - {A8B3C2D1-5E4F-4A7B-9C0D-E1F2A3B4C5D6}.Release|x64.ActiveCfg = Release|x64 - {A8B3C2D1-5E4F-4A7B-9C0D-E1F2A3B4C5D6}.Release|arm64.ActiveCfg = Release|arm64 - {A8B3C2D1-5E4F-4A7B-9C0D-E1F2A3B4C5D6}.Release|x64.Build.0 = Release|x64 - {A8B3C2D1-5E4F-4A7B-9C0D-E1F2A3B4C5D6}.Release|arm64.Build.0 = Release|arm64 + {B0E59327-933E-4DB0-BD2D-FB16EB9B4194}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B0E59327-933E-4DB0-BD2D-FB16EB9B4194}.Release|Any CPU.Build.0 = Release|Any CPU + {B0E59327-933E-4DB0-BD2D-FB16EB9B4194}.Release|x86.ActiveCfg = Release|Any CPU + {B0E59327-933E-4DB0-BD2D-FB16EB9B4194}.Release|x86.Build.0 = Release|Any CPU + {181E4491-B96D-4E33-8102-AC91420D6123}.Debug|x64.ActiveCfg = Debug|x64 + {181E4491-B96D-4E33-8102-AC91420D6123}.Debug|x64.Build.0 = Debug|x64 + {181E4491-B96D-4E33-8102-AC91420D6123}.Debug|arm64.ActiveCfg = Debug|arm64 + {181E4491-B96D-4E33-8102-AC91420D6123}.Debug|arm64.Build.0 = Debug|arm64 + {181E4491-B96D-4E33-8102-AC91420D6123}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {181E4491-B96D-4E33-8102-AC91420D6123}.Debug|Any CPU.Build.0 = Debug|Any CPU + {181E4491-B96D-4E33-8102-AC91420D6123}.Debug|x86.ActiveCfg = Debug|Any CPU + {181E4491-B96D-4E33-8102-AC91420D6123}.Debug|x86.Build.0 = Debug|Any CPU + {181E4491-B96D-4E33-8102-AC91420D6123}.Release|x64.ActiveCfg = Release|x64 + {181E4491-B96D-4E33-8102-AC91420D6123}.Release|x64.Build.0 = Release|x64 + {181E4491-B96D-4E33-8102-AC91420D6123}.Release|arm64.ActiveCfg = Release|arm64 + {181E4491-B96D-4E33-8102-AC91420D6123}.Release|arm64.Build.0 = Release|arm64 + {181E4491-B96D-4E33-8102-AC91420D6123}.Release|Any CPU.ActiveCfg = Release|Any CPU + {181E4491-B96D-4E33-8102-AC91420D6123}.Release|Any CPU.Build.0 = Release|Any CPU + {181E4491-B96D-4E33-8102-AC91420D6123}.Release|x86.ActiveCfg = Release|Any CPU + {181E4491-B96D-4E33-8102-AC91420D6123}.Release|x86.Build.0 = Release|Any CPU + {991DBBC6-3E13-4A95-BCDC-F99512F87DC3}.Debug|x64.ActiveCfg = Debug|x64 + {991DBBC6-3E13-4A95-BCDC-F99512F87DC3}.Debug|x64.Build.0 = Debug|x64 + {991DBBC6-3E13-4A95-BCDC-F99512F87DC3}.Debug|arm64.ActiveCfg = Debug|arm64 + {991DBBC6-3E13-4A95-BCDC-F99512F87DC3}.Debug|arm64.Build.0 = Debug|arm64 + {991DBBC6-3E13-4A95-BCDC-F99512F87DC3}.Debug|Any CPU.ActiveCfg = Debug|x64 + {991DBBC6-3E13-4A95-BCDC-F99512F87DC3}.Debug|Any CPU.Build.0 = Debug|x64 + {991DBBC6-3E13-4A95-BCDC-F99512F87DC3}.Debug|x86.ActiveCfg = Debug|x64 + {991DBBC6-3E13-4A95-BCDC-F99512F87DC3}.Debug|x86.Build.0 = Debug|x64 + {991DBBC6-3E13-4A95-BCDC-F99512F87DC3}.Release|x64.ActiveCfg = Release|x64 + {991DBBC6-3E13-4A95-BCDC-F99512F87DC3}.Release|x64.Build.0 = Release|x64 + {991DBBC6-3E13-4A95-BCDC-F99512F87DC3}.Release|arm64.ActiveCfg = Release|arm64 + {991DBBC6-3E13-4A95-BCDC-F99512F87DC3}.Release|arm64.Build.0 = Release|arm64 + {991DBBC6-3E13-4A95-BCDC-F99512F87DC3}.Release|Any CPU.ActiveCfg = Release|x64 + {991DBBC6-3E13-4A95-BCDC-F99512F87DC3}.Release|Any CPU.Build.0 = Release|x64 + {991DBBC6-3E13-4A95-BCDC-F99512F87DC3}.Release|x86.ActiveCfg = Release|x64 + {991DBBC6-3E13-4A95-BCDC-F99512F87DC3}.Release|x86.Build.0 = Release|x64 + {8CDDF549-38D0-45DB-9494-3B9B3376E7C0}.Debug|x64.ActiveCfg = Debug|x64 + {8CDDF549-38D0-45DB-9494-3B9B3376E7C0}.Debug|x64.Build.0 = Debug|x64 + {8CDDF549-38D0-45DB-9494-3B9B3376E7C0}.Debug|arm64.ActiveCfg = Debug|arm64 + {8CDDF549-38D0-45DB-9494-3B9B3376E7C0}.Debug|arm64.Build.0 = Debug|arm64 + {8CDDF549-38D0-45DB-9494-3B9B3376E7C0}.Debug|Any CPU.ActiveCfg = Debug|x64 + {8CDDF549-38D0-45DB-9494-3B9B3376E7C0}.Debug|Any CPU.Build.0 = Debug|x64 + {8CDDF549-38D0-45DB-9494-3B9B3376E7C0}.Debug|x86.ActiveCfg = Debug|x64 + {8CDDF549-38D0-45DB-9494-3B9B3376E7C0}.Debug|x86.Build.0 = Debug|x64 + {8CDDF549-38D0-45DB-9494-3B9B3376E7C0}.Release|x64.ActiveCfg = Release|x64 + {8CDDF549-38D0-45DB-9494-3B9B3376E7C0}.Release|x64.Build.0 = Release|x64 + {8CDDF549-38D0-45DB-9494-3B9B3376E7C0}.Release|arm64.ActiveCfg = Release|arm64 + {8CDDF549-38D0-45DB-9494-3B9B3376E7C0}.Release|arm64.Build.0 = Release|arm64 + {8CDDF549-38D0-45DB-9494-3B9B3376E7C0}.Release|Any CPU.ActiveCfg = Release|x64 + {8CDDF549-38D0-45DB-9494-3B9B3376E7C0}.Release|Any CPU.Build.0 = Release|x64 + {8CDDF549-38D0-45DB-9494-3B9B3376E7C0}.Release|x86.ActiveCfg = Release|x64 + {8CDDF549-38D0-45DB-9494-3B9B3376E7C0}.Release|x86.Build.0 = Release|x64 EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -480,6 +876,7 @@ Global {95168D0B-1B2C-4295-B6D4-D5BAF781B9FA} = {7940E867-EEBA-4AFD-9904-1536F003239C} {D47CC16E-466B-4D58-A8FC-ECAE5C9606FC} = {95168D0B-1B2C-4295-B6D4-D5BAF781B9FA} {1143176D-B7F0-477C-90BB-72289068D927} = {95168D0B-1B2C-4295-B6D4-D5BAF781B9FA} + {A8B3C2D1-5E4F-4A7B-9C0D-E1F2A3B4C5D6} = {7940E867-EEBA-4AFD-9904-1536F003239C} {E454D3A5-C5C6-4291-BE96-220CF0D5CFFD} = {95168D0B-1B2C-4295-B6D4-D5BAF781B9FA} {57D094C1-6913-46BF-A657-84A5F46D4EE7} = {95168D0B-1B2C-4295-B6D4-D5BAF781B9FA} {740E2894-903D-4B94-9C32-B630593BEB16} = {95168D0B-1B2C-4295-B6D4-D5BAF781B9FA} @@ -502,7 +899,7 @@ Global {015B44EE-32AE-4105-9016-49140743CAF9} = {7940E867-EEBA-4AFD-9904-1536F003239C} {F1610A61-5444-4C11-9447-13CCA327887E} = {015B44EE-32AE-4105-9016-49140743CAF9} {B0E59327-933E-4DB0-BD2D-FB16EB9B4194} = {E05D1183-D360-4AFE-8968-314A34FAD3B2} - {A8B3C2D1-5E4F-4A7B-9C0D-E1F2A3B4C5D6} = {7940E867-EEBA-4AFD-9904-1536F003239C} + {181E4491-B96D-4E33-8102-AC91420D6123} = {015B44EE-32AE-4105-9016-49140743CAF9} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {D044BB14-0B37-47E5-A579-8B30FCBA1F9F} diff --git a/src/UniGetUI/AutoUpdater.Helpers.cs b/src/UniGetUI/AutoUpdater.Helpers.cs new file mode 100644 index 0000000000..8ec91a0b90 --- /dev/null +++ b/src/UniGetUI/AutoUpdater.Helpers.cs @@ -0,0 +1,280 @@ +using System.Runtime.InteropServices; +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.Win32; +using UniGetUI.Core.Logging; +using UniGetUI.Core.Tools; + +namespace UniGetUI; + +public partial class AutoUpdater +{ + private const string REGISTRY_PATH = @"Software\Devolutions\UniGetUI"; + private const string DEFAULT_PRODUCTINFO_URL = "https://devolutions.net/productinfo.json"; + private const string DEFAULT_PRODUCTINFO_KEY = "Devolutions.UniGetUI"; + + private const string REG_PRODUCTINFO_URL = "UpdaterProductInfoUrl"; + private const string REG_PRODUCTINFO_KEY = "UpdaterProductKey"; + private const string REG_ALLOW_UNSAFE_URLS = "UpdaterAllowUnsafeUrls"; + private const string REG_SKIP_HASH_VALIDATION = "UpdaterSkipHashValidation"; + private const string REG_SKIP_SIGNER_THUMBPRINT_CHECK = "UpdaterSkipSignerThumbprintCheck"; + private const string REG_DISABLE_TLS_VALIDATION = "UpdaterDisableTlsValidation"; + +#if !DEBUG + private static readonly string[] RELEASE_IGNORED_REGISTRY_VALUES = + [ + REG_PRODUCTINFO_KEY, + REG_ALLOW_UNSAFE_URLS, + REG_SKIP_HASH_VALIDATION, + REG_SKIP_SIGNER_THUMBPRINT_CHECK, + REG_DISABLE_TLS_VALIDATION, + ]; +#endif + + private static HttpClientHandler CreateHttpClientHandler(UpdaterOverrides updaterOverrides) + { + HttpClientHandler handler = CoreTools.GenericHttpClientParameters; + if (updaterOverrides.DisableTlsValidation) + { + Logger.Warn( + "Registry override enabled: TLS certificate validation is disabled for updater requests." + ); + handler.ServerCertificateCustomValidationCallback = static (_, _, _, _) => true; + } + + return handler; + } + + internal static bool IsSourceUrlAllowed(string url, bool allowUnsafeUrls) + { + if (!Uri.TryCreate(url, UriKind.Absolute, out Uri? uri)) + { + return false; + } + + if (allowUnsafeUrls) + { + Logger.Warn($"Registry override enabled: allowing potentially unsafe updater URL {url}"); + return true; + } + + if (!string.Equals(uri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + return uri.Host.EndsWith("devolutions.net", StringComparison.OrdinalIgnoreCase) + || uri.Host.Equals("github.com", StringComparison.OrdinalIgnoreCase) + || uri.Host.Equals("objects.githubusercontent.com", StringComparison.OrdinalIgnoreCase) + || uri.Host.Equals( + "release-assets.githubusercontent.com", + StringComparison.OrdinalIgnoreCase + ); + } + + internal static ProductInfoFile SelectInstallerFile(List files) + { + string targetArch = RuntimeInformation.ProcessArchitecture switch + { + Architecture.Arm64 => "arm64", + Architecture.X64 => "x64", + _ => "x64", + }; + + ProductInfoFile? match = files.FirstOrDefault(file => + file.Type.Equals("exe", StringComparison.OrdinalIgnoreCase) + && file.Arch.Equals(targetArch, StringComparison.OrdinalIgnoreCase) + ); + + match ??= files.FirstOrDefault(file => + file.Type.Equals("exe", StringComparison.OrdinalIgnoreCase) + && file.Arch.Equals("Any", StringComparison.OrdinalIgnoreCase) + ); + + match ??= files.FirstOrDefault(file => + file.Type.Equals("msi", StringComparison.OrdinalIgnoreCase) + && file.Arch.Equals(targetArch, StringComparison.OrdinalIgnoreCase) + ); + + match ??= files.FirstOrDefault(file => + file.Type.Equals("msi", StringComparison.OrdinalIgnoreCase) + && file.Arch.Equals("Any", StringComparison.OrdinalIgnoreCase) + ); + + if (match is null) + { + throw new KeyNotFoundException( + $"No compatible installer file found in productinfo for architecture '{targetArch}'" + ); + } + + return match; + } + + internal static Version ParseVersionOrFallback(string rawVersion, Version fallbackVersion) + { + if (Version.TryParse(rawVersion, out Version? parsed)) + { + return CoreTools.NormalizeVersionForComparison(parsed); + } + + string sanitized = rawVersion.Trim().TrimStart('v', 'V'); + if (Version.TryParse(sanitized, out parsed)) + { + return CoreTools.NormalizeVersionForComparison(parsed); + } + + Logger.Warn($"Could not parse version '{rawVersion}', using fallback '{fallbackVersion}'"); + return fallbackVersion; + } + + internal static string NormalizeThumbprint(string thumbprint) + { + char[] normalized = thumbprint.ToLowerInvariant().Where(char.IsAsciiHexDigit).ToArray(); + + return new string(normalized); + } + + private static UpdaterOverrides LoadUpdaterOverrides() + { + using RegistryKey? key = Registry.LocalMachine.OpenSubKey(REGISTRY_PATH); + +#if DEBUG + if (key is not null) + { + Logger.Info($"Updater registry overrides loaded from HKLM\\{REGISTRY_PATH}"); + } + + return new UpdaterOverrides( + GetRegistryString(key, REG_PRODUCTINFO_URL) ?? DEFAULT_PRODUCTINFO_URL, + GetRegistryString(key, REG_PRODUCTINFO_KEY) ?? DEFAULT_PRODUCTINFO_KEY, + GetRegistryBool(key, REG_ALLOW_UNSAFE_URLS), + GetRegistryBool(key, REG_SKIP_HASH_VALIDATION), + GetRegistryBool(key, REG_SKIP_SIGNER_THUMBPRINT_CHECK), + GetRegistryBool(key, REG_DISABLE_TLS_VALIDATION) + ); +#else + LogIgnoredReleaseOverrides(key); + string productInfoUrl = + GetRegistryString(key, REG_PRODUCTINFO_URL) ?? DEFAULT_PRODUCTINFO_URL; + + return new UpdaterOverrides( + productInfoUrl, + DEFAULT_PRODUCTINFO_KEY, + false, + false, + false, + false + ); +#endif + } + +#if !DEBUG + private static void LogIgnoredReleaseOverrides(RegistryKey? key) + { + if (key is null) + { + return; + } + + foreach (string valueName in RELEASE_IGNORED_REGISTRY_VALUES) + { + if (key.GetValue(valueName) is not null) + { + Logger.Warn( + $"Release build is ignoring updater registry value HKLM\\{REGISTRY_PATH}\\{valueName}." + ); + } + } + } +#endif + + internal static string? GetRegistryString(RegistryKey? key, string valueName) + { +#pragma warning disable CA1416 + object? value = key?.GetValue(valueName); +#pragma warning restore CA1416 + if (value is null) + { + return null; + } + + string? parsedValue = value.ToString(); + if (string.IsNullOrWhiteSpace(parsedValue)) + { + return null; + } + + return parsedValue.Trim(); + } + +#if DEBUG + internal static bool GetRegistryBool(RegistryKey? key, string valueName) + { +#pragma warning disable CA1416 + object? value = key?.GetValue(valueName); +#pragma warning restore CA1416 + if (value is null) + { + return false; + } + + if (value is int intValue) + { + return intValue != 0; + } + + if (value is long longValue) + { + return longValue != 0; + } + + string normalized = value.ToString()?.Trim() ?? ""; + return normalized.Equals("1", StringComparison.OrdinalIgnoreCase) + || normalized.Equals("true", StringComparison.OrdinalIgnoreCase) + || normalized.Equals("yes", StringComparison.OrdinalIgnoreCase) + || normalized.Equals("on", StringComparison.OrdinalIgnoreCase); + } +#endif + + private sealed record UpdateCandidate( + bool IsUpgradable, + string VersionName, + string InstallerHash, + string InstallerDownloadUrl, + string SourceName + ); + + private sealed record UpdaterOverrides( + string ProductInfoUrl, + string ProductInfoProductKey, + bool AllowUnsafeUrls, + bool SkipHashValidation, + bool SkipSignerThumbprintCheck, + bool DisableTlsValidation + ); + + private sealed class ProductInfoProduct + { + public ProductInfoChannel? Current { get; set; } + public ProductInfoChannel? Beta { get; set; } + } + + internal sealed class ProductInfoChannel + { + public string Version { get; set; } = string.Empty; + public List Files { get; set; } = []; + } + + internal sealed class ProductInfoFile + { + public string Arch { get; set; } = string.Empty; + public string Type { get; set; } = string.Empty; + public string Url { get; set; } = string.Empty; + public string Hash { get; set; } = string.Empty; + } + + [JsonSourceGenerationOptions(AllowTrailingCommas = true)] + [JsonSerializable(typeof(Dictionary))] + private sealed partial class AutoUpdaterJsonContext : JsonSerializerContext { } +} diff --git a/src/UniGetUI/AutoUpdater.cs b/src/UniGetUI/AutoUpdater.cs index c2204d4cea..f5b58d8430 100644 --- a/src/UniGetUI/AutoUpdater.cs +++ b/src/UniGetUI/AutoUpdater.cs @@ -1,13 +1,9 @@ using System.Diagnostics; using System.Globalization; -using System.Runtime.InteropServices; using System.Security.Cryptography; using System.Security.Cryptography.X509Certificates; -using System.Text.Json; -using System.Text.Json.Serialization; using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Controls; -using Microsoft.Win32; using Microsoft.Windows.AppNotifications; using Microsoft.Windows.AppNotifications.Builder; using UniGetUI.Core.Data; @@ -20,17 +16,6 @@ namespace UniGetUI; public partial class AutoUpdater { - private const string REGISTRY_PATH = @"Software\Devolutions\UniGetUI"; - private const string DEFAULT_PRODUCTINFO_URL = "https://devolutions.net/productinfo.json"; - private const string DEFAULT_PRODUCTINFO_KEY = "Devolutions.UniGetUI"; - - private const string REG_PRODUCTINFO_URL = "UpdaterProductInfoUrl"; - private const string REG_PRODUCTINFO_KEY = "UpdaterProductKey"; - private const string REG_ALLOW_UNSAFE_URLS = "UpdaterAllowUnsafeUrls"; - private const string REG_SKIP_HASH_VALIDATION = "UpdaterSkipHashValidation"; - private const string REG_SKIP_SIGNER_THUMBPRINT_CHECK = "UpdaterSkipSignerThumbprintCheck"; - private const string REG_DISABLE_TLS_VALIDATION = "UpdaterDisableTlsValidation"; - private static readonly string[] DEVOLUTIONS_CERT_THUMBPRINTS = [ "3f5202a9432d54293bdfe6f7e46adb0a6f8b3ba6", @@ -38,17 +23,6 @@ public partial class AutoUpdater "50f753333811ff11f1920274afde3ffd4468b210", ]; -#if !DEBUG - private static readonly string[] RELEASE_IGNORED_REGISTRY_VALUES = - [ - REG_PRODUCTINFO_KEY, - REG_ALLOW_UNSAFE_URLS, - REG_SKIP_HASH_VALIDATION, - REG_SKIP_SIGNER_THUMBPRINT_CHECK, - REG_DISABLE_TLS_VALIDATION, - ]; -#endif - private static readonly AutoUpdaterJsonContext ProductInfoJsonContext = new( new JsonSerializerOptions(SerializationHelpers.DefaultOptions) ); @@ -594,252 +568,4 @@ private static void ShowMessage_ThreadSafe( } } - private static HttpClientHandler CreateHttpClientHandler(UpdaterOverrides updaterOverrides) - { - HttpClientHandler handler = CoreTools.GenericHttpClientParameters; - if (updaterOverrides.DisableTlsValidation) - { - Logger.Warn( - "Registry override enabled: TLS certificate validation is disabled for updater requests." - ); - handler.ServerCertificateCustomValidationCallback = static (_, _, _, _) => true; - } - - return handler; - } - - private static bool IsSourceUrlAllowed(string url, bool allowUnsafeUrls) - { - if (!Uri.TryCreate(url, UriKind.Absolute, out Uri? uri)) - { - return false; - } - - if (allowUnsafeUrls) - { - Logger.Warn( - $"Registry override enabled: allowing potentially unsafe updater URL {url}" - ); - return true; - } - - if (!string.Equals(uri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase)) - { - return false; - } - - return uri.Host.EndsWith("devolutions.net", StringComparison.OrdinalIgnoreCase) - || uri.Host.Equals("github.com", StringComparison.OrdinalIgnoreCase) - || uri.Host.Equals("objects.githubusercontent.com", StringComparison.OrdinalIgnoreCase) - || uri.Host.Equals( - "release-assets.githubusercontent.com", - StringComparison.OrdinalIgnoreCase - ); - } - - private static ProductInfoFile SelectInstallerFile(List files) - { - string targetArch = RuntimeInformation.ProcessArchitecture switch - { - Architecture.Arm64 => "arm64", - Architecture.X64 => "x64", - _ => "x64", - }; - - ProductInfoFile? match = files.FirstOrDefault(file => - file.Type.Equals("exe", StringComparison.OrdinalIgnoreCase) - && file.Arch.Equals(targetArch, StringComparison.OrdinalIgnoreCase) - ); - - match ??= files.FirstOrDefault(file => - file.Type.Equals("exe", StringComparison.OrdinalIgnoreCase) - && file.Arch.Equals("Any", StringComparison.OrdinalIgnoreCase) - ); - - match ??= files.FirstOrDefault(file => - file.Type.Equals("msi", StringComparison.OrdinalIgnoreCase) - && file.Arch.Equals(targetArch, StringComparison.OrdinalIgnoreCase) - ); - - match ??= files.FirstOrDefault(file => - file.Type.Equals("msi", StringComparison.OrdinalIgnoreCase) - && file.Arch.Equals("Any", StringComparison.OrdinalIgnoreCase) - ); - - if (match is null) - { - throw new KeyNotFoundException( - $"No compatible installer file found in productinfo for architecture '{targetArch}'" - ); - } - - return match; - } - - private static Version ParseVersionOrFallback(string rawVersion, Version fallbackVersion) - { - if (Version.TryParse(rawVersion, out Version? parsed)) - { - return CoreTools.NormalizeVersionForComparison(parsed); - } - - string sanitized = rawVersion.Trim().TrimStart('v', 'V'); - if (Version.TryParse(sanitized, out parsed)) - { - return CoreTools.NormalizeVersionForComparison(parsed); - } - - Logger.Warn($"Could not parse version '{rawVersion}', using fallback '{fallbackVersion}'"); - return fallbackVersion; - } - - private static string NormalizeThumbprint(string thumbprint) - { - char[] normalized = thumbprint.ToLowerInvariant().Where(char.IsAsciiHexDigit).ToArray(); - - return new string(normalized); - } - - private static UpdaterOverrides LoadUpdaterOverrides() - { - using RegistryKey? key = Registry.LocalMachine.OpenSubKey(REGISTRY_PATH); - -#if DEBUG - if (key is not null) - { - Logger.Info($"Updater registry overrides loaded from HKLM\\{REGISTRY_PATH}"); - } - - return new UpdaterOverrides( - GetRegistryString(key, REG_PRODUCTINFO_URL) ?? DEFAULT_PRODUCTINFO_URL, - GetRegistryString(key, REG_PRODUCTINFO_KEY) ?? DEFAULT_PRODUCTINFO_KEY, - GetRegistryBool(key, REG_ALLOW_UNSAFE_URLS), - GetRegistryBool(key, REG_SKIP_HASH_VALIDATION), - GetRegistryBool(key, REG_SKIP_SIGNER_THUMBPRINT_CHECK), - GetRegistryBool(key, REG_DISABLE_TLS_VALIDATION) - ); -#else - LogIgnoredReleaseOverrides(key); - string productInfoUrl = - GetRegistryString(key, REG_PRODUCTINFO_URL) ?? DEFAULT_PRODUCTINFO_URL; - - return new UpdaterOverrides( - productInfoUrl, - DEFAULT_PRODUCTINFO_KEY, - false, - false, - false, - false - ); -#endif - } - -#if !DEBUG - private static void LogIgnoredReleaseOverrides(RegistryKey? key) - { - if (key is null) - { - return; - } - - foreach (string valueName in RELEASE_IGNORED_REGISTRY_VALUES) - { - if (key.GetValue(valueName) is not null) - { - Logger.Warn( - $"Release build is ignoring updater registry value HKLM\\{REGISTRY_PATH}\\{valueName}." - ); - } - } - } -#endif - - private static string? GetRegistryString(RegistryKey? key, string valueName) - { -#pragma warning disable CA1416 - object? value = key?.GetValue(valueName); -#pragma warning restore CA1416 - if (value is null) - { - return null; - } - - string? parsedValue = value.ToString(); - if (string.IsNullOrWhiteSpace(parsedValue)) - { - return null; - } - - return parsedValue.Trim(); - } - -#if DEBUG - private static bool GetRegistryBool(RegistryKey? key, string valueName) - { -#pragma warning disable CA1416 - object? value = key?.GetValue(valueName); -#pragma warning restore CA1416 - if (value is null) - { - return false; - } - - if (value is int intValue) - { - return intValue != 0; - } - - if (value is long longValue) - { - return longValue != 0; - } - - string normalized = value.ToString()?.Trim() ?? ""; - return normalized.Equals("1", StringComparison.OrdinalIgnoreCase) - || normalized.Equals("true", StringComparison.OrdinalIgnoreCase) - || normalized.Equals("yes", StringComparison.OrdinalIgnoreCase) - || normalized.Equals("on", StringComparison.OrdinalIgnoreCase); - } -#endif - - private sealed record UpdateCandidate( - bool IsUpgradable, - string VersionName, - string InstallerHash, - string InstallerDownloadUrl, - string SourceName - ); - - private sealed record UpdaterOverrides( - string ProductInfoUrl, - string ProductInfoProductKey, - bool AllowUnsafeUrls, - bool SkipHashValidation, - bool SkipSignerThumbprintCheck, - bool DisableTlsValidation - ); - - private sealed class ProductInfoProduct - { - public ProductInfoChannel? Current { get; set; } - public ProductInfoChannel? Beta { get; set; } - } - - private sealed class ProductInfoChannel - { - public string Version { get; set; } = string.Empty; - public List Files { get; set; } = []; - } - - private sealed class ProductInfoFile - { - public string Arch { get; set; } = string.Empty; - public string Type { get; set; } = string.Empty; - public string Url { get; set; } = string.Empty; - public string Hash { get; set; } = string.Empty; - } - - [JsonSourceGenerationOptions(AllowTrailingCommas = true)] - [JsonSerializable(typeof(Dictionary))] - private sealed partial class AutoUpdaterJsonContext : JsonSerializerContext { } } diff --git a/src/UniGetUI/CLIHandler.cs b/src/UniGetUI/CLIHandler.cs index 6a404add25..e600ab177c 100644 --- a/src/UniGetUI/CLIHandler.cs +++ b/src/UniGetUI/CLIHandler.cs @@ -45,16 +45,20 @@ public static int Help() public static int ImportSettings() { - var args = Environment.GetCommandLineArgs().ToList(); + return ImportSettings(Environment.GetCommandLineArgs()); + } - var filePos = args.IndexOf(IMPORT_SETTINGS); + internal static int ImportSettings(IReadOnlyList args) + { + var arguments = args.ToList(); + var filePos = arguments.IndexOf(IMPORT_SETTINGS); if (filePos < 0) return (int)HRESULT.STATUS_INVALID_PARAMETER; // The base paramater --import-settings was not found - if (filePos + 1 >= args.Count) + if (filePos + 1 >= arguments.Count) return (int)HRESULT.STATUS_INVALID_PARAMETER; // The file parameter does not exist (import settings requires "--import-settings file") - var file = args[filePos + 1].Trim('"').Trim('\''); + var file = arguments[filePos + 1].Trim('"').Trim('\''); if (!File.Exists(file)) return (int)HRESULT.STATUS_NO_SUCH_FILE; // The given file does not exist @@ -72,16 +76,20 @@ public static int ImportSettings() public static int ExportSettings() { - var args = Environment.GetCommandLineArgs().ToList(); + return ExportSettings(Environment.GetCommandLineArgs()); + } - var filePos = args.IndexOf(EXPORT_SETTINGS); + internal static int ExportSettings(IReadOnlyList args) + { + var arguments = args.ToList(); + var filePos = arguments.IndexOf(EXPORT_SETTINGS); if (filePos < 0) return (int)HRESULT.STATUS_INVALID_PARAMETER; // The base paramater --export-settings was not found - if (filePos + 1 >= args.Count) + if (filePos + 1 >= arguments.Count) return (int)HRESULT.STATUS_INVALID_PARAMETER; // The file parameter does not exist (export settings requires "--export-settings file") - var file = args[filePos + 1].Trim('"').Trim('\''); + var file = arguments[filePos + 1].Trim('"').Trim('\''); try { @@ -97,16 +105,20 @@ public static int ExportSettings() public static int EnableSetting() { - var args = Environment.GetCommandLineArgs().ToList(); + return EnableSetting(Environment.GetCommandLineArgs()); + } - var basePos = args.IndexOf(ENABLE_SETTING); + internal static int EnableSetting(IReadOnlyList args) + { + var arguments = args.ToList(); + var basePos = arguments.IndexOf(ENABLE_SETTING); if (basePos < 0) return (int)HRESULT.STATUS_INVALID_PARAMETER; // The base paramater --export-settings was not found - if (basePos + 1 >= args.Count) + if (basePos + 1 >= arguments.Count) return (int)HRESULT.STATUS_INVALID_PARAMETER; // The file parameter does not exist (export settings requires "--export-settings file") - var setting = args[basePos + 1].Trim('"').Trim('\''); + var setting = arguments[basePos + 1].Trim('"').Trim('\''); if (!Enum.TryParse(setting, out Settings.K validKey)) return (int)HRESULT.STATUS_UNKNOWN__SETTINGS_KEY; @@ -124,16 +136,20 @@ public static int EnableSetting() public static int DisableSetting() { - var args = Environment.GetCommandLineArgs().ToList(); + return DisableSetting(Environment.GetCommandLineArgs()); + } - var basePos = args.IndexOf(DISABLE_SETTING); + internal static int DisableSetting(IReadOnlyList args) + { + var arguments = args.ToList(); + var basePos = arguments.IndexOf(DISABLE_SETTING); if (basePos < 0) return (int)HRESULT.STATUS_INVALID_PARAMETER; // The base paramater --export-settings was not found - if (basePos + 1 >= args.Count) + if (basePos + 1 >= arguments.Count) return (int)HRESULT.STATUS_INVALID_PARAMETER; // The file parameter does not exist (export settings requires "--export-settings file") - var setting = args[basePos + 1].Trim('"').Trim('\''); + var setting = arguments[basePos + 1].Trim('"').Trim('\''); if (!Enum.TryParse(setting, out Settings.K validKey)) return (int)HRESULT.STATUS_UNKNOWN__SETTINGS_KEY; try @@ -150,17 +166,21 @@ public static int DisableSetting() public static int SetSettingsValue() { - var args = Environment.GetCommandLineArgs().ToList(); + return SetSettingsValue(Environment.GetCommandLineArgs()); + } - var basePos = args.IndexOf(SET_SETTING_VAL); + internal static int SetSettingsValue(IReadOnlyList args) + { + var arguments = args.ToList(); + var basePos = arguments.IndexOf(SET_SETTING_VAL); if (basePos < 0) return (int)HRESULT.STATUS_INVALID_PARAMETER; // The base paramater --export-settings was not found - if (basePos + 2 >= args.Count) + if (basePos + 2 >= arguments.Count) return (int)HRESULT.STATUS_INVALID_PARAMETER; // The file parameter does not exist (export settings requires "--export-settings file") - var setting = args[basePos + 1].Trim('"').Trim('\''); - var value = args[basePos + 2]; + var setting = arguments[basePos + 1].Trim('"').Trim('\''); + var value = arguments[basePos + 2]; if (!Enum.TryParse(setting, out Settings.K validKey)) return (int)HRESULT.STATUS_UNKNOWN__SETTINGS_KEY; @@ -255,16 +275,20 @@ public static int UninstallUniGetUI() public static int EnableSecureSetting() { - var args = Environment.GetCommandLineArgs().ToList(); + return EnableSecureSetting(Environment.GetCommandLineArgs()); + } - var basePos = args.IndexOf(ENABLE_SECURE_SETTING); + internal static int EnableSecureSetting(IReadOnlyList args) + { + var arguments = args.ToList(); + var basePos = arguments.IndexOf(ENABLE_SECURE_SETTING); if (basePos < 0) return (int)HRESULT.STATUS_INVALID_PARAMETER; // The base paramater was not found - if (basePos + 1 >= args.Count) + if (basePos + 1 >= arguments.Count) return (int)HRESULT.STATUS_INVALID_PARAMETER; // The file parameter does not exist (export settings requires "--export-settings file") - var setting = args[basePos + 1].Trim('"').Trim('\''); + var setting = arguments[basePos + 1].Trim('"').Trim('\''); if (!Enum.TryParse(setting, out SecureSettings.K validKey)) return (int)HRESULT.STATUS_UNKNOWN__SETTINGS_KEY; @@ -284,16 +308,20 @@ public static int EnableSecureSetting() public static int DisableSecureSetting() { - var args = Environment.GetCommandLineArgs().ToList(); + return DisableSecureSetting(Environment.GetCommandLineArgs()); + } - var basePos = args.IndexOf(DISABLE_SECURE_SETTING); + internal static int DisableSecureSetting(IReadOnlyList args) + { + var arguments = args.ToList(); + var basePos = arguments.IndexOf(DISABLE_SECURE_SETTING); if (basePos < 0) return (int)HRESULT.STATUS_INVALID_PARAMETER; // The base paramater was not found - if (basePos + 1 >= args.Count) + if (basePos + 1 >= arguments.Count) return (int)HRESULT.STATUS_INVALID_PARAMETER; // The first positional argument does not exist - var setting = args[basePos + 1].Trim('"').Trim('\''); + var setting = arguments[basePos + 1].Trim('"').Trim('\''); if (!Enum.TryParse(setting, out SecureSettings.K validKey)) return (int)HRESULT.STATUS_UNKNOWN__SETTINGS_KEY; @@ -313,17 +341,21 @@ public static int DisableSecureSetting() public static int EnableSecureSettingForUser() { - var args = Environment.GetCommandLineArgs().ToList(); + return EnableSecureSettingForUser(Environment.GetCommandLineArgs()); + } - var basePos = args.IndexOf(ENABLE_SECURE_SETTING_FOR_USER); + internal static int EnableSecureSettingForUser(IReadOnlyList args) + { + var arguments = args.ToList(); + var basePos = arguments.IndexOf(ENABLE_SECURE_SETTING_FOR_USER); if (basePos < 0) return (int)HRESULT.STATUS_INVALID_PARAMETER; // The base paramater was not found - if (basePos + 2 >= args.Count) + if (basePos + 2 >= arguments.Count) return (int)HRESULT.STATUS_INVALID_PARAMETER; // The required parameters do not exist - var user = args[basePos + 1].Trim('"').Trim('\''); - var setting = args[basePos + 2].Trim('"').Trim('\''); + var user = arguments[basePos + 1].Trim('"').Trim('\''); + var setting = arguments[basePos + 2].Trim('"').Trim('\''); try { @@ -337,17 +369,21 @@ public static int EnableSecureSettingForUser() public static int DisableSecureSettingForUser() { - var args = Environment.GetCommandLineArgs().ToList(); + return DisableSecureSettingForUser(Environment.GetCommandLineArgs()); + } - var basePos = args.IndexOf(DISABLE_SECURE_SETTING_FOR_USER); + internal static int DisableSecureSettingForUser(IReadOnlyList args) + { + var arguments = args.ToList(); + var basePos = arguments.IndexOf(DISABLE_SECURE_SETTING_FOR_USER); if (basePos < 0) return (int)HRESULT.STATUS_INVALID_PARAMETER; // The base paramater was not found - if (basePos + 2 >= args.Count) + if (basePos + 2 >= arguments.Count) return (int)HRESULT.STATUS_INVALID_PARAMETER; // The required parameters do not exist - var user = args[basePos + 1].Trim('"').Trim('\''); - var setting = args[basePos + 2].Trim('"').Trim('\''); + var user = arguments[basePos + 1].Trim('"').Trim('\''); + var setting = arguments[basePos + 2].Trim('"').Trim('\''); try { diff --git a/src/UniGetUI/InternalsVisibleTo.cs b/src/UniGetUI/InternalsVisibleTo.cs new file mode 100644 index 0000000000..f5236ec50a --- /dev/null +++ b/src/UniGetUI/InternalsVisibleTo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("UniGetUI.Tests")] From 5692768703cc9f291b2967e8cc490b06b0e5678f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Moreau?= Date: Fri, 10 Apr 2026 10:01:15 -0400 Subject: [PATCH 2/2] Harden updater host allow-list Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/UniGetUI.Tests/AutoUpdaterTests.cs | 2 ++ src/UniGetUI/AutoUpdater.Helpers.cs | 3 ++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/src/UniGetUI.Tests/AutoUpdaterTests.cs b/src/UniGetUI.Tests/AutoUpdaterTests.cs index 56efe77213..6a4d3ca847 100644 --- a/src/UniGetUI.Tests/AutoUpdaterTests.cs +++ b/src/UniGetUI.Tests/AutoUpdaterTests.cs @@ -7,6 +7,8 @@ public sealed class AutoUpdaterTests { [Theory] [InlineData("https://devolutions.net/productinfo.json", false, true)] + [InlineData("https://updates.devolutions.net/productinfo.json", false, true)] + [InlineData("https://notdevolutions.net/productinfo.json", false, false)] [InlineData("https://github.com/Devolutions/UniGetUI/releases", false, true)] [InlineData("http://devolutions.net/productinfo.json", false, false)] [InlineData("http://contoso.invalid/file.exe", true, true)] diff --git a/src/UniGetUI/AutoUpdater.Helpers.cs b/src/UniGetUI/AutoUpdater.Helpers.cs index 8ec91a0b90..35715f0c38 100644 --- a/src/UniGetUI/AutoUpdater.Helpers.cs +++ b/src/UniGetUI/AutoUpdater.Helpers.cs @@ -63,7 +63,8 @@ internal static bool IsSourceUrlAllowed(string url, bool allowUnsafeUrls) return false; } - return uri.Host.EndsWith("devolutions.net", StringComparison.OrdinalIgnoreCase) + return uri.Host.Equals("devolutions.net", StringComparison.OrdinalIgnoreCase) + || uri.Host.EndsWith(".devolutions.net", StringComparison.OrdinalIgnoreCase) || uri.Host.Equals("github.com", StringComparison.OrdinalIgnoreCase) || uri.Host.Equals("objects.githubusercontent.com", StringComparison.OrdinalIgnoreCase) || uri.Host.Equals(