From aa32cdc3bef9dbe2ea0e7ab91abff55d6a09b9fd Mon Sep 17 00:00:00 2001 From: AmirMS <104940545+AmelBawa-msft@users.noreply.github.com> Date: Wed, 9 Sep 2026 16:02:31 -0700 Subject: [PATCH 1/3] Promote source priority to a feature (#6509) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 📖 Description Promotes source priority to a regular feature by removing experimental gating from CLI and DSC workflows and retiring the `experimentalFeatures.sourcePriority` setting. Uses the existing commands and options. Default priorities, selection rules, and tie handling are unchanged. Updates documentation, release notes, and regression coverage. REST filtering, interactive resolution, and heuristic deduplication remain out of scope for this PR. ## 🔗 References - Related to #5345 — source-priority workstream only. - Builds on #6029. ## 🔍 Validation - Built the CLI, native tests, and E2E test project. - Passed **43 native test cases with 792 assertions**, covering priority ordering, ties, negative values, argument boundaries, disabled experimental-feature policy, source output, and DSC source operations. - Passed CLI smoke checks for `source add --help`, `source edit --help`, `features`, and `dscv3 source --schema`. ## ✅ Checklist - [ ] Signed the [Contributor License Agreement](https://cla.opensource.microsoft.com) - [x] Linked to an issue - [x] Updated [Release Notes](../doc/ReleaseNotes.md) (if applicable) - [x] Updated documentation (if applicable) - [ ] Updated [Copilot instructions](.github/copilot-instructions.md) (if build, architecture, or conventions changed) — not applicable ## 📋 Issue Type - [ ] Bug fix - [x] Feature - [ ] Task ###### Microsoft Reviewers: [Open in CodeFlow](https://microsoft.github.io/open-pr/?codeflow=https://github.com/microsoft/winget-cli/pull/6509) --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- doc/ReleaseNotes.md | 4 + doc/Settings.md | 12 -- doc/windows/package-manager/winget/source.md | 12 ++ .../JSON/settings/settings.schema.0.2.json | 5 - src/AppInstallerCLICore/Argument.cpp | 2 +- .../Commands/DscSourceResource.cpp | 14 +- .../Workflows/SourceFlow.cpp | 10 +- .../Workflows/WorkflowBase.cpp | 3 +- .../DSCv3SourceResourceCommand.cs | 1 - .../Helpers/WinGetSettingsHelper.cs | 1 - src/AppInstallerCLIE2ETests/InstallCommand.cs | 9 - src/AppInstallerCLIE2ETests/README.md | 2 +- src/AppInstallerCLIE2ETests/SourceCommand.cs | 9 - src/AppInstallerCLITests/SourceFlow.cpp | 204 ++++++++++++++++++ src/AppInstallerCLITests/Sources.cpp | 23 +- .../ExperimentalFeature.cpp | 4 - .../Public/winget/ExperimentalFeature.h | 1 - .../Public/winget/UserSettings.h | 2 - src/AppInstallerCommonCore/UserSettings.cpp | 1 - src/AppInstallerRepositoryCore/SourceList.cpp | 5 +- .../tests/Microsoft.WinGet.Client.Tests.ps1 | 8 - .../tests/Microsoft.WinGet.DSC.Tests.ps1 | 2 - 22 files changed, 236 insertions(+), 98 deletions(-) diff --git a/doc/ReleaseNotes.md b/doc/ReleaseNotes.md index 11ee297b11..5645777c39 100644 --- a/doc/ReleaseNotes.md +++ b/doc/ReleaseNotes.md @@ -2,6 +2,10 @@ ## New Features +### Source priority + +Source priority is now available without enabling an experimental feature. Use `winget source add --priority ` or `winget source edit --name --priority ` to configure it. Higher values take precedence; sources with equal priority still require disambiguation when multiple matches remain. + ### `--output-locale` argument Added a new `--output-locale` argument that overrides the language used for WinGet's own output for a single invocation. diff --git a/doc/Settings.md b/doc/Settings.md index 50db6cc830..9e0fa4033f 100644 --- a/doc/Settings.md +++ b/doc/Settings.md @@ -453,15 +453,3 @@ This feature enables support for fonts via `winget settings`. The `winget font l "fonts": true }, ``` - -### sourcePriority - -This feature enables sources to have a priority value assigned. Sources with a higher priority will appear earlier in search results and will be selected for installing new packages when multiple sources have a matching package. - -Note that search result ordering is dependent on several factors, and source priority is the lowest field in that currently (match quality and field are more important). - -```json - "experimentalFeatures": { - "sourcePriority": true - }, -``` diff --git a/doc/windows/package-manager/winget/source.md b/doc/windows/package-manager/winget/source.md index 6f87ba5e36..5a6ea9c6ea 100644 --- a/doc/windows/package-manager/winget/source.md +++ b/doc/windows/package-manager/winget/source.md @@ -35,6 +35,7 @@ Source supports the following sub-commands for manipulating the sources. | Sub-command | Description | |--------------|-------------| | **add** | Adds a new source. | +| **edit** | Edits an existing source. | | **list** | Enumerates the list of enabled sources. | | **update** | Updates a source. | | **remove** | Removes a source. | @@ -52,6 +53,7 @@ The **source** command supports the following options. | **-n, --name** | The name to identify the source by. | | **-a, --arg** | The URL or UNC of the source. | | **-t, --type** | The type of source. | +| **-p, --priority** | Sets the source priority for **add** or **edit**. Higher values take precedence; new sources default to `0`. | | **-?, --help** | Gets additional help on this command. | | **--wait** | Prompts the user to press any key before exiting. | | **--logs, --open-logs** | Open the default logs location. | @@ -73,6 +75,16 @@ The **add** sub-command also supports the optional **type** parameter. The **typ | **Microsoft.PreIndexed.Package** | The type of source \. | | **Microsoft.Rest** | A Microsoft REST API source. | +## Source priority + +To prefer a source when installing packages, set its priority from an elevated terminal: + +```powershell +winget source edit --name winget --priority 1 +``` + +If multiple matches remain at the highest priority, refine the search or specify `--source`. + ## list the **list** sub-command enumerates the currently enabled sources. This sub-command also provides details on a specific source. diff --git a/schemas/JSON/settings/settings.schema.0.2.json b/schemas/JSON/settings/settings.schema.0.2.json index c47e291aec..de8dc24389 100644 --- a/schemas/JSON/settings/settings.schema.0.2.json +++ b/schemas/JSON/settings/settings.schema.0.2.json @@ -343,11 +343,6 @@ "description": "Enable support for some commands to resume", "type": "boolean", "default": false - }, - "sourcePriority": { - "description": "Enable source priority feature", - "type": "boolean", - "default": false } } }, diff --git a/src/AppInstallerCLICore/Argument.cpp b/src/AppInstallerCLICore/Argument.cpp index ef529ccce1..1cd85d86ac 100644 --- a/src/AppInstallerCLICore/Argument.cpp +++ b/src/AppInstallerCLICore/Argument.cpp @@ -435,7 +435,7 @@ namespace AppInstaller::CLI case Args::Type::SourceEditExplicit: return Argument{ type, Resource::String::SourceEditExplicitArgumentDescription, ArgumentType::Standard }; case Args::Type::SourcePriority: - return Argument{ type, Resource::String::SourcePriorityArgumentDescription, ArgumentType::Standard, ExperimentalFeature::Feature::SourcePriority }; + return Argument{ type, Resource::String::SourcePriorityArgumentDescription, ArgumentType::Standard }; case Args::Type::SourceTrustLevel: return Argument{ type, Resource::String::SourceTrustLevelArgumentDescription, ArgumentType::Standard, Argument::Visibility::Help }; case Args::Type::ValidateManifest: diff --git a/src/AppInstallerCLICore/Commands/DscSourceResource.cpp b/src/AppInstallerCLICore/Commands/DscSourceResource.cpp index 733a254879..1fa3b2eec4 100644 --- a/src/AppInstallerCLICore/Commands/DscSourceResource.cpp +++ b/src/AppInstallerCLICore/Commands/DscSourceResource.cpp @@ -6,7 +6,6 @@ #include "Resources.h" #include "Workflows/SourceFlow.h" #include -#include using namespace AppInstaller::Utility::literals; using namespace AppInstaller::Repository; @@ -111,10 +110,7 @@ namespace AppInstaller::CLI Output.TrustLevel(TrustLevelStringFromFlags(source.TrustLevel)); Output.Explicit(source.Explicit); - if (Settings::ExperimentalFeature::IsEnabled(Settings::ExperimentalFeature::Feature::SourcePriority)) - { - Output.Priority(source.Priority); - } + Output.Priority(source.Priority); std::vector sources; sources.emplace_back(source); @@ -158,7 +154,6 @@ namespace AppInstaller::CLI std::string priorityString; if (Input.Priority()) { - THROW_HR_IF(APPINSTALLER_CLI_ERROR_EXPERIMENTAL_FEATURE_DISABLED, !Settings::ExperimentalFeature::IsEnabled(Settings::ExperimentalFeature::Feature::SourcePriority)); priorityString = std::to_string(Input.Priority().value()); SubContext->Args.AddArg(Execution::Args::Type::SourcePriority, priorityString); } @@ -202,7 +197,6 @@ namespace AppInstaller::CLI std::string priorityString; if (Input.Priority()) { - THROW_HR_IF(APPINSTALLER_CLI_ERROR_EXPERIMENTAL_FEATURE_DISABLED, !Settings::ExperimentalFeature::IsEnabled(Settings::ExperimentalFeature::Feature::SourcePriority)); priorityString = std::to_string(Input.Priority().value()); SubContext->Args.AddArg(Execution::Args::Type::SourcePriority, priorityString); } @@ -373,7 +367,6 @@ namespace AppInstaller::CLI { if (Input.Priority()) { - THROW_HR_IF(APPINSTALLER_CLI_ERROR_EXPERIMENTAL_FEATURE_DISABLED, !Settings::ExperimentalFeature::IsEnabled(Settings::ExperimentalFeature::Feature::SourcePriority)); if (Output.Priority()) { return Input.Priority().value() == Output.Priority().value(); @@ -499,10 +492,7 @@ namespace AppInstaller::CLI output.TrustLevel(TrustLevelStringFromFlags(source.TrustLevel)); output.Explicit(source.Explicit); - if (Settings::ExperimentalFeature::IsEnabled(Settings::ExperimentalFeature::Feature::SourcePriority)) - { - output.Priority(source.Priority); - } + output.Priority(source.Priority); WriteJsonOutputLine(context, output.ToJson()); } diff --git a/src/AppInstallerCLICore/Workflows/SourceFlow.cpp b/src/AppInstallerCLICore/Workflows/SourceFlow.cpp index acccb9a421..cdf6f68d37 100644 --- a/src/AppInstallerCLICore/Workflows/SourceFlow.cpp +++ b/src/AppInstallerCLICore/Workflows/SourceFlow.cpp @@ -189,10 +189,7 @@ namespace AppInstaller::CLI::Workflow table.OutputLine({ Resource::LocString(Resource::String::SourceListIdentifier), source.Identifier }); table.OutputLine({ Resource::LocString(Resource::String::SourceListTrustLevel), Repository::GetSourceTrustLevelForDisplay(source.TrustLevel)}); table.OutputLine({ Resource::LocString(Resource::String::SourceListExplicit), std::string{ Utility::ConvertBoolToString(source.Explicit) } }); - if (ExperimentalFeature::IsEnabled(ExperimentalFeature::Feature::SourcePriority)) - { - table.OutputLine({ Resource::LocString(Resource::String::SourceListPriority), std::to_string(source.Priority) }); - } + table.OutputLine({ Resource::LocString(Resource::String::SourceListPriority), std::to_string(source.Priority) }); if (source.LastUpdateTime == Utility::ConvertUnixEpochToSystemClock(0)) { @@ -410,10 +407,7 @@ namespace AppInstaller::CLI::Workflow s.TrustLevel = std::vector(sourceTrustLevels.begin(), sourceTrustLevels.end()); s.Explicit = source.Explicit; - if (ExperimentalFeature::IsEnabled(ExperimentalFeature::Feature::SourcePriority)) - { - s.Priority = source.Priority; - } + s.Priority = source.Priority; context.Reporter.Info() << s.ToJsonString() << std::endl; } diff --git a/src/AppInstallerCLICore/Workflows/WorkflowBase.cpp b/src/AppInstallerCLICore/Workflows/WorkflowBase.cpp index 4641d99359..3a238f57fc 100644 --- a/src/AppInstallerCLICore/Workflows/WorkflowBase.cpp +++ b/src/AppInstallerCLICore/Workflows/WorkflowBase.cpp @@ -1358,8 +1358,7 @@ namespace AppInstaller::CLI::Workflow m_operationType == OperationType::Repair || m_operationType == OperationType::Export; // Try limiting results to highest priority sources - if (searchResult.Matches.size() > 1 && !operationTargetsInstalled && - ExperimentalFeature::IsEnabled(ExperimentalFeature::Feature::SourcePriority)) + if (searchResult.Matches.size() > 1 && !operationTargetsInstalled) { // Find the set of matches that have the highest priority std::vector highestPriorityMatches; diff --git a/src/AppInstallerCLIE2ETests/DSCv3SourceResourceCommand.cs b/src/AppInstallerCLIE2ETests/DSCv3SourceResourceCommand.cs index 0caa33ea93..6e7a74fa4f 100644 --- a/src/AppInstallerCLIE2ETests/DSCv3SourceResourceCommand.cs +++ b/src/AppInstallerCLIE2ETests/DSCv3SourceResourceCommand.cs @@ -78,7 +78,6 @@ public void OneTimeTeardown() public void Setup() { RemoveTestSource(); - WinGetSettingsHelper.ConfigureFeature("sourcePriority", true); } /// diff --git a/src/AppInstallerCLIE2ETests/Helpers/WinGetSettingsHelper.cs b/src/AppInstallerCLIE2ETests/Helpers/WinGetSettingsHelper.cs index 2a9c85cfbb..c55a104c16 100644 --- a/src/AppInstallerCLIE2ETests/Helpers/WinGetSettingsHelper.cs +++ b/src/AppInstallerCLIE2ETests/Helpers/WinGetSettingsHelper.cs @@ -245,7 +245,6 @@ public static void InitializeAllFeatures(bool status) ConfigureFeature(settingsJson, "resume", status); ConfigureFeature(settingsJson, "reboot", status); ConfigureFeature(settingsJson, "fonts", status); - ConfigureFeature(settingsJson, "sourcePriority", status); SetWingetSettings(settingsJson); } diff --git a/src/AppInstallerCLIE2ETests/InstallCommand.cs b/src/AppInstallerCLIE2ETests/InstallCommand.cs index 8da9265ca7..a7e8454383 100644 --- a/src/AppInstallerCLIE2ETests/InstallCommand.cs +++ b/src/AppInstallerCLIE2ETests/InstallCommand.cs @@ -16,15 +16,6 @@ namespace AppInstallerCLIE2ETests /// public class InstallCommand : BaseCommand { - /// - /// One time set up. - /// - [OneTimeSetUp] - public void OneTimeSetup() - { - WinGetSettingsHelper.ConfigureFeature("sourcePriority", true); - } - /// /// Set up. /// diff --git a/src/AppInstallerCLIE2ETests/README.md b/src/AppInstallerCLIE2ETests/README.md index 61d0accda1..27ce488b34 100644 --- a/src/AppInstallerCLIE2ETests/README.md +++ b/src/AppInstallerCLIE2ETests/README.md @@ -54,7 +54,7 @@ Assuming you clone winget-cli in c:\dev, the localhost web server is running in - + diff --git a/src/AppInstallerCLIE2ETests/SourceCommand.cs b/src/AppInstallerCLIE2ETests/SourceCommand.cs index 0c9fb285d6..ec6efe15f5 100644 --- a/src/AppInstallerCLIE2ETests/SourceCommand.cs +++ b/src/AppInstallerCLIE2ETests/SourceCommand.cs @@ -14,15 +14,6 @@ namespace AppInstallerCLIE2ETests /// public class SourceCommand : BaseCommand { - /// - /// One time set up. - /// - [OneTimeSetUp] - public void OneTimeSetup() - { - WinGetSettingsHelper.ConfigureFeature("sourcePriority", true); - } - /// /// Test set up. /// diff --git a/src/AppInstallerCLITests/SourceFlow.cpp b/src/AppInstallerCLITests/SourceFlow.cpp index 1bea3bbc92..485682c2e9 100644 --- a/src/AppInstallerCLITests/SourceFlow.cpp +++ b/src/AppInstallerCLITests/SourceFlow.cpp @@ -4,6 +4,8 @@ #include "WorkflowCommon.h" #include "TestHooks.h" #include "TestSettings.h" +#include +#include #include #include #include @@ -14,6 +16,208 @@ using namespace AppInstaller::CLI::Workflow; using namespace AppInstaller::Repository; using namespace AppInstaller::Settings; +TEST_CASE("SourcePriority_Arguments", "[SourcePriority][workflow]") +{ + GroupPolicyTestOverride policies; + policies.SetState(TogglePolicy::Policy::ExperimentalFeatures, GENERATE(PolicyState::NotConfigured, PolicyState::Disabled)); + auto priority = GENERATE("-2147483648"sv, "0"sv, "42"sv, "2147483647"sv); + + Execution::Args addArgs; + addArgs.AddArg(Execution::Args::Type::SourceName, "TestSource"sv); + addArgs.AddArg(Execution::Args::Type::SourceArg, "https://test"sv); + addArgs.AddArg(Execution::Args::Type::SourcePriority, priority); + SourceAddCommand sourceAdd({}); + REQUIRE_NOTHROW(sourceAdd.ValidateArguments(addArgs)); + + Execution::Args editArgs; + editArgs.AddArg(Execution::Args::Type::SourceName, "TestSource"sv); + editArgs.AddArg(Execution::Args::Type::SourcePriority, priority); + SourceEditCommand sourceEdit({}); + REQUIRE_NOTHROW(sourceEdit.ValidateArguments(editArgs)); + + REQUIRE(Argument::ForType(Execution::Args::Type::SourcePriority).GetVisibility() != Argument::Visibility::Hidden); +} + +TEST_CASE("SourcePriority_SearchResult", "[SourcePriority][workflow]") +{ + GroupPolicyTestOverride policies; + policies.SetState(TogglePolicy::Policy::ExperimentalFeatures, GENERATE(PolicyState::NotConfigured, PolicyState::Disabled)); + auto operationType = GENERATE(OperationType::Install, OperationType::Upgrade, OperationType::Uninstall, OperationType::Repair, OperationType::Export); + + std::vector priorities; + size_t expectedMatches = 1; + + SECTION("Unique highest priority") + { + priorities = { 0, 2, 1 }; + } + SECTION("Default priorities") + { + priorities = { 0, 0, 0 }; + expectedMatches = 3; + } + SECTION("Tied highest priority") + { + priorities = { 0, 2, 2 }; + expectedMatches = 2; + } + SECTION("Negative priorities") + { + priorities = { -3, -1, -2 }; + } + + auto manifest = AppInstaller::Manifest::YamlParser::CreateFromPath(TestDataFile("InstallFlowTest_Exe.yaml")); + std::vector versions{ manifest }; + std::vector> sources; + SearchResult searchResult; + + for (int32_t priority : priorities) + { + auto source = std::make_shared(); + source->Details.Priority = priority; + auto package = TestCompositePackage::Make(versions, source); + if (operationType != OperationType::Install) + { + package->Installed = TestPackage::Make(manifest, TestPackage::MetadataMap{}, source); + } + + searchResult.Matches.emplace_back(package, PackageMatchFilter{ PackageMatchField::Id, MatchType::Exact, manifest.Id }); + sources.emplace_back(std::move(source)); + } + + auto expectedPackage = searchResult.Matches[1].Package; + if (operationType != OperationType::Install) + { + expectedMatches = priorities.size(); + } + + std::ostringstream output; + TestContext context{ output, std::cin }; + auto previousThreadGlobals = context.SetForCurrentThread(); + context.Add(std::move(searchResult)); + context << EnsureOneMatchFromSearchResult(operationType); + + INFO(output.str()); + REQUIRE(context.Get().Matches.size() == expectedMatches); + REQUIRE(context.GetTerminationHR() == (expectedMatches == 1 ? S_OK : APPINSTALLER_CLI_ERROR_MULTIPLE_APPLICATIONS_FOUND)); + REQUIRE((output.str().find(Resource::LocString(Resource::String::MultiplePackagesFoundFilteredBySourcePriority).get()) != std::string::npos) == + (expectedMatches < priorities.size())); + + if (expectedMatches == 1) + { + REQUIRE(context.Get() == expectedPackage); + } +} + +TEST_CASE("SourcePriority_SourceOutput", "[SourcePriority][workflow]") +{ + GroupPolicyTestOverride policies; + policies.SetState(TogglePolicy::Policy::ExperimentalFeatures, GENERATE(PolicyState::NotConfigured, PolicyState::Disabled)); + + SourceDetails source; + source.Name = "PriorityTestSource"; + source.Priority = GENERATE(-1, 0, 42); + + std::ostringstream output; + TestContext context{ output, std::cin }; + auto previousThreadGlobals = context.SetForCurrentThread(); + context.Add(std::vector{ source }); + + SECTION("List") + { + context.Args.AddArg(Execution::Args::Type::SourceName, source.Name); + context << ListSources; + REQUIRE(output.str().find(Resource::LocString(Resource::String::SourceListPriority).get()) != std::string::npos); + REQUIRE(output.str().find(std::to_string(source.Priority)) != std::string::npos); + } + SECTION("Export") + { + context << ExportSourceList; + auto json = ConvertToJson(output.str()); + REQUIRE(json["Priority"].isInt()); + REQUIRE(json["Priority"].asInt() == source.Priority); + } + + REQUIRE(context.GetTerminationHR() == S_OK); +} + +TEST_CASE("SourcePriority_DscSource", "[SourcePriority][workflow]") +{ + GroupPolicyTestOverride policies; + policies.SetState(TogglePolicy::Policy::ExperimentalFeatures, GENERATE(PolicyState::NotConfigured, PolicyState::Disabled)); + SetSetting(Stream::UserSources, "Sources:"sv); + RemoveSetting(Stream::SourcesMetadata); + TestHook_ClearSourceFactoryOverrides(); + + TestSourceFactory factory{ [](const SourceDetails& details) { return std::make_shared(details); } }; + auto clearFactoryOverrides = wil::scope_exit([]() { TestHook_ClearSourceFactoryOverrides(); }); + TestHook_SetSourceFactoryOverride("Microsoft.Test", factory); + + Json::Value input{ Json::ValueType::objectValue }; + input["name"] = "PriorityTestSource"; + input["argument"] = "priority-test"; + input["type"] = "Microsoft.Test"; + input["priority"] = GENERATE(-1, 0, 42); + + auto invoke = [&](Execution::Args::Type function, bool modifiesSource = false) + { + std::istringstream inputStream{ Json::writeString(Json::StreamWriterBuilder{}, input) }; + std::ostringstream output; + TestContext context{ output, inputStream }; + auto previousThreadGlobals = context.SetForCurrentThread(); + context.Args.AddArg(function); + if (modifiesSource) + { + context.Override({ EnsureRunningAsAdmin, [](TestContext& subContext) + { + subContext.Reporter.SetChannel(Execution::Reporter::Channel::Json); + } }); + } + + DscSourceResource command({}); + command.Execute(context); + INFO(output.str()); + REQUIRE(context.GetTerminationHR() == S_OK); + + std::vector result; + std::istringstream outputStream{ output.str() }; + for (std::string line; std::getline(outputStream, line);) + { + result.emplace_back(ConvertToJson(line)); + } + REQUIRE_FALSE(result.empty()); + return result; + }; + + auto result = invoke(Execution::Args::Type::DscResourceFunctionSet, true); + REQUIRE(result[0]["priority"] == input["priority"]); + + result = invoke(Execution::Args::Type::DscResourceFunctionGet); + REQUIRE(result[0]["priority"] == input["priority"]); + + result = invoke(Execution::Args::Type::DscResourceFunctionTest); + REQUIRE(result[0]["_inDesiredState"].asBool()); + + input["priority"] = input["priority"].asInt() + 1; + result = invoke(Execution::Args::Type::DscResourceFunctionTest); + REQUIRE_FALSE(result[0]["_inDesiredState"].asBool()); + + result = invoke(Execution::Args::Type::DscResourceFunctionSet, true); + REQUIRE(result.size() == 2); + REQUIRE(result[0]["priority"] == input["priority"]); + REQUIRE(result[1].size() == 1); + REQUIRE(result[1][0].asString() == "priority"); + + result = invoke(Execution::Args::Type::DscResourceFunctionSet); + REQUIRE(result.size() == 2); + REQUIRE(result[1].empty()); + + result = invoke(Execution::Args::Type::DscResourceFunctionExport); + auto source = std::find_if(result.begin(), result.end(), [&](const Json::Value& value) { return value["name"] == input["name"]; }); + REQUIRE(source != result.end()); + REQUIRE((*source)["priority"] == input["priority"]); +} + void OverrideForSourceAddWithAgreements(TestContext& context, bool isAddExpected = true) { context.Override({ EnsureRunningAsAdmin, [](TestContext&) diff --git a/src/AppInstallerCLITests/Sources.cpp b/src/AppInstallerCLITests/Sources.cpp index 2ca8987633..7b433833a5 100644 --- a/src/AppInstallerCLITests/Sources.cpp +++ b/src/AppInstallerCLITests/Sources.cpp @@ -406,24 +406,14 @@ TEST_CASE("RepoSources_SingleSource_AllProperties", "[sources]") TEST_CASE("RepoSources_ThreeSources", "[sources]") { + GroupPolicyTestOverride policies; + policies.SetState(TogglePolicy::Policy::ExperimentalFeatures, GENERATE(PolicyState::NotConfigured, PolicyState::Disabled)); + SetSetting(Stream::UserSources, s_ThreeSources); SetSetting(Stream::SourcesMetadata, s_ThreeSourcesMetadata); const char* suffixStrings[3] = { "", "2", "3" }; - size_t suffixUnsorted[3] = { 0, 1, 2 }; - size_t suffixPrioritySorted[3] = { 1, 2, 0 }; - size_t* suffix = nullptr; - std::unique_ptr override; - - SECTION("Unsorted") - { - suffix = suffixUnsorted; - } - SECTION("Priority Sorted") - { - override = std::make_unique(ExperimentalFeature::Feature::SourcePriority); - suffix = suffixPrioritySorted; - } + size_t suffix[3] = { 1, 2, 0 }; std::vector sources = GetSources(); REQUIRE(sources.size() == 3); @@ -1049,6 +1039,9 @@ TEST_CASE("RepoSources_GroupPolicy_AdditionalSources", "[sources][groupPolicy]") auto sources = GetSources(); + // Compare in descending priority order. + std::reverse(policySources.begin(), policySources.end()); + // The source list includes the default source REQUIRE(sources.size() == policySources.size() + c_DefaultSourceCount); REQUIRE(sources.back().Origin == SourceOrigin::Default); @@ -1069,7 +1062,7 @@ TEST_CASE("RepoSources_GroupPolicy_AdditionalSources", "[sources][groupPolicy]") } SECTION("Priority sorts additional sources") { - TestHook::SetSingleExperimentalFeature_Override prioritySortEnabled{ ExperimentalFeature::Feature::SourcePriority }; + policies.SetState(TogglePolicy::Policy::ExperimentalFeatures, PolicyState::Disabled); // The sources are given in increasing priority order; the result should be the reverse. std::vector policySources; diff --git a/src/AppInstallerCommonCore/ExperimentalFeature.cpp b/src/AppInstallerCommonCore/ExperimentalFeature.cpp index dd659448a8..c814afb965 100644 --- a/src/AppInstallerCommonCore/ExperimentalFeature.cpp +++ b/src/AppInstallerCommonCore/ExperimentalFeature.cpp @@ -65,8 +65,6 @@ namespace AppInstaller::Settings return userSettings.Get(); case ExperimentalFeature::Feature::Font: return userSettings.Get(); - case ExperimentalFeature::Feature::SourcePriority: - return userSettings.Get(); default: THROW_HR(E_UNEXPECTED); } @@ -100,8 +98,6 @@ namespace AppInstaller::Settings return ExperimentalFeature{ "Resume", "resume", "https://aka.ms/winget-settings", Feature::Resume }; case Feature::Font: return ExperimentalFeature{ "Font", "fonts", "https://aka.ms/winget-settings", Feature::Font }; - case Feature::SourcePriority: - return ExperimentalFeature{ "Source Priority", "sourcePriority", "https://aka.ms/winget-settings", Feature::SourcePriority }; default: THROW_HR(E_UNEXPECTED); } diff --git a/src/AppInstallerCommonCore/Public/winget/ExperimentalFeature.h b/src/AppInstallerCommonCore/Public/winget/ExperimentalFeature.h index 42559317ef..2dc097f548 100644 --- a/src/AppInstallerCommonCore/Public/winget/ExperimentalFeature.h +++ b/src/AppInstallerCommonCore/Public/winget/ExperimentalFeature.h @@ -25,7 +25,6 @@ namespace AppInstaller::Settings DirectMSI = 0x1, Resume = 0x2, Font = 0x4, - SourcePriority = 0x8, Max, // This MUST always be after all experimental features // Features listed after Max will not be shown with the features command diff --git a/src/AppInstallerCommonCore/Public/winget/UserSettings.h b/src/AppInstallerCommonCore/Public/winget/UserSettings.h index 52fcf2c46e..f438d6ed8e 100644 --- a/src/AppInstallerCommonCore/Public/winget/UserSettings.h +++ b/src/AppInstallerCommonCore/Public/winget/UserSettings.h @@ -102,7 +102,6 @@ namespace AppInstaller::Settings EFDirectMSI, EFResume, EFFonts, - EFSourcePriority, // Telemetry TelemetryDisable, // Install behavior @@ -195,7 +194,6 @@ namespace AppInstaller::Settings SETTINGMAPPING_SPECIALIZATION(Setting::EFDirectMSI, bool, bool, false, ".experimentalFeatures.directMSI"sv); SETTINGMAPPING_SPECIALIZATION(Setting::EFResume, bool, bool, false, ".experimentalFeatures.resume"sv); SETTINGMAPPING_SPECIALIZATION(Setting::EFFonts, bool, bool, false, ".experimentalFeatures.fonts"sv); - SETTINGMAPPING_SPECIALIZATION(Setting::EFSourcePriority, bool, bool, false, ".experimentalFeatures.sourcePriority"sv); // Telemetry SETTINGMAPPING_SPECIALIZATION(Setting::TelemetryDisable, bool, bool, false, ".telemetry.disable"sv); // Install behavior diff --git a/src/AppInstallerCommonCore/UserSettings.cpp b/src/AppInstallerCommonCore/UserSettings.cpp index 04a293c78b..3fe6bc75a2 100644 --- a/src/AppInstallerCommonCore/UserSettings.cpp +++ b/src/AppInstallerCommonCore/UserSettings.cpp @@ -287,7 +287,6 @@ namespace AppInstaller::Settings WINGET_VALIDATE_PASS_THROUGH(EFDirectMSI) WINGET_VALIDATE_PASS_THROUGH(EFResume) WINGET_VALIDATE_PASS_THROUGH(EFFonts) - WINGET_VALIDATE_PASS_THROUGH(EFSourcePriority) WINGET_VALIDATE_PASS_THROUGH(AnonymizePathForDisplay) WINGET_VALIDATE_PASS_THROUGH(TelemetryDisable) WINGET_VALIDATE_PASS_THROUGH(InteractivityDisable) diff --git a/src/AppInstallerRepositoryCore/SourceList.cpp b/src/AppInstallerRepositoryCore/SourceList.cpp index 52f1e55d1c..bffbeeef57 100644 --- a/src/AppInstallerRepositoryCore/SourceList.cpp +++ b/src/AppInstallerRepositoryCore/SourceList.cpp @@ -759,10 +759,7 @@ namespace AppInstaller::Repository } } - if (ExperimentalFeature::IsEnabled(ExperimentalFeature::Feature::SourcePriority)) - { - std::stable_sort(m_sourceList.begin(), m_sourceList.end()); - } + std::stable_sort(m_sourceList.begin(), m_sourceList.end()); } void SourceList::OverwriteMetadata() diff --git a/src/PowerShell/tests/Microsoft.WinGet.Client.Tests.ps1 b/src/PowerShell/tests/Microsoft.WinGet.Client.Tests.ps1 index 9cd9a08564..e864171e80 100644 --- a/src/PowerShell/tests/Microsoft.WinGet.Client.Tests.ps1 +++ b/src/PowerShell/tests/Microsoft.WinGet.Client.Tests.ps1 @@ -188,9 +188,6 @@ Describe 'Reset-WinGetSource' { Describe 'Get|Add|Reset-WinGetSource' { BeforeAll { - $ogSettings = @{ experimentalFeatures= @{sourcePriority=$true}} - SetWinGetSettingsHelper $ogSettings - Add-WinGetSource -Name 'TestSource' -Arg 'https://localhost:5001/TestKit/' -TrustLevel 'Trusted' -Explicit -Priority 42 } @@ -217,7 +214,6 @@ Describe 'Get|Add|Reset-WinGetSource' { AfterAll { RemoveTestSource - RestoreWinGetSettings } } @@ -512,9 +508,6 @@ Describe 'Install-WinGetPackage Source Priority' { } It 'Install higher Priority' { - $ogSettings = @{ experimentalFeatures= @{sourcePriority=$true}} - SetWinGetSettingsHelper $ogSettings - RemoveTestSource Add-WinGetSource -Name 'TestSource' -Arg 'https://localhost:5001/TestKit/' -Priority 1 Add-WinGetSource -Name 'dummyPackageSource' -Type 'Microsoft.Test.Configurable' -Arg '{"ContainsPackage":true}' @@ -542,7 +535,6 @@ Describe 'Install-WinGetPackage Source Priority' { Remove-WinGetSource -Name 'dummyPackageSource' RemoveTestSource - RestoreWinGetSettings } } diff --git a/src/PowerShell/tests/Microsoft.WinGet.DSC.Tests.ps1 b/src/PowerShell/tests/Microsoft.WinGet.DSC.Tests.ps1 index 6a4305c6be..827fb86505 100644 --- a/src/PowerShell/tests/Microsoft.WinGet.DSC.Tests.ps1 +++ b/src/PowerShell/tests/Microsoft.WinGet.DSC.Tests.ps1 @@ -130,8 +130,6 @@ Describe 'WinGetUserSettings' { Describe 'WinGetSource' { BeforeAll { - InvokeWinGetDSC -Name WinGetUserSettings -Method Set -Property @{ Settings = @{ experimentalFeatures = @{ sourcePriority = $true } } } - $testSourceName = 'TestSource' $testSourceArg = 'https://localhost:5001/TestKit/' $testSourceType = 'Microsoft.PreIndexed.Package' From 1e0cf348b6682df2acc0105ef08d18a522089f97 Mon Sep 17 00:00:00 2001 From: AmirMS <104940545+AmelBawa-msft@users.noreply.github.com> Date: Wed, 30 Sep 2026 11:12:34 -0700 Subject: [PATCH 2/3] Validate REST search results against filters and selectors (#6514) --- .github/actions/spelling/allow.txt | 1 + .github/copilot-instructions.md | 2 + doc/ReleaseNotes.md | 5 + src/AppInstallerCLITests/CompositeSource.cpp | 286 +++ .../MatchCriteriaResolver.cpp | 517 ++++++ .../RestInterface_1_0.cpp | 1653 ++++++++++++++++- .../RestInterface_1_1.cpp | 136 +- src/AppInstallerCLITests/SourceFlow.cpp | 91 + src/AppInstallerCLITests/Strings.cpp | 19 + src/AppInstallerCLITests/YamlManifest.cpp | 104 ++ .../Manifest/Manifest.cpp | 66 +- .../Public/winget/Manifest.h | 7 + .../ARPCorrelation.cpp | 29 +- .../CompositeSource.cpp | 33 +- .../MatchCriteriaResolver.cpp | 173 +- .../MatchCriteriaResolver.h | 15 + .../Public/winget/RepositorySearch.h | 11 + .../RepositorySearch.cpp | 23 + .../Rest/RestSource.cpp | 55 +- .../Rest/Schema/1_0/Interface.h | 8 +- .../1_0/Json/SearchRequestSerializer_1_0.cpp | 1 + .../Rest/Schema/1_0/RestInterface_1_0.cpp | 353 +++- .../Rest/Schema/1_1/Interface.h | 4 +- .../Rest/Schema/1_1/RestInterface_1_1.cpp | 4 +- .../Rest/Schema/IRestClient.h | 2 + .../AppInstallerStrings.cpp | 4 +- 26 files changed, 3431 insertions(+), 171 deletions(-) diff --git a/.github/actions/spelling/allow.txt b/.github/actions/spelling/allow.txt index 24088f8881..adfa886dfa 100644 --- a/.github/actions/spelling/allow.txt +++ b/.github/actions/spelling/allow.txt @@ -80,6 +80,7 @@ dotnet downloaders dsx DWORDLONG +EApp emoji ENDDIALOG ensureandinsert diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index f19f1a7bdc..e21e0fc455 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -31,6 +31,8 @@ The solution uses: - vcpkg for C++ dependencies - NuGet for C++ and .NET dependencies +CI uses `/p:PreferredToolArchitecture=x64` to avoid 32-bit linker memory limits without changing the target architecture. Use the same setting for command-line Release builds. + ### Running/Debugging 1. Deploy solution: Build > Deploy Solution diff --git a/doc/ReleaseNotes.md b/doc/ReleaseNotes.md index 5645777c39..dad9051542 100644 --- a/doc/ReleaseNotes.md +++ b/doc/ReleaseNotes.md @@ -40,6 +40,11 @@ This change resolves alias failures in non-symlinked scenarios, including cases Because the alias is now created as an executable hardlink in the install location, command aliases remain available and consistent even when symlink creation is skipped. ### Minor Bug Fixes +* Fixed REST search results bypassing locally verifiable package filters and selectors. Extra manifests are retrieved only for complete source result sets of three or fewer packages. Normalized name/publisher criteria remain unvalidated client-side. +* Fixed installed-package matching incorrectly combining names and publishers from different manifest entries. +* Prevented unrestricted REST searches when a source declares all requested selectors unsupported. +* Prevented REST searches from looping indefinitely when continuation tokens repeat. +* Fixed Unicode case-insensitive prefix matching when case folding changes character lengths. * Fixed an issue where `winget search --id ` could fail to return a Microsoft Store package unless `--exact` was also provided. * Updated NUnit to v4 * Fixed a crash (`0x8000ffff`) when using `--disable-interactivity` with the Resume experimental feature enabled during install operations. diff --git a/src/AppInstallerCLITests/CompositeSource.cpp b/src/AppInstallerCLITests/CompositeSource.cpp index 8160c8df5e..0869dc650d 100644 --- a/src/AppInstallerCLITests/CompositeSource.cpp +++ b/src/AppInstallerCLITests/CompositeSource.cpp @@ -4,10 +4,12 @@ #include "TestCommon.h" #include "TestSource.h" #include "TestHooks.h" +#include "TestRestRequestHandler.h" #include #include #include #include +#include #include #include #include @@ -438,6 +440,64 @@ TEST_CASE("CompositeSource_ProductCode_Available", "[CompositeSource]") REQUIRE(result.Matches[0].Package->GetAvailable()[0]->GetVersionKeys().size() == 1); } +TEST_CASE("Package_MatrixProperty_NameAndPublisher", "[CompositeSource]") +{ + bool hasNames = GENERATE(false, true); + bool hasPublishers = GENERATE(false, true); + CAPTURE(hasNames, hasPublishers); + Manifest::Manifest manifest; + manifest.Version = "1.0"; + auto& localization = manifest.Localizations.emplace_back(); + if (hasNames) + { + manifest.DefaultLocalization.Add("First Name"); + localization.Add("Second Name"); + } + if (hasPublishers) + { + manifest.DefaultLocalization.Add("First Publisher"); + localization.Add("Second Publisher"); + } + auto package = TestPackage::Make(std::vector{ manifest }); + std::vector> expected; + if (hasNames && hasPublishers) + { + expected = { + { "first name", "first publisher" }, + { "first name", "second publisher" }, + { "second name", "first publisher" }, + { "second name", "second publisher" }, + }; + } + REQUIRE(package->GetMatrixProperty(PackageMatrixProperty::NormalizedNameAndPublisher) == expected); + REQUIRE_THROWS_HR(package->GetMatrixProperty(static_cast(-1)), E_UNEXPECTED); +} + +TEST_CASE("CompositeSource_NameAndPublisher_InvalidMatrixRow", "[CompositeSource]") +{ + struct TestMatrixPackage : TestPackage + { + using TestPackage::TestPackage; + + std::vector> GetMatrixProperty(PackageMatrixProperty) const override + { + return Rows; + } + + std::vector> Rows; + }; + + size_t columnCount = GENERATE(size_t{ 0 }, size_t{ 1 }, size_t{ 3 }); + CAPTURE(columnCount); + CompositeTestSetup setup{ CompositeSearchBehavior::AvailablePackages }; + auto package = std::make_shared(std::vector{ MakeDefaultManifest() }, setup.Available); + package->Rows.emplace_back(columnCount, "value"); + auto available = setup.MakeAvailable().ToPackage(); + available->Available[0] = package; + setup.Available->Everything.Matches.emplace_back(available, Criteria()); + REQUIRE_THROWS_HR(setup.Search(), E_UNEXPECTED); +} + TEST_CASE("CompositeSource_NameAndPublisher_Match", "[CompositeSource]") { CompositeTestSetup setup; @@ -2037,3 +2097,229 @@ TEST_CASE("CompositeSource_MappedVersions_ProperSorting", "[CompositeSource]") REQUIRE(installedVersions[0].Version == versionMapped2); REQUIRE(installedVersions[1].Version == versionMapped1); } + +struct RestCorrelationTestSetup : CompositeWithTrackingTestSetup +{ + web::json::value SearchResponse = web::json::value::parse(LR"({ + "Data": [{ + "PackageIdentifier": "Foo.Bar", "PackageName": "Legacy App", "Publisher": "Legacy Publisher", + "Versions": [{ "PackageVersion": "Unknown" }] + }] + })"); + web::json::value ManifestResponse = web::json::value::parse(LR"({ + "Data": { + "PackageIdentifier": "Foo.Bar", + "Versions": [{ + "PackageVersion": "1.0.0", + "DefaultLocale": { + "PackageLocale": "en-US", "PackageName": "New App", "Publisher": "New Publisher", "Moniker": "tool", + "License": "MIT", "ShortDescription": "Example application" + }, + "Installers": [{ + "Architecture": "x64", "InstallerType": "exe", "InstallerUrl": "https://example.com/installer.exe", + "InstallerSha256": "011048877dfaef109801b3f3ab2b60afc74f3fc4f7b3430e0c897f5da1df84b6" + }] + }] + } + })"); + size_t ManifestRequests = 0; + + RestCorrelationTestSetup(CompositeSearchBehavior behavior) + { + namespace RepositoryRest = AppInstaller::Repository::Rest; + auto handler = std::make_shared( + [this](web::http::http_request request) -> pplx::task + { + web::http::http_response response{ web::http::status_codes::BadRequest }; + response.headers().set_content_type(web::http::details::mime_types::application_json); + response.headers().set_cache_control(L"no-store"); + if (request.method() == web::http::methods::POST) + { + response.set_status_code(web::http::status_codes::OK); + response.set_body(SearchResponse); + } + else if (request.method() == web::http::methods::GET) + { + ++ManifestRequests; + response.set_status_code(web::http::status_codes::OK); + response.set_body(ManifestResponse); + } + return pplx::task_from_result(response); + }); + Http::HttpClientHelper helper{ handler }; + SourceDetails details; + details.Identifier = "RestCorrelationTestSource"; + auto source = std::make_shared(details, SourceInformation{}, + RepositoryRest::RestClient::Create("https://restsource.com/api", {}, {}, helper, + RepositoryRest::Schema::IRestClient::Information{ details.Identifier, { "1.4.0" } })); + Composite = CompositeSource{ "*RestTests" }; + Composite.SetInstalledSource(Source{ Installed }, behavior); + Composite.AddAvailableSource(Source{ source }); + } +}; + +TEST_CASE("CompositeSource_RestRetrieval_InstalledVersion", "[RestSource][CompositeSource][RestRetrievalRegression]") +{ + auto [manifestHasArpRanges, onlyLatestVersion] = GENERATE( + std::make_pair(false, false), std::make_pair(false, true), std::make_pair(true, false)); + bool retrieveBeforeCorrelation = GENERATE(false, true); + CAPTURE(manifestHasArpRanges, onlyLatestVersion, retrieveBeforeCorrelation); + RestCorrelationTestSetup setup{ CompositeSearchBehavior::AvailablePackages }; + auto& searchVersion = setup.SearchResponse[L"Data"][0][L"Versions"][0]; + searchVersion[L"ProductCodes"][0] = web::json::value::string(L"search.code"); + searchVersion[L"AppsAndFeaturesEntryVersions"] = web::json::value::array( + { web::json::value::string(L"1.0.0"), web::json::value::string(L"2.0.0") }); + auto first = setup.ManifestResponse[L"Data"][L"Versions"][0]; + auto second = first; + first[L"PackageVersion"] = web::json::value::string(manifestHasArpRanges ? L"10.0.0" : L"1.0.0"); + second[L"PackageVersion"] = web::json::value::string(manifestHasArpRanges ? L"20.0.0" : L"2.0.0"); + if (manifestHasArpRanges) + { + first[L"Installers"][0][L"AppsAndFeaturesEntries"][0][L"DisplayVersion"] = web::json::value::string(L"1.0.0"); + second[L"Installers"][0][L"AppsAndFeaturesEntries"][0][L"DisplayVersion"] = web::json::value::string(L"2.0.0"); + } + setup.ManifestResponse[L"Data"][L"Versions"] = onlyLatestVersion ? + web::json::value::array({ second }) : web::json::value::array({ first, second }); + auto installed = setup.MakeInstalled().WithVersion("1.0.0").WithPC("search.code") + .WithMetadata(PackageVersionMetadata::InstalledType, "exe").ToPackage(); + setup.Installed->SearchFunction = [&](const SearchRequest& request) + { + SearchResult result; + if (request.Purpose == SearchPurpose::CorrelationToInstalled && + SearchRequestIncludes(request.Inclusions, PackageMatchField::ProductCode, MatchType::Exact, "search.code")) + { + result.Matches.emplace_back(installed, PackageMatchFilter{ PackageMatchField::ProductCode, MatchType::Exact, "search.code" }); + } + return result; + }; + SearchRequest request; + request.Filters.emplace_back(retrieveBeforeCorrelation ? PackageMatchField::Moniker : PackageMatchField::Name, + MatchType::Exact, retrieveBeforeCorrelation ? "tool"sv : "Legacy App"sv); + auto result = setup.Composite.Search(request); + REQUIRE(result.Failures.empty()); + REQUIRE(result.Matches.size() == 1); + auto installedVersion = GetInstalledVersion(result.Matches[0].Package); + REQUIRE(installedVersion); + CHECK(installedVersion->GetProperty(PackageVersionProperty::Version).get() == (manifestHasArpRanges ? "10.0.0" : "1.0.0")); + auto latestAvailable = GetAvailableVersionsForInstalledVersion(result.Matches[0].Package)->GetLatestVersion(); + REQUIRE(latestAvailable); + REQUIRE(latestAvailable->GetProperty(PackageVersionProperty::Version).get() == (manifestHasArpRanges ? "20.0.0" : "2.0.0")); + PinningData::PinStateEvaluator evaluator{ PinBehavior::IgnorePins, {}, installedVersion }; + CHECK(evaluator.IsUpdate(latestAvailable)); + REQUIRE(setup.ManifestRequests == 1); +} + +TEST_CASE("CompositeSource_RestRetrieval_NamePublisher", "[RestSource][CompositeSource][RestRetrievalRegression]") +{ + auto behavior = GENERATE(CompositeSearchBehavior::Installed, CompositeSearchBehavior::AvailablePackages); + auto versionState = GENERATE("Known"sv, "Unknown"sv, "PartiallyCached"sv); + bool legacyName = GENERATE(false, true); + bool legacyPublisher = GENERATE(false, true); + CAPTURE(behavior, versionState, legacyName, legacyPublisher); + RestCorrelationTestSetup setup{ behavior }; + if (versionState != "Unknown") + { + setup.SearchResponse[L"Data"][0][L"Versions"][0][L"PackageVersion"] = web::json::value::string(L"1.0.0"); + } + if (versionState == "PartiallyCached") + { + setup.SearchResponse[L"Data"][0][L"Versions"][1][L"PackageVersion"] = web::json::value::string(L"2.0.0"); + } + const std::string name = legacyName ? "Legacy App" : "New App"; + const std::string publisher = legacyPublisher ? "Legacy Publisher" : "New Publisher"; + auto installedManifest = MakeDefaultManifest("1.0.0"); + installedManifest.DefaultLocalization.Add(name); + installedManifest.DefaultLocalization.Add(publisher); + auto installed = TestCompositePackage::Make(installedManifest, TestCompositePackage::MetadataMap{}, + std::vector{}, setup.Installed); + setup.Installed->SearchFunction = [&](const SearchRequest& request) + { + SearchResult result; + if (request.Purpose == SearchPurpose::CorrelationToInstalled) + { + for (const auto& inclusion : request.Inclusions) + { + if (inclusion.Field == PackageMatchField::NormalizedNameAndPublisher && inclusion.Additional && + ICUCaseInsensitiveEquals(inclusion.Value, name) && + ICUCaseInsensitiveEquals(inclusion.Additional.value(), publisher)) + { + result.Matches.emplace_back(installed, inclusion); + break; + } + } + } + return result; + }; + SearchRequest request; + request.Filters.emplace_back(PackageMatchField::Moniker, MatchType::Exact, "tool"); + auto result = setup.Composite.Search(request); + REQUIRE(result.Failures.empty()); + bool shouldCorrelate = legacyName == legacyPublisher; + size_t expectedCount = behavior == CompositeSearchBehavior::Installed && !shouldCorrelate ? 0 : 1; + REQUIRE(result.Matches.size() == expectedCount); + if (expectedCount) + { + REQUIRE(static_cast(GetInstalledVersion(result.Matches[0].Package)) == shouldCorrelate); + REQUIRE(result.Matches[0].Package->GetAvailable().size() == 1); + } + REQUIRE(setup.ManifestRequests == (versionState == "PartiallyCached" ? size_t{ 2 } : size_t{ 1 })); +} + +TEST_CASE("CompositeSource_RestRetrieval_ManifestNamePublisherPairs", "[RestSource][CompositeSource][RestRetrievalRegression]") +{ + auto behavior = GENERATE(CompositeSearchBehavior::Installed, CompositeSearchBehavior::AvailablePackages); + bool cacheManifest = GENERATE(false, true); + size_t nameIndex = GENERATE(0, 1, 2); + size_t publisherIndex = GENERATE(0, 1, 2); + const std::vector names{ "New App", "Localized App", "Installed App" }; + const std::vector publishers{ "New Publisher", "Localized Publisher", "Installed Publisher" }; + const auto& name = names[nameIndex]; + const auto& publisher = publishers[publisherIndex]; + CAPTURE(behavior, cacheManifest, name, publisher); + RestCorrelationTestSetup setup{ behavior }; + setup.SearchResponse[L"Data"][0][L"Versions"][0][L"PackageVersion"] = web::json::value::string(L"1.0.0"); + auto& manifestVersion = setup.ManifestResponse[L"Data"][L"Versions"][0]; + manifestVersion[L"Locales"] = web::json::value::parse(LR"([ + { "PackageLocale": "fr-FR", "PackageName": "Localized App", "Publisher": "Localized Publisher" } + ])"); + manifestVersion[L"Installers"][0][L"AppsAndFeaturesEntries"] = web::json::value::parse(LR"([ + { "DisplayName": "Installed App", "Publisher": "Installed Publisher" } + ])"); + auto installedManifest = MakeDefaultManifest("1.0.0"); + installedManifest.DefaultLocalization.Add(name); + installedManifest.DefaultLocalization.Add(publisher); + auto installed = TestCompositePackage::Make(installedManifest, TestCompositePackage::MetadataMap{}, + std::vector{}, setup.Installed); + setup.Installed->SearchFunction = [&](const SearchRequest& request) + { + SearchResult result; + if (request.Purpose == SearchPurpose::CorrelationToInstalled) + { + for (const auto& inclusion : request.Inclusions) + { + if (inclusion.Field == PackageMatchField::NormalizedNameAndPublisher && inclusion.Additional && + ICUCaseInsensitiveEquals(inclusion.Value, name) && + ICUCaseInsensitiveEquals(inclusion.Additional.value(), publisher)) + { + result.Matches.emplace_back(installed, inclusion); + break; + } + } + } + return result; + }; + SearchRequest request; + request.Filters.emplace_back(cacheManifest ? PackageMatchField::Moniker : PackageMatchField::Name, + MatchType::Exact, cacheManifest ? "tool"sv : "Legacy App"sv); + auto result = setup.Composite.Search(request); + REQUIRE(result.Failures.empty()); + bool shouldCorrelate = nameIndex == publisherIndex; + size_t expectedCount = behavior == CompositeSearchBehavior::Installed && !shouldCorrelate ? 0 : 1; + REQUIRE(result.Matches.size() == expectedCount); + if (expectedCount) + { + REQUIRE(static_cast(GetInstalledVersion(result.Matches[0].Package)) == shouldCorrelate); + REQUIRE(result.Matches[0].Package->GetAvailable().size() == 1); + } + REQUIRE(setup.ManifestRequests == 1); +} diff --git a/src/AppInstallerCLITests/MatchCriteriaResolver.cpp b/src/AppInstallerCLITests/MatchCriteriaResolver.cpp index 2854eb71b2..acdcca824f 100644 --- a/src/AppInstallerCLITests/MatchCriteriaResolver.cpp +++ b/src/AppInstallerCLITests/MatchCriteriaResolver.cpp @@ -20,6 +20,523 @@ void RequireMatchCriteria(const PackageMatchFilter& expected, const PackageMatch REQUIRE(expected.Value == actual.Value); } +TEST_CASE("MatchCriteriaResolver_MatchesRequest", "[MatchCriteriaResolver]") +{ + struct MatchCase + { + MatchType Type; + std::string_view Query; + std::string_view Value; + bool Expected; + }; + + const MatchCase cases[] = + { + { MatchType::Exact, "Foo.Bar", "Foo.Bar", true }, + { MatchType::Exact, "foo.bar", "Foo.Bar", false }, + { MatchType::Exact, "Foo", "Foo.Bar", false }, + { MatchType::CaseInsensitive, "foo.bar", "Foo.Bar", true }, + { MatchType::CaseInsensitive, "foo", "Foo.Bar", false }, + { MatchType::StartsWith, "foo", "Foo.Bar", true }, + { MatchType::StartsWith, "bar", "Foo.Bar", false }, + { MatchType::Substring, "BAR", "Foo.Bar", true }, + { MatchType::Substring, "Baz", "Foo.Bar", false }, + { MatchType::Exact, "caf\xC3\xA9", "cafe\xCC\x81", true }, + { MatchType::CaseInsensitive, "CAF\xC3\x89", "caf\xC3\xA9", true }, + { MatchType::Exact, "", "Foo.Bar", false }, + }; + + for (const auto& test : cases) + { + CAPTURE(ToString(test.Type), test.Query, test.Value); + auto result = MatchesRequest(RequestMatch{ test.Type, test.Query }, test.Value); + REQUIRE(result.has_value()); + REQUIRE(result.value() == test.Expected); + } +} + +TEST_CASE("MatchCriteriaResolver_MatchesRequest_Unsupported", "[MatchCriteriaResolver]") +{ + auto type = GENERATE(MatchType::Fuzzy, MatchType::FuzzySubstring, MatchType::Wildcard); + REQUIRE_FALSE(MatchesRequest(RequestMatch{ type, "Foo" }, "Foo.Bar").has_value()); +} + +TEST_CASE("MatchCriteriaResolver_ManifestFields", "[MatchCriteriaResolver]") +{ + bool hasDefaultName = GENERATE(false, true); + CAPTURE(hasDefaultName); + Manifest::Manifest manifest; + manifest.Id = "Foo.Bar"; + manifest.Moniker = "FooBar"; + if (hasDefaultName) + { + manifest.DefaultLocalization.Add("Foo Bar"); + } + manifest.DefaultLocalization.Add({ "DefaultTag" }); + auto& localization = manifest.Localizations.emplace_back(); + localization.Add("Localized Name"); + localization.Add({ "Localized_Tag" }); + manifest.Localizations.emplace_back().Add(u8"Caf\u00E9"); + auto& installer = manifest.Installers.emplace_back(); + installer.Commands.emplace_back("Tool_Cmd"); + installer.PackageFamilyName = "Foo.Bar_123"; + installer.ProductCode = "Installer.Code"; + auto& entry = installer.AppsAndFeaturesEntries.emplace_back(); + entry.DisplayName = "Installed Name"; + entry.ProductCode = "ARP.Code"; + entry.UpgradeCode = "ARP.Upgrade"; + + struct MatchCase + { + PackageMatchField Field; + MatchType Type; + std::string_view Value; + std::optional Expected; + }; + + const MatchCase cases[] = + { + { PackageMatchField::Id, MatchType::Exact, "Foo.Bar", true }, + { PackageMatchField::Id, MatchType::Exact, "foo.bar", false }, + { PackageMatchField::Name, MatchType::Exact, "Foo Bar", hasDefaultName }, + { PackageMatchField::Name, MatchType::Exact, "foo bar", false }, + { PackageMatchField::Name, MatchType::CaseInsensitive, "foo bar", hasDefaultName }, + { PackageMatchField::Name, MatchType::Exact, "Localized Name", true }, + { PackageMatchField::Name, MatchType::Exact, "localized name", false }, + { PackageMatchField::Name, MatchType::Exact, "Installed Name", true }, + { PackageMatchField::Name, MatchType::Exact, "installed name", false }, + { PackageMatchField::Name, MatchType::StartsWith, "localized", true }, + { PackageMatchField::Name, MatchType::Substring, "NAME", true }, + { PackageMatchField::Name, MatchType::Exact, u8"Cafe\u0301", true }, + { PackageMatchField::Moniker, MatchType::Exact, "FooBar", true }, + { PackageMatchField::Moniker, MatchType::Exact, "foobar", false }, + { PackageMatchField::Moniker, MatchType::CaseInsensitive, "foobar", true }, + { PackageMatchField::Tag, MatchType::Exact, "DefaultTag", true }, + { PackageMatchField::Tag, MatchType::Exact, "Localized_Tag", true }, + { PackageMatchField::Tag, MatchType::Exact, "localized_tag", false }, + { PackageMatchField::Command, MatchType::Exact, "Tool_Cmd", true }, + { PackageMatchField::Command, MatchType::Exact, "tool_cmd", false }, + { PackageMatchField::PackageFamilyName, MatchType::Exact, "FOO.BAR_123", true }, + { PackageMatchField::ProductCode, MatchType::Exact, "INSTALLER.CODE", true }, + { PackageMatchField::ProductCode, MatchType::Exact, "ARP.CODE", true }, + { PackageMatchField::UpgradeCode, MatchType::Exact, "ARP.UPGRADE", true }, + { PackageMatchField::Name, MatchType::Fuzzy, "Foo", std::nullopt }, + { PackageMatchField::Name, MatchType::FuzzySubstring, "Foo", std::nullopt }, + { PackageMatchField::Name, MatchType::Wildcard, "Foo*", std::nullopt }, + { PackageMatchField::NormalizedNameAndPublisher, MatchType::Exact, "Foo Bar", std::nullopt }, + { PackageMatchField::Market, MatchType::Exact, "US", std::nullopt }, + { PackageMatchField::Unknown, MatchType::Exact, "Foo Bar", std::nullopt }, + }; + + for (const auto& test : cases) + { + CAPTURE(ToString(test.Field), ToString(test.Type), test.Value); + REQUIRE(MatchesRequest(PackageMatchFilter{ test.Field, test.Type, test.Value }, manifest) == test.Expected); + } + for (auto field : { PackageMatchField::Id, PackageMatchField::Name, PackageMatchField::Moniker, + PackageMatchField::Tag, PackageMatchField::Command, PackageMatchField::PackageFamilyName, + PackageMatchField::ProductCode, PackageMatchField::UpgradeCode }) + { + CAPTURE(ToString(field)); + REQUIRE(MatchesRequest(PackageMatchFilter{ field, MatchType::Exact, "Missing.Value" }, manifest) == std::optional{ false }); + } +} + +TEST_CASE("MatchCriteriaResolver_ManifestNormalizedNameAndPublisher_Unknown", "[MatchCriteriaResolver]") +{ + Manifest::Manifest manifest; + manifest.DefaultLocalization.Add("Foo Bar"); + manifest.DefaultLocalization.Add("Foo"); + auto name = GENERATE("Foo Bar", "Other Name"); + auto publisher = GENERATE("Foo", "Other Publisher"); + CAPTURE(name, publisher); + + PackageMatchFilter request{ PackageMatchField::NormalizedNameAndPublisher, MatchType::Exact, name, publisher }; + REQUIRE_FALSE(MatchesRequest(request, manifest).has_value()); +} + +TEST_CASE("MatchCriteriaResolver_ManifestEmptyFields", "[MatchCriteriaResolver]") +{ + Manifest::Manifest manifest; + auto field = GENERATE(PackageMatchField::Name, PackageMatchField::Moniker, PackageMatchField::Tag, PackageMatchField::Command, + PackageMatchField::PackageFamilyName, PackageMatchField::ProductCode, PackageMatchField::UpgradeCode); + auto type = GENERATE(MatchType::Exact, MatchType::CaseInsensitive, MatchType::StartsWith, MatchType::Substring); + CAPTURE(ToString(field), ToString(type)); + PackageMatchFilter request{ field, type, "" }; + REQUIRE(MatchesRequest(request, manifest) == std::optional{ false }); + request.Type = MatchType::Wildcard; + REQUIRE_FALSE(MatchesRequest(request, manifest).has_value()); +} + +TEST_CASE("MatchCriteriaResolver_SearchRequest", "[MatchCriteriaResolver]") +{ + const PackageMatchFilter idMatch{ PackageMatchField::Id, MatchType::CaseInsensitive, "microsoft.powertoys" }; + const PackageMatchFilter nameMatch{ PackageMatchField::Name, MatchType::Exact, "Microsoft PowerToys" }; + const PackageMatchFilter idMismatch{ PackageMatchField::Id, MatchType::Exact, "Other.Package" }; + const PackageMatchFilter unknown{ PackageMatchField::Moniker, MatchType::CaseInsensitive, "powertoys" }; + const PackageMatchFilter unsupported{ PackageMatchField::Id, MatchType::Fuzzy, "powertoys" }; + + struct MatchCase + { + std::string_view Name; + std::vector Filters; + std::vector Inclusions; + bool HasQuery; + std::optional Expected; + }; + + const MatchCase cases[] = + { + { "Empty request", {}, {}, false, true }, + { "All filters match", { idMatch, nameMatch }, {}, false, true }, + { "Every filter must match", { idMatch, idMismatch }, {}, false, false }, + { "Unknown filter", { idMatch, unknown }, {}, false, std::nullopt }, + { "Failed filter after unknown", { unknown, idMismatch }, {}, false, false }, + { "Any inclusion may match", {}, { idMismatch, nameMatch }, false, true }, + { "Failed inclusions", {}, { idMismatch }, false, false }, + { "Unknown inclusion may match", {}, { idMismatch, unknown }, false, std::nullopt }, + { "Match after unknown inclusion", {}, { unknown, idMatch }, false, true }, + { "Inclusion cannot override failed filter", { idMismatch }, { nameMatch }, false, false }, + { "Filters cannot override failed inclusions", { idMatch }, { idMismatch }, false, false }, + { "Matching inclusion with unknown filter", { unknown }, { idMatch }, false, std::nullopt }, + { "Failed inclusions with unknown filter", { unknown }, { idMismatch }, false, false }, + { "Failed filter with unknown inclusion", { idMismatch }, { unknown }, false, false }, + { "Unknown filter and inclusion", { unknown }, { unknown }, false, std::nullopt }, + { "Unsupported filter match type", { unsupported }, {}, false, std::nullopt }, + { "Unsupported inclusion match type", {}, { unsupported }, false, std::nullopt }, + { "Source-defined query", {}, {}, true, std::nullopt }, + { "Query may select despite failed inclusions", { idMatch }, { idMismatch }, true, std::nullopt }, + { "Query cannot override failed filter", { idMismatch }, { idMatch }, true, false }, + { "Matching inclusion alongside query", {}, { idMatch }, true, true }, + }; + + auto matchesField = [](const PackageMatchFilter& filter) -> std::optional + { + switch (filter.Field) + { + case PackageMatchField::Id: + return MatchesRequest(filter, "Microsoft.PowerToys"); + case PackageMatchField::Name: + return MatchesRequest(filter, "Microsoft PowerToys"); + default: + return std::nullopt; + } + }; + + for (const auto& test : cases) + { + CAPTURE(test.Name); + SearchRequest request; + request.Filters = test.Filters; + request.Inclusions = test.Inclusions; + if (test.HasQuery) + { + request.Query.emplace(MatchType::CaseInsensitive, "powertoys"); + } + REQUIRE(MatchesRequest(request, matchesField) == test.Expected); + } +} + +TEST_CASE("MatchCriteriaResolver_SearchRequestTruthTable", "[MatchCriteriaResolver]") +{ + auto firstFilter = GENERATE(std::optional{}, std::optional{ false }, std::optional{ true }); + auto secondFilter = GENERATE(std::optional{}, std::optional{ false }, std::optional{ true }); + auto firstInclusion = GENERATE(std::optional{}, std::optional{ false }, std::optional{ true }); + auto secondInclusion = GENERATE(std::optional{}, std::optional{ false }, std::optional{ true }); + bool hasQuery = GENERATE(false, true); + CAPTURE(firstFilter, secondFilter, firstInclusion, secondInclusion, hasQuery); + SearchRequest request; + request.Filters.emplace_back(PackageMatchField::Name, MatchType::Exact, "Name"); + request.Filters.emplace_back(PackageMatchField::Id, MatchType::Exact, "Id"); + request.Inclusions.emplace_back(PackageMatchField::Moniker, MatchType::Exact, "Moniker"); + request.Inclusions.emplace_back(PackageMatchField::Tag, MatchType::Exact, "Tag"); + if (hasQuery) + { + request.Query.emplace(MatchType::Substring, "Query"); + } + std::vector evaluatedFields; + auto matchesField = [&](const PackageMatchFilter& field) -> std::optional + { + evaluatedFields.emplace_back(field.Field); + switch (field.Field) + { + case PackageMatchField::Name: return firstFilter; + case PackageMatchField::Id: return secondFilter; + case PackageMatchField::Moniker: return firstInclusion; + case PackageMatchField::Tag: return secondInclusion; + default: THROW_HR(E_UNEXPECTED); + } + }; + + std::optional expected; + if (firstFilter == false || secondFilter == false || + (!hasQuery && firstInclusion == false && secondInclusion == false)) + { + expected = false; + } + else if (firstFilter == true && secondFilter == true && (firstInclusion == true || secondInclusion == true)) + { + expected = true; + } + REQUIRE(MatchesRequest(request, matchesField) == expected); + + std::vector expectedEvaluatedFields{ PackageMatchField::Name }; + if (firstFilter != false) + { + expectedEvaluatedFields.emplace_back(PackageMatchField::Id); + if (secondFilter != false) + { + expectedEvaluatedFields.emplace_back(PackageMatchField::Moniker); + if (firstInclusion != true) + { + expectedEvaluatedFields.emplace_back(PackageMatchField::Tag); + } + } + } + REQUIRE(evaluatedFields == expectedEvaluatedFields); +} + +TEST_CASE("MatchCriteriaResolver_ResolveUnknownCriteria", "[MatchCriteriaResolver]") +{ + Manifest::Manifest manifest; + manifest.Id = "Foo.Bar"; + manifest.DefaultLocalization.Add("Foo Bar"); + manifest.Moniker = "foobar"; + + const PackageMatchFilter idMatch{ PackageMatchField::Id, MatchType::Exact, "Foo.Bar" }; + const PackageMatchFilter idMismatch{ PackageMatchField::Id, MatchType::Exact, "Other.Package" }; + const PackageMatchFilter nameMatch{ PackageMatchField::Name, MatchType::Exact, "Foo Bar" }; + const PackageMatchFilter monikerMismatch{ PackageMatchField::Moniker, MatchType::Exact, "other" }; + const PackageMatchFilter unknown{ PackageMatchField::Market, MatchType::Exact, "US" }; + + struct MatchCase + { + std::string_view Name; + std::vector Filters; + std::vector Inclusions; + bool HasQuery; + std::optional Expected; + std::vector ResolvedFields; + }; + + const MatchCase cases[] = + { + { "Empty request", {}, {}, false, true, {} }, + { "Known matching filter", { idMatch }, {}, false, true, {} }, + { "Known failed filter after unknown", { nameMatch, idMismatch }, {}, false, false, {} }, + { "Known inclusion after unknown", {}, { nameMatch, idMatch }, false, true, {} }, + { "Failed inclusions with unknown filter", { nameMatch }, { idMismatch }, false, false, {} }, + { "Failed filter with unknown inclusion", { idMismatch }, { nameMatch }, false, false, {} }, + { "Resolve matching filter", { idMatch, nameMatch }, {}, false, true, { PackageMatchField::Name } }, + { "Resolve failing filter", { monikerMismatch }, {}, false, false, { PackageMatchField::Moniker } }, + { "Filter remains unknown", { unknown }, {}, false, std::nullopt, { PackageMatchField::Market } }, + { "Resolve matching inclusion", {}, { idMismatch, nameMatch }, false, true, { PackageMatchField::Name } }, + { "Resolve failing inclusion", {}, { monikerMismatch }, false, false, { PackageMatchField::Moniker } }, + { "Inclusion remains unknown", {}, { unknown }, false, std::nullopt, { PackageMatchField::Market } }, + { "Resolved mismatch stops later filters", { monikerMismatch, nameMatch }, {}, false, false, { PackageMatchField::Moniker } }, + { "Resolved mismatch avoids inclusion lookup", { monikerMismatch }, { nameMatch }, false, false, { PackageMatchField::Moniker } }, + { "Resolved match stops later inclusions", {}, { nameMatch, monikerMismatch }, false, true, { PackageMatchField::Name } }, + { "Resolve next inclusion after mismatch", {}, { monikerMismatch, nameMatch }, false, true, { PackageMatchField::Moniker, PackageMatchField::Name } }, + { "Unknown filter cannot override failed inclusions", { unknown }, { monikerMismatch }, false, false, { PackageMatchField::Market, PackageMatchField::Moniker } }, + { "Matching inclusion cannot prove unknown filter", { unknown }, { nameMatch }, false, std::nullopt, { PackageMatchField::Market, PackageMatchField::Name } }, + { "Resolved inclusion stops lookups despite unknown filter", { unknown }, { nameMatch, monikerMismatch }, false, std::nullopt, { PackageMatchField::Market, PackageMatchField::Name } }, + { "Matching filter cannot prove unknown inclusion", { nameMatch }, { unknown }, false, std::nullopt, { PackageMatchField::Name, PackageMatchField::Market } }, + { "Source-defined query", {}, {}, true, std::nullopt, {} }, + { "Query makes inclusion lookup unnecessary", {}, { nameMatch }, true, std::nullopt, {} }, + { "Query still requires filter resolution", { nameMatch }, { monikerMismatch }, true, std::nullopt, { PackageMatchField::Name } }, + { "Query cannot override resolved filter failure", { monikerMismatch }, { nameMatch }, true, false, { PackageMatchField::Moniker } }, + { "Known inclusion alongside query", {}, { nameMatch, idMatch }, true, true, {} }, + { "Resolve only filter when selection is known", { unknown }, { nameMatch, idMatch }, false, std::nullopt, { PackageMatchField::Market } }, + }; + + auto matchesField = [&](const PackageMatchFilter& field) -> std::optional + { + return field.Field == PackageMatchField::Id ? MatchesRequest(field, manifest.Id) : std::nullopt; + }; + for (const auto& test : cases) + { + CAPTURE(test.Name); + SearchRequest request; + request.Filters = test.Filters; + request.Inclusions = test.Inclusions; + if (test.HasQuery) + { + request.Query.emplace(MatchType::Substring, "Source-defined query"); + } + + std::vector resolvedFields; + auto resolveField = [&](const PackageMatchFilter& field) + { + resolvedFields.emplace_back(field.Field); + return MatchesRequest(field, manifest); + }; + REQUIRE(MatchesRequest(request, matchesField, resolveField) == test.Expected); + REQUIRE(resolvedFields == test.ResolvedFields); + } +} + +TEST_CASE("MatchCriteriaResolver_ResolutionAttemptsUnknownsOnce", "[MatchCriteriaResolver]") +{ + bool hasQuery = GENERATE(false, true); + CAPTURE(hasQuery); + SearchRequest request; + request.Filters.emplace_back(PackageMatchField::Name, MatchType::Exact, "Name"); + request.Filters.emplace_back(PackageMatchField::Id, MatchType::Exact, "Id"); + request.Inclusions.emplace_back(PackageMatchField::Moniker, MatchType::Exact, "Moniker"); + request.Inclusions.emplace_back(PackageMatchField::Tag, MatchType::Exact, "Tag"); + if (hasQuery) + { + request.Query.emplace(MatchType::Substring, "Query"); + } + size_t idEvaluations = 0; + auto matchesField = [&](const PackageMatchFilter& field) -> std::optional + { + if (field.Field == PackageMatchField::Id) + { + ++idEvaluations; + return true; + } + return std::nullopt; + }; + std::vector resolvedFields; + auto resolveField = [&](const PackageMatchFilter& field) -> std::optional + { + resolvedFields.emplace_back(field.Field); + return std::nullopt; + }; + std::vector expectedResolvedFields{ PackageMatchField::Name }; + if (!hasQuery) + { + expectedResolvedFields.insert(expectedResolvedFields.end(), { PackageMatchField::Moniker, PackageMatchField::Tag }); + } + + REQUIRE_FALSE(MatchesRequest(request, matchesField, resolveField).has_value()); + REQUIRE(resolvedFields == expectedResolvedFields); + REQUIRE(idEvaluations == 1); +} + +TEST_CASE("MatchCriteriaResolver_ResolutionReusesAvailableMetadata", "[MatchCriteriaResolver]") +{ + SearchRequest request; + request.Filters.emplace_back(PackageMatchField::Name, MatchType::Exact, "Foo Bar"); + request.Filters.emplace_back(PackageMatchField::Tag, MatchType::Exact, "utility"); + request.Inclusions.emplace_back(PackageMatchField::Moniker, MatchType::Exact, "foobar"); + + std::optional manifest; + auto matchesField = [&](const PackageMatchFilter& field) -> std::optional + { + return manifest ? MatchesRequest(field, manifest.value()) : std::nullopt; + }; + std::vector resolvedFields; + auto resolveField = [&](const PackageMatchFilter& field) + { + resolvedFields.emplace_back(field.Field); + auto& data = manifest.emplace(); + data.Id = "Foo.Bar"; + data.DefaultLocalization.Add("Foo Bar"); + data.DefaultLocalization.Add({ "utility" }); + data.Moniker = "foobar"; + return MatchesRequest(field, data); + }; + + REQUIRE(MatchesRequest(request, matchesField, resolveField) == std::optional{ true }); + REQUIRE(resolvedFields == std::vector{ PackageMatchField::Name }); +} + +TEST_CASE("MatchCriteriaResolver_ResolutionRefreshesEarlierCriteria", "[MatchCriteriaResolver]") +{ + const PackageMatchFilter nameMatch{ PackageMatchField::Name, MatchType::Exact, "Foo Bar" }; + const PackageMatchFilter nameMismatch{ PackageMatchField::Name, MatchType::Exact, "Other" }; + const PackageMatchFilter monikerMatch{ PackageMatchField::Moniker, MatchType::Exact, "foobar" }; + const PackageMatchFilter monikerMismatch{ PackageMatchField::Moniker, MatchType::Exact, "other" }; + const PackageMatchFilter tagMatch{ PackageMatchField::Tag, MatchType::Exact, "utility" }; + SearchRequest request; + request.Filters = { nameMismatch }; + request.Inclusions = { monikerMatch }; + std::optional expected = false; + std::vector expectedResolvedFields{ PackageMatchField::Name, PackageMatchField::Moniker }; + bool tagKnownOnlyToResolver = false; + + SECTION("Inclusion resolution reveals a failed filter") {} + SECTION("Inclusion resolution confirms an earlier filter") + { + request.Filters = { nameMatch }; + expected = true; + } + SECTION("Later filter resolution reveals an earlier failure") + { + request.Filters = { nameMismatch, tagMatch }; + request.Filters.emplace_back(PackageMatchField::Market, MatchType::Exact, "US"); + expectedResolvedFields = { PackageMatchField::Name, PackageMatchField::Tag }; + } + SECTION("Later inclusion resolution reveals an earlier match") + { + request.Filters.clear(); + request.Inclusions = { nameMatch, monikerMismatch }; + expected = true; + } + SECTION("Later inclusion resolution rules out all alternatives") + { + request.Filters.clear(); + request.Inclusions = { nameMismatch, monikerMismatch }; + } + SECTION("Refreshing unknowns preserves definitive resolver answers") + { + request.Filters = { tagMatch, nameMatch }; + tagKnownOnlyToResolver = true; + expected = true; + expectedResolvedFields = { PackageMatchField::Tag, PackageMatchField::Name, PackageMatchField::Moniker }; + } + + std::optional manifest; + auto matchesField = [&](const PackageMatchFilter& field) -> std::optional + { + if (tagKnownOnlyToResolver && field.Field == PackageMatchField::Tag) + { + return std::nullopt; + } + return manifest ? MatchesRequest(field, manifest.value()) : std::nullopt; + }; + std::vector resolvedFields; + auto resolveField = [&](const PackageMatchFilter& field) -> std::optional + { + resolvedFields.emplace_back(field.Field); + if (field.Field == PackageMatchField::Name) + { + return std::nullopt; + } + if (tagKnownOnlyToResolver && field.Field == PackageMatchField::Tag) + { + return true; + } + auto& data = manifest.emplace(); + data.Id = "Foo.Bar"; + data.DefaultLocalization.Add("Foo Bar"); + data.DefaultLocalization.Add({ "utility" }); + data.Moniker = "foobar"; + return MatchesRequest(field, data); + }; + + REQUIRE(MatchesRequest(request, matchesField, resolveField) == expected); + REQUIRE(resolvedFields == expectedResolvedFields); +} + +TEST_CASE("MatchCriteriaResolver_ResolutionFailure", "[MatchCriteriaResolver]") +{ + SearchRequest request; + request.Filters.emplace_back(PackageMatchField::Name, MatchType::Exact, "Foo Bar"); + auto matchesField = [](const PackageMatchFilter&) -> std::optional + { + return std::nullopt; + }; + auto resolveField = [](const PackageMatchFilter&) -> std::optional + { + THROW_HR(E_ACCESSDENIED); + }; + + REQUIRE_THROWS_HR(MatchesRequest(request, matchesField, resolveField), E_ACCESSDENIED); +} + TEST_CASE("MatchCriteriaResolver_MatchType", "[MatchCriteriaResolver]") { Manifest::Manifest manifest; diff --git a/src/AppInstallerCLITests/RestInterface_1_0.cpp b/src/AppInstallerCLITests/RestInterface_1_0.cpp index 8998685a39..25390594d0 100644 --- a/src/AppInstallerCLITests/RestInterface_1_0.cpp +++ b/src/AppInstallerCLITests/RestInterface_1_0.cpp @@ -3,10 +3,13 @@ #include "pch.h" #include "TestCommon.h" #include "TestRestRequestHandler.h" +#include #include +#include #include #include #include +#include #include #include @@ -23,6 +26,57 @@ namespace { const std::string TestRestUriString = "http://restsource.com/api"; + utility::string_t GetSearchResponse_PackageIds( + std::initializer_list identifiers, const utility::string_t& continuationToken = {}) + { + web::json::value response; + response[L"Data"] = web::json::value::array(); + size_t index = 0; + for (const auto& identifier : identifiers) + { + web::json::value package; + package[L"PackageIdentifier"] = web::json::value::string(identifier); + package[L"PackageName"] = web::json::value::string(L"Microsoft Teams"); + package[L"Publisher"] = web::json::value::string(L"Microsoft"); + package[L"Versions"][0][L"PackageVersion"] = web::json::value::string(L"1.0.0"); + response[L"Data"][index++] = std::move(package); + } + + if (!continuationToken.empty()) + { + response[L"ContinuationToken"] = web::json::value::string(continuationToken); + } + + return response.serialize(); + } + + struct CachedMetadataInterface : Interface + { + using Interface::Interface; + std::vector Versions; + + protected: + IRestClient::SearchResult GetSearchResult(const web::json::value& response) const override + { + auto result = Interface::GetSearchResult(response); + result.Matches.at(0).Versions = Versions; + return result; + } + }; + + struct QueryValidationTrackingInterface : V1_1::Interface + { + using V1_1::Interface::Interface; + mutable size_t QueryValidations = 0; + + protected: + std::map GetValidatedQueryParams(const std::map& params) const override + { + ++QueryValidations; + return V1_1::Interface::GetValidatedQueryParams(params); + } + }; + utility::string_t GetGoodManifest_RequiredFields() { return _XPLATSTR( @@ -101,6 +155,54 @@ namespace })delimiter"); } + struct SearchAndManifestResponses + { + web::json::value SearchResponse = web::json::value::parse(GetSearchResponse_PackageIds({ L"Foo.Bar" })); + web::json::value ManifestResponse = web::json::value::parse(GetGoodManifest_RequiredFields()); + web::http::status_code ManifestStatus = web::http::status_codes::OK; + size_t SearchRequests = 0; + size_t ManifestRequests = 0; + web::http::http_request LastSearchRequest; + web::http::http_request LastManifestRequest; + + SearchAndManifestResponses() + { + ManifestResponse[L"Data"][L"Versions"][0][L"PackageVersion"] = web::json::value::string(L"1.0.0"); + } + + void SetManifestNotFound() + { + ManifestStatus = web::http::status_codes::NotFound; + ManifestResponse = web::json::value::parse(LR"({"code":"DataNotFound","message":"Not found"})"); + } + + std::shared_ptr GetHandler() + { + return std::make_shared( + [this](web::http::http_request request) -> pplx::task + { + web::http::http_response response{ web::http::status_codes::BadRequest }; + response.headers().set_content_type(web::http::details::mime_types::application_json); + response.headers().set_cache_control(L"no-store"); + if (request.method() == web::http::methods::POST) + { + ++SearchRequests; + LastSearchRequest = request; + response.set_status_code(web::http::status_codes::OK); + response.set_body(SearchResponse); + } + else if (request.method() == web::http::methods::GET) + { + ++ManifestRequests; + LastManifestRequest = request; + response.set_status_code(ManifestStatus); + response.set_body(ManifestResponse); + } + return pplx::task_from_result(response); + }); + } + }; + struct GoodManifest_AllFields { utility::string_t GetSampleManifest_AllFields() @@ -398,39 +500,1421 @@ TEST_CASE("Search_GoodResponse_404AsEmpty", "[RestSource][Interface_1_0]") REQUIRE(searchResponse.Matches.size() == 0); } -TEST_CASE("Search_ContinuationToken", "[RestSource][Interface_1_0]") +TEST_CASE("Search_ExplicitIdFilters", "[RestSource][Interface_1_0]") { - utility::string_t sample = _XPLATSTR( - R"delimiter({ - "Data" : [ - { - "PackageIdentifier": "git.package", - "PackageName": "package", - "Publisher": "git", - "Versions": [ - { "PackageVersion": "1.0.0" }] - }, + HttpClientHelper helper{ GetTestRestRequestHandler(web::http::status_codes::OK, + GetSearchResponse_PackageIds({ L"XP8BT8DW290MPQ", L"Microsoft.Teams", L"Microsoft.Teams.Preview" })) }; + Interface v1{ TestRestUriString, helper }; + SearchRequest request; + request.Filters.emplace_back(PackageMatchField::Name, MatchType::CaseInsensitive, "Microsoft Teams"); + std::vector expected{ "Microsoft.Teams" }; + + SECTION("Exact") + { + request.Filters.emplace_back(PackageMatchField::Id, MatchType::Exact, "Microsoft.Teams"); + } + SECTION("Exact case mismatch") + { + request.Filters.emplace_back(PackageMatchField::Id, MatchType::Exact, "microsoft.teams"); + expected.clear(); + } + SECTION("Case insensitive") + { + request.Filters.emplace_back(PackageMatchField::Id, MatchType::CaseInsensitive, "microsoft.teams"); + } + SECTION("Starts with") + { + request.Filters.emplace_back(PackageMatchField::Id, MatchType::StartsWith, "microsoft.teams"); + expected.emplace_back("Microsoft.Teams.Preview"); + } + SECTION("Substring") + { + request.Filters.emplace_back(PackageMatchField::Id, MatchType::Substring, "teams"); + expected.emplace_back("Microsoft.Teams.Preview"); + } + SECTION("All ID filters must match") + { + request.Filters.emplace_back(PackageMatchField::Id, MatchType::Exact, "Microsoft.Teams"); + request.Filters.emplace_back(PackageMatchField::Id, MatchType::Exact, "Microsoft.Teams.Preview"); + expected.clear(); + } + SECTION("Query and inclusions cannot override a failed filter") + { + request.Query.emplace(MatchType::Substring, "Teams"); + request.Inclusions.emplace_back(PackageMatchField::Name, MatchType::Exact, "Microsoft Teams"); + request.Filters.emplace_back(PackageMatchField::Id, MatchType::Exact, "Other.Package"); + expected.clear(); + } + + auto result = v1.Search(request); + REQUIRE(result.Matches.size() == expected.size()); + REQUIRE_FALSE(result.Truncated); + for (size_t i = 0; i < expected.size(); ++i) + { + REQUIRE(result.Matches[i].PackageInformation.PackageIdentifier == expected[i]); + } +} + +TEST_CASE("Search_ExplicitIdFilters_UnicodePrefix", "[RestSource][Interface_1_0]") +{ + std::wstring id = GENERATE(L"Vendor.\u1E9EApp", L"Vendor.\u00DFApp", L"Vendor.SSApp"); + std::string prefix = GENERATE(u8"vendor.\u00DF", u8"vendor.\u1E9E", "vendor.ss"); + CAPTURE(id, prefix); + HttpClientHelper helper{ GetTestRestRequestHandler(web::http::status_codes::OK, + GetSearchResponse_PackageIds({ L"Vendor.Other", id })) }; + Interface v1{ TestRestUriString, helper }; + SearchRequest request; + request.Filters.emplace_back(PackageMatchField::Id, MatchType::StartsWith, prefix); + request.MaximumResults = 1; + + auto result = v1.Search(request); + REQUIRE(result.Matches.size() == 1); + REQUIRE(result.Matches[0].PackageInformation.PackageIdentifier == ConvertToUTF8(id)); + REQUIRE_FALSE(result.Truncated); +} + +TEST_CASE("Search_IdInclusions", "[RestSource][Interface_1_0]") +{ + SearchAndManifestResponses responses; + responses.SearchResponse = web::json::value::parse(GetSearchResponse_PackageIds({ L"Foo.Bar", L"Foo.Baz", L"Other.Package" })); + responses.SetManifestNotFound(); + HttpClientHelper helper{ responses.GetHandler() }; + Interface v1{ TestRestUriString, helper }; + SearchRequest request; + request.Inclusions.emplace_back(PackageMatchField::Id, MatchType::Exact, "Foo.Bar"); + const std::vector allIds{ "Foo.Bar", "Foo.Baz", "Other.Package" }; + std::vector expected{ "Foo.Bar" }; + size_t expectedManifestRequests = 0; + + SECTION("Matching inclusion") {} + SECTION("Any inclusion may match") + { + request.Inclusions.emplace_back(PackageMatchField::Id, MatchType::Exact, "Other.Package"); + expected.emplace_back("Other.Package"); + } + SECTION("No matching inclusion") + { + request.Inclusions[0].Value = "Missing.Package"; + expected.clear(); + } + SECTION("Filters narrow matching inclusions") + { + request.Inclusions.emplace_back(PackageMatchField::Id, MatchType::Exact, "Other.Package"); + request.Filters.emplace_back(PackageMatchField::Id, MatchType::StartsWith, "Foo."); + } + SECTION("Matching filters cannot override failed inclusions") + { + request.Inclusions[0].Value = "Missing.Package"; + request.Filters.emplace_back(PackageMatchField::Id, MatchType::Exact, "Foo.Bar"); + expected.clear(); + } + SECTION("Unknown inclusion preserves candidates") + { + request.Inclusions.emplace_back(PackageMatchField::Name, MatchType::Exact, "Localized name"); + expected = allIds; + expectedManifestRequests = 2; + } + SECTION("Unknown filter does not disable inclusion matching") + { + request.Filters.emplace_back(PackageMatchField::Name, MatchType::Exact, "Localized name"); + expectedManifestRequests = 1; + } + SECTION("A query may select independently of inclusions") + { + request.Query.emplace(MatchType::Substring, "Source-defined query"); + expected = allIds; + } + SECTION("Unsupported match types remain unknown") + { + request.Inclusions[0].Type = GENERATE(MatchType::Fuzzy, MatchType::FuzzySubstring, MatchType::Wildcard); + expected = allIds; + } + + auto result = v1.Search(request); + REQUIRE(responses.SearchRequests == 1); + REQUIRE(responses.ManifestRequests == expectedManifestRequests); + REQUIRE(result.Matches.size() == expected.size()); + REQUIRE_FALSE(result.Truncated); + for (size_t i = 0; i < expected.size(); ++i) + { + REQUIRE(result.Matches[i].PackageInformation.PackageIdentifier == expected[i]); + } +} + +TEST_CASE("Search_CachedManifestMetadata", "[RestSource][Interface_1_0]") +{ + auto field = GENERATE(PackageMatchField::Name, PackageMatchField::Moniker, PackageMatchField::Tag, + PackageMatchField::Command, PackageMatchField::PackageFamilyName, PackageMatchField::ProductCode, + PackageMatchField::UpgradeCode); + bool useInclusions = GENERATE(false, true); + CAPTURE(ToString(field), useInclusions); + SearchAndManifestResponses responses; + responses.SetManifestNotFound(); + HttpClientHelper helper{ responses.GetHandler() }; + CachedMetadataInterface v1{ TestRestUriString, helper }; + auto createManifest = [](std::string_view version, const NormalizedString& value) + { + Manifest manifest; + manifest.Id = "Foo.Bar"; + manifest.Version = version; + manifest.Moniker = value; + manifest.DefaultLocalization.Add(value); + manifest.DefaultLocalization.Add({ value }); + auto& installer = manifest.Installers.emplace_back(); + installer.Commands.emplace_back(value); + installer.PackageFamilyName = value; + installer.ProductCode = value; + installer.AppsAndFeaturesEntries.emplace_back().UpgradeCode = value; + return manifest; + }; + v1.Versions.emplace_back(VersionAndChannel{ Version{ "1.0.0" }, Channel{} }, createManifest("1.0.0", "Other")); + v1.Versions.emplace_back(VersionAndChannel{ Version{ "2.0.0" }, Channel{} }, createManifest("2.0.0", "Other")); + SearchRequest request; + request.Filters.emplace_back(PackageMatchField::Id, MatchType::Exact, "Foo.Bar"); + auto& criteria = useInclusions ? request.Inclusions : request.Filters; + criteria.emplace_back(field, MatchType::Exact, "Match"); + size_t expectedCount = 0; + size_t expectedManifestRequests = 0; + + SECTION("All cached versions mismatch") {} + SECTION("An earlier cached version matches") + { + v1.Versions[0].Manifest = createManifest("1.0.0", "Match"); + expectedCount = 1; + } + SECTION("A later cached version matches") + { + v1.Versions[1].Manifest = createManifest("2.0.0", "Match"); + expectedCount = 1; + } + SECTION("An uncached version preserves unknown") + { + v1.Versions[0].Manifest.reset(); + expectedCount = 1; + expectedManifestRequests = 1; + } + SECTION("A match alongside an uncached version is retained") + { + v1.Versions[0].Manifest.reset(); + v1.Versions[1].Manifest = createManifest("2.0.0", "Match"); + expectedCount = 1; + } + SECTION("Missing manifests remain unknown") + { + v1.Versions[0].Manifest.reset(); + v1.Versions[1].Manifest.reset(); + expectedCount = 1; + expectedManifestRequests = 1; + } + SECTION("Unsupported comparisons remain unknown") + { + criteria.back().Type = GENERATE(MatchType::Fuzzy, MatchType::FuzzySubstring, MatchType::Wildcard); + expectedCount = 1; + } + SECTION("A query can select independently but cannot bypass filters") + { + request.Query.emplace(MatchType::Substring, "Source-defined query"); + expectedCount = useInclusions ? size_t{ 1 } : size_t{ 0 }; + } + SECTION("An ID mismatch still rejects matching metadata") + { + request.Filters[0].Value = "Other.Package"; + v1.Versions[1].Manifest = createManifest("2.0.0", "Match"); + } + + auto result = v1.Search(request); + REQUIRE(result.Matches.size() == expectedCount); + REQUIRE(responses.SearchRequests == 1); + REQUIRE(responses.ManifestRequests == expectedManifestRequests); + REQUIRE_FALSE(result.Truncated); +} + +TEST_CASE("Search_ReturnedMetadata", "[RestSource][Interface_1_0]") +{ + auto field = GENERATE(PackageMatchField::Name, PackageMatchField::PackageFamilyName, + PackageMatchField::ProductCode, PackageMatchField::UpgradeCode); + auto type = GENERATE(MatchType::Exact, MatchType::CaseInsensitive); + CAPTURE(ToString(field), ToString(type)); + SearchAndManifestResponses responses; + responses.SetManifestNotFound(); + HttpClientHelper helper{ responses.GetHandler() }; + CachedMetadataInterface v1{ TestRestUriString, helper }; + Manifest manifest; + manifest.Id = "Foo.Bar"; + manifest.Version = "1.0.0"; + manifest.DefaultLocalization.Add("Other"); + v1.Versions.emplace_back(VersionAndChannel{ manifest.Version, manifest.Channel }, manifest); + auto setReturnedValue = [&](std::string_view value) + { + switch (field) + { + case PackageMatchField::Name: + responses.SearchResponse[L"Data"][0][L"PackageName"] = web::json::value::string(ConvertToUTF16(value)); + break; + case PackageMatchField::PackageFamilyName: + v1.Versions[0].PackageFamilyNames = { std::string{ value } }; + break; + case PackageMatchField::ProductCode: + v1.Versions[0].ProductCodes = { std::string{ value } }; + break; + case PackageMatchField::UpgradeCode: + v1.Versions[0].UpgradeCodes = { std::string{ value } }; + break; + } + }; + setReturnedValue("Match"); + SearchRequest request; + request.Filters.emplace_back(field, type, "Match"); + size_t expectedCount = 1; + size_t expectedManifestRequests = 0; + + SECTION("Returned metadata confirms a match despite cached metadata") {} + SECTION("Case differences follow field-specific rules") + { + request.Filters[0].Value = "match"; + if (field == PackageMatchField::Name && type == MatchType::Exact) + { + expectedCount = 0; + } + } + SECTION("Nonmatching response metadata is inconclusive without a manifest") + { + setReturnedValue("Other"); + v1.Versions[0].Manifest.reset(); + expectedManifestRequests = 1; + } + + auto result = v1.Search(request); + REQUIRE(result.Matches.size() == expectedCount); + REQUIRE(responses.SearchRequests == 1); + REQUIRE(responses.ManifestRequests == expectedManifestRequests); + REQUIRE_FALSE(result.Truncated); +} + +TEST_CASE("Search_ManifestResolution_Fields", "[RestSource][Interface_1_4]") +{ + auto field = GENERATE(PackageMatchField::Name, PackageMatchField::Moniker, PackageMatchField::Tag, + PackageMatchField::Command, PackageMatchField::PackageFamilyName, PackageMatchField::ProductCode, + PackageMatchField::UpgradeCode); + bool useInclusions = GENERATE(false, true); + CAPTURE(ToString(field), useInclusions); + SearchAndManifestResponses responses; + auto& version = responses.ManifestResponse[L"Data"][L"Versions"][0]; + std::string value = "Wanted"; + switch (field) + { + case PackageMatchField::Name: + version[L"Locales"][0][L"PackageLocale"] = web::json::value::string(L"fr-FR"); + version[L"Locales"][0][L"PackageName"] = web::json::value::string(L"Wanted"); + break; + case PackageMatchField::Moniker: + version[L"DefaultLocale"][L"Moniker"] = web::json::value::string(L"Wanted"); + break; + case PackageMatchField::Tag: + version[L"DefaultLocale"][L"Tags"][0] = web::json::value::string(L"Wanted"); + break; + case PackageMatchField::Command: + version[L"Installers"][0][L"Commands"][0] = web::json::value::string(L"Wanted"); + break; + case PackageMatchField::PackageFamilyName: + value = "Test.Package_8wekyb3d8bbwe"; + version[L"Installers"][0][L"InstallerType"] = web::json::value::string(L"msix"); + version[L"Installers"][0][L"PackageFamilyName"] = web::json::value::string(ConvertToUTF16(value)); + break; + case PackageMatchField::ProductCode: + value = "{A0000000-0000-0000-0000-000000000001}"; + version[L"Installers"][0][L"ProductCode"] = web::json::value::string(ConvertToUTF16(value)); + break; + case PackageMatchField::UpgradeCode: + value = "{A0000000-0000-0000-0000-000000000002}"; + version[L"Installers"][0][L"AppsAndFeaturesEntries"][0][L"UpgradeCode"] = web::json::value::string(ConvertToUTF16(value)); + break; + } + size_t expectedCount = 1; + bool hasManifest = true; + SECTION("Retrieved metadata matches") {} + SECTION("Retrieved metadata rejects the candidate") + { + value = "Missing"; + expectedCount = 0; + } + SECTION("Missing manifests preserve unknown") + { + responses.SetManifestNotFound(); + hasManifest = false; + } + + HttpClientHelper helper{ responses.GetHandler() }; + V1_4::Interface rest{ TestRestUriString, helper, {} }; + SearchRequest request; + auto& criteria = useInclusions ? request.Inclusions : request.Filters; + criteria.emplace_back(field, MatchType::Exact, value); + auto result = rest.Search(request); + REQUIRE(responses.SearchRequests == 1); + REQUIRE(responses.ManifestRequests == 1); + REQUIRE(responses.LastManifestRequest.absolute_uri().path() == L"/api/packageManifests/Foo.Bar"); + REQUIRE(result.Matches.size() == expectedCount); + REQUIRE_FALSE(result.Truncated); + if (expectedCount) + { + REQUIRE(result.Matches[0].PackageInformation.PackageName == + ConvertToUTF8(responses.SearchResponse[L"Data"][0][L"PackageName"].as_string())); + REQUIRE(result.Matches[0].Versions[0].Manifest.has_value() == hasManifest); + REQUIRE(result.Matches[0].Versions[0].VersionAndChannel.GetVersion().ToString() == "1.0.0"); + } +} + +TEST_CASE("Search_ManifestResolution_PositionalQuery", "[RestSource][Interface_1_0]") +{ + std::string query = GENERATE("browser", "."); + bool matches = GENERATE(false, true); + bool unknownVersion = GENERATE(false, true); + CAPTURE(query, matches, unknownVersion); + SearchAndManifestResponses responses; + responses.SearchResponse[L"Data"][0][L"PackageName"] = web::json::value::string(L"Unrelated application"); + if (unknownVersion) + { + responses.SearchResponse[L"Data"][0][L"Versions"][0][L"PackageVersion"] = web::json::value::string(L"Unknown"); + } + if (matches) + { + responses.ManifestResponse[L"Data"][L"Versions"][0][L"DefaultLocale"][L"PackageName"] = + web::json::value::string(ConvertToUTF16(query)); + } + HttpClientHelper helper{ responses.GetHandler() }; + Interface rest{ TestRestUriString, helper }; + SearchRequest request; + request.Inclusions.emplace_back(PackageMatchField::PackageFamilyName, MatchType::Exact, query); + request.Inclusions.emplace_back(PackageMatchField::ProductCode, MatchType::Exact, query); + request.Inclusions.emplace_back(PackageMatchField::Id, MatchType::CaseInsensitive, query); + request.Inclusions.emplace_back(PackageMatchField::Name, MatchType::CaseInsensitive, query); + request.Inclusions.emplace_back(PackageMatchField::Moniker, MatchType::CaseInsensitive, query); + + auto result = rest.Search(request); + REQUIRE(responses.SearchRequests == 1); + REQUIRE(responses.ManifestRequests == 1); + REQUIRE(result.Matches.size() == (matches ? size_t{ 1 } : size_t{ 0 })); + if (matches) + { + REQUIRE(result.Matches[0].Versions[0].Manifest.has_value()); + REQUIRE(result.Matches[0].Versions[0].VersionAndChannel.GetVersion().ToString() == "1.0.0"); + } +} + +TEST_CASE("Search_ManifestResolution_SkipsUnnecessaryLookups", "[RestSource][Interface_1_0]") +{ + SearchAndManifestResponses responses; + responses.ManifestStatus = web::http::status_codes::ServiceUnavailable; + SearchRequest request; + size_t expectedCount = 1; + SECTION("No selectors") {} + SECTION("Generic query") + { + request.Query.emplace(MatchType::Substring, "browser"); + } + SECTION("Generic query can select independently") + { + request.Query.emplace(MatchType::Substring, "browser"); + request.Inclusions.emplace_back(PackageMatchField::Moniker, MatchType::Exact, "browser"); + } + SECTION("Known inclusion matches after unknown inclusion") + { + request.Inclusions.emplace_back(PackageMatchField::Moniker, MatchType::Exact, "browser"); + request.Inclusions.emplace_back(PackageMatchField::Id, MatchType::Exact, "Foo.Bar"); + } + SECTION("Known filter fails after unknown filter") + { + request.Filters.emplace_back(PackageMatchField::Moniker, MatchType::Exact, "browser"); + request.Filters.emplace_back(PackageMatchField::Id, MatchType::Exact, "Other.Package"); + expectedCount = 0; + } + SECTION("Known inclusions fail despite unknown filter") + { + request.Filters.emplace_back(PackageMatchField::Moniker, MatchType::Exact, "browser"); + request.Inclusions.emplace_back(PackageMatchField::Id, MatchType::Exact, "Other.Package"); + expectedCount = 0; + } + SECTION("Unsupported comparison") + { + auto type = GENERATE(MatchType::Fuzzy, MatchType::FuzzySubstring, MatchType::Wildcard); + request.Filters.emplace_back(PackageMatchField::Name, type, "browser"); + } + SECTION("Market remains unvalidated") + { + request.Filters.emplace_back(PackageMatchField::Market, MatchType::Exact, "value"); + } + SECTION("Normalized pair remains unvalidated") + { + request.Purpose = GENERATE(SearchPurpose::Default, SearchPurpose::CorrelationToInstalled, SearchPurpose::CorrelationToAvailable); + bool useInclusions = GENERATE(false, true); + CAPTURE(request.Purpose, useInclusions); + request.Filters.emplace_back(PackageMatchField::Id, MatchType::Exact, "Foo.Bar"); + auto& criteria = useInclusions ? request.Inclusions : request.Filters; + criteria.emplace_back(PackageMatchField::NormalizedNameAndPublisher, MatchType::Exact, "Other Name", "Other Publisher"); + } + SECTION("Installed-package correlation") + { + request.Purpose = GENERATE(SearchPurpose::CorrelationToInstalled, SearchPurpose::CorrelationToAvailable); + request.Inclusions.emplace_back(PackageMatchField::ProductCode, MatchType::Exact, "Missing.Code"); + } + SECTION("Returned name proves the filter") + { + request.Filters.emplace_back(PackageMatchField::Name, MatchType::Exact, + ConvertToUTF8(responses.SearchResponse[L"Data"][0][L"PackageName"].as_string())); + } + HttpClientHelper helper{ responses.GetHandler() }; + Interface rest{ TestRestUriString, helper }; + auto result = rest.Search(request); + REQUIRE(result.Matches.size() == expectedCount); + REQUIRE(responses.SearchRequests == 1); + REQUIRE(responses.ManifestRequests == 0); +} + +TEST_CASE("Search_ManifestResolution_Errors", "[RestSource][Interface_1_1]") +{ + SearchAndManifestResponses responses; + HRESULT expectedError = APPINSTALLER_CLI_ERROR_RESTSOURCE_INVALID_DATA; + SECTION("Mismatching package identifier") + { + responses.ManifestResponse[L"Data"][L"PackageIdentifier"] = web::json::value::string(L"Other.Package"); + } + SECTION("Malformed manifest data") + { + responses.ManifestResponse[L"Data"][L"Versions"] = web::json::value::array(); + } + SECTION("Invalid installer") + { + responses.ManifestResponse[L"Data"][L"Versions"][0][L"Installers"][0].as_object().erase(L"InstallerUrl"); + } + SECTION("Access denied") + { + responses.ManifestStatus = web::http::status_codes::Unauthorized; + expectedError = HTTP_E_STATUS_DENIED; + } + SECTION("Service unavailable") + { + responses.ManifestStatus = web::http::status_codes::ServiceUnavailable; + expectedError = APPINSTALLER_CLI_ERROR_SERVICE_UNAVAILABLE; + } + SECTION("Unsupported request reported by the server") + { + responses.ManifestResponse = web::json::value::parse(LR"({"Data":null,"RequiredQueryParameters":["Version"]})"); + expectedError = APPINSTALLER_CLI_ERROR_UNSUPPORTED_SOURCE_REQUEST; + } + HttpClientHelper helper{ responses.GetHandler() }; + V1_1::Interface rest{ TestRestUriString, helper, {} }; + SearchRequest request; + request.Filters.emplace_back(PackageMatchField::Name, MatchType::Exact, "Bar"); + REQUIRE_THROWS_HR(rest.Search(request), expectedError); + REQUIRE(responses.SearchRequests == 1); + REQUIRE(responses.ManifestRequests == 1); +} + +TEST_CASE("Search_ManifestResolution_Versions", "[RestSource][Interface_1_0]") +{ + SearchAndManifestResponses responses; + auto firstManifest = responses.ManifestResponse[L"Data"][L"Versions"][0]; + firstManifest[L"DefaultLocale"][L"Moniker"] = web::json::value::string(L"other"); + auto secondManifest = firstManifest; + secondManifest[L"PackageVersion"] = web::json::value::string(L"2.0.0"); + secondManifest[L"DefaultLocale"][L"Moniker"] = web::json::value::string(L"target"); + responses.ManifestResponse[L"Data"][L"Versions"] = web::json::value::array({ firstManifest, secondManifest }); + responses.SearchResponse[L"Data"][0][L"Versions"][1][L"PackageVersion"] = web::json::value::string(L"2.0.0"); + size_t expectedCount = 1; + bool secondManifestCached = true; + std::string expectedVersion = "2.0.0"; + std::string expectedChannel; + + SECTION("A different version can match") {} + SECTION("Identifier casing is not a different package") + { + responses.ManifestResponse[L"Data"][L"PackageIdentifier"] = web::json::value::string(L"foo.bar"); + } + SECTION("Canonically equivalent identifiers are the same package") + { + responses.SearchResponse[L"Data"][0][L"PackageIdentifier"] = web::json::value::string(L"Foo.Cafe\u0301"); + responses.ManifestResponse[L"Data"][L"PackageIdentifier"] = web::json::value::string(L"Foo.Caf\u00E9"); + } + SECTION("An omitted version keeps the result unknown") + { + responses.ManifestResponse[L"Data"][L"Versions"] = web::json::value::array({ firstManifest }); + secondManifestCached = false; + } + SECTION("A manifest from another channel cannot fill the missing version") + { + responses.SearchResponse[L"Data"][0][L"Versions"][1][L"Channel"] = web::json::value::string(L"beta"); + expectedChannel = "beta"; + secondManifestCached = false; + } + SECTION("Version matching ignores casing") + { + expectedVersion = "2.0.0-BETA"; + responses.SearchResponse[L"Data"][0][L"Versions"][1][L"PackageVersion"] = web::json::value::string(ConvertToUTF16(expectedVersion)); + responses.ManifestResponse[L"Data"][L"Versions"][1][L"PackageVersion"] = web::json::value::string(L"2.0.0-beta"); + } + SECTION("Version matching does not normalize version parts") + { + expectedVersion = "2.0"; + responses.SearchResponse[L"Data"][0][L"Versions"][1][L"PackageVersion"] = web::json::value::string(ConvertToUTF16(expectedVersion)); + secondManifestCached = false; + } + SECTION("All versions reject the request") + { + responses.ManifestResponse[L"Data"][L"Versions"][1][L"DefaultLocale"][L"Moniker"] = web::json::value::string(L"other"); + expectedCount = 0; + } + SECTION("A version outside the search result cannot satisfy the request") + { + responses.ManifestResponse[L"Data"][L"Versions"][1][L"DefaultLocale"][L"Moniker"] = web::json::value::string(L"other"); + secondManifest[L"PackageVersion"] = web::json::value::string(L"3.0.0"); + responses.ManifestResponse[L"Data"][L"Versions"][2] = secondManifest; + expectedCount = 0; + } + + HttpClientHelper helper{ responses.GetHandler() }; + Interface rest{ TestRestUriString, helper }; + SearchRequest request; + request.Filters.emplace_back(PackageMatchField::Moniker, MatchType::Exact, "target"); + auto result = rest.Search(request); + REQUIRE(responses.ManifestRequests == 1); + REQUIRE(result.Matches.size() == expectedCount); + if (expectedCount) + { + const auto& versions = result.Matches[0].Versions; + REQUIRE(versions.size() == 2); + REQUIRE(versions[0].Manifest.has_value()); + REQUIRE(versions[1].Manifest.has_value() == secondManifestCached); + REQUIRE(versions[1].VersionAndChannel.GetVersion().ToString() == expectedVersion); + REQUIRE(versions[1].VersionAndChannel.GetChannel().ToString() == expectedChannel); + } +} + +TEST_CASE("Search_ManifestResolution_ReusesPackageCache", "[RestSource]") +{ + auto [unknownVersion, cacheDuringSearch] = GENERATE( + std::make_pair(false, true), std::make_pair(true, false), std::make_pair(true, true)); + CAPTURE(unknownVersion, cacheDuringSearch); + SearchAndManifestResponses responses; + if (unknownVersion) + { + responses.SearchResponse[L"Data"][0][L"Versions"][0][L"PackageVersion"] = web::json::value::string(L"Unknown"); + } + responses.SearchResponse[L"Data"][0][L"Versions"][0][L"PackageFamilyNames"][0] = web::json::value::string(L"Search.Reference_123"); + responses.SearchResponse[L"Data"][0][L"Versions"][0][L"ProductCodes"][0] = web::json::value::string(L"Search.Product"); + responses.SearchResponse[L"Data"][0][L"Versions"][0][L"UpgradeCodes"][0] = web::json::value::string(L"Search.Upgrade"); + responses.SearchResponse[L"Data"][0][L"Versions"][0][L"AppsAndFeaturesEntryVersions"][0] = web::json::value::string(L"0.5.0"); + responses.SearchResponse[L"Data"][0][L"Versions"][0][L"AppsAndFeaturesEntryVersions"][1] = web::json::value::string(L"0.6.0"); + responses.ManifestResponse[L"Data"][L"Versions"][0][L"DefaultLocale"][L"Moniker"] = web::json::value::string(L"bar"); + HttpClientHelper helper{ responses.GetHandler() }; + IRestClient::Information information{ "TestSource", { "1.4.0" } }; + SourceDetails details; + details.Identifier = "TestSource"; + auto source = std::make_shared(details, SourceInformation{}, + RestClient::Create(TestRestUriString, {}, {}, helper, information)); + SearchRequest request; + request.Filters.emplace_back(PackageMatchField::Name, MatchType::Exact, cacheDuringSearch ? "Bar"sv : "Microsoft Teams"sv); + request.Filters.emplace_back(PackageMatchField::ProductCode, MatchType::Exact, "Search.Product"); + if (cacheDuringSearch) + { + request.Inclusions.emplace_back(PackageMatchField::Moniker, MatchType::Exact, "bar"); + } + + auto result = source->Search(request); + REQUIRE(result.Matches.size() == 1); + REQUIRE(responses.ManifestRequests == (cacheDuringSearch ? size_t{ 1 } : size_t{ 0 })); + auto package = result.Matches[0].Package->GetAvailable().at(0); + REQUIRE(package->GetLatestVersion()->GetManifest().Moniker == "bar"); + auto keys = package->GetVersionKeys(); + REQUIRE(keys.size() == 1); + REQUIRE(keys[0].Version == "1.0.0"); + REQUIRE(package->GetVersion(keys[0])->GetManifest().Moniker == "bar"); + REQUIRE(package->GetLatestVersion()->GetManifest().Version == "1.0.0"); + auto references = package->GetMultiProperty(PackageMultiProperty::PackageFamilyName); + REQUIRE(references.size() == 1); + REQUIRE(references[0].get() == "Search.Reference_123"); + auto packageProductCodes = package->GetMultiProperty(PackageMultiProperty::ProductCode); + REQUIRE(packageProductCodes.size() == 1); + REQUIRE(packageProductCodes[0].get() == "Search.Product"); + auto packageUpgradeCodes = package->GetMultiProperty(PackageMultiProperty::UpgradeCode); + REQUIRE(packageUpgradeCodes.size() == 1); + REQUIRE(packageUpgradeCodes[0].get() == "Search.Upgrade"); + auto versionReferences = package->GetLatestVersion()->GetMultiProperty(PackageVersionMultiProperty::PackageFamilyName); + auto productCodes = package->GetLatestVersion()->GetMultiProperty(PackageVersionMultiProperty::ProductCode); + auto upgradeCodes = package->GetLatestVersion()->GetMultiProperty(PackageVersionMultiProperty::UpgradeCode); + if (unknownVersion) + { + REQUIRE(versionReferences.empty()); + REQUIRE(productCodes.empty()); + REQUIRE(upgradeCodes.empty()); + } + else + { + REQUIRE(versionReferences.size() == 1); + REQUIRE(versionReferences[0].get() == "Search.Reference_123"); + REQUIRE(productCodes.size() == 1); + REQUIRE(productCodes[0].get() == "Search.Product"); + REQUIRE(upgradeCodes.size() == 1); + REQUIRE(upgradeCodes[0].get() == "Search.Upgrade"); + } + REQUIRE(package->GetLatestVersion()->GetProperty(PackageVersionProperty::ArpMinVersion).get() == (unknownVersion ? "" : "0.5.0")); + REQUIRE(package->GetLatestVersion()->GetProperty(PackageVersionProperty::ArpMaxVersion).get() == (unknownVersion ? "" : "0.6.0")); + auto pairs = package->GetMatrixProperty(PackageMatrixProperty::NormalizedNameAndPublisher); + for (const auto& pair : pairs) + { + REQUIRE(pair.size() == 2); + } + auto containsPair = [&](const auto& name, const auto& publisher) + { + return std::any_of(pairs.begin(), pairs.end(), [&](const auto& pair) { - "PackageIdentifier": "foo.package", - "PackageName": "package", - "Publisher": "foo", - "Versions": [ - { "PackageVersion": "1.0.0" }] - }], - "ContinuationToken" : "abcd-ct=" - })delimiter"); + return pair[0] == ConvertToUTF8(name.as_string()) && + pair[1] == ConvertToUTF8(publisher.as_string()); + }); + }; + const auto& searchPackage = responses.SearchResponse.at(L"Data")[0]; + const auto& locale = responses.ManifestResponse.at(L"Data").at(L"Versions")[0].at(L"DefaultLocale"); + REQUIRE(containsPair(searchPackage.at(L"PackageName"), searchPackage.at(L"Publisher"))); + REQUIRE(containsPair(locale.at(L"PackageName"), locale.at(L"Publisher"))); + REQUIRE_FALSE(containsPair(searchPackage.at(L"PackageName"), locale.at(L"Publisher"))); + REQUIRE_FALSE(containsPair(locale.at(L"PackageName"), searchPackage.at(L"Publisher"))); + REQUIRE_THROWS_HR(package->GetMatrixProperty(static_cast(-1)), E_UNEXPECTED); + REQUIRE(responses.SearchRequests == 1); + REQUIRE(responses.ManifestRequests == 1); +} - HttpClientHelper helper{ GetTestRestRequestHandler(web::http::status_codes::OK, std::move(sample)) }; +TEST_CASE("RestSource_UnknownVersion_PackageReferences", "[RestSource]") +{ + bool cacheDuringSearch = GENERATE(false, true); + CAPTURE(cacheDuringSearch); + const std::string firstCode = "{00000000-0000-0000-0000-000000000001}"; + const std::string secondCode = "{00000000-0000-0000-0000-000000000002}"; + const std::string searchOnlyCode = "{00000000-0000-0000-0000-000000000003}"; + SearchAndManifestResponses responses; + auto& searchVersion = responses.SearchResponse[L"Data"][0][L"Versions"][0]; + searchVersion[L"PackageVersion"] = web::json::value::string(L"Unknown"); + searchVersion[L"ProductCodes"] = web::json::value::array({ + web::json::value::string(ConvertToUTF16(firstCode)), web::json::value::string(ConvertToUTF16(searchOnlyCode)) }); + auto firstManifest = responses.ManifestResponse[L"Data"][L"Versions"][0]; + firstManifest[L"DefaultLocale"][L"Moniker"] = web::json::value::string(L"bar"); + firstManifest[L"Installers"][0][L"ProductCode"] = web::json::value::string(ConvertToUTF16(firstCode)); + auto secondManifest = firstManifest; + secondManifest[L"PackageVersion"] = web::json::value::string(L"2.0.0"); + secondManifest[L"Installers"][0][L"ProductCode"] = web::json::value::string(ConvertToUTF16(secondCode)); + responses.ManifestResponse[L"Data"][L"Versions"] = web::json::value::array({ firstManifest, secondManifest }); + HttpClientHelper helper{ responses.GetHandler() }; + SourceDetails details; + details.Identifier = "TestSource"; + auto source = std::make_shared(details, SourceInformation{}, + RestClient::Create(TestRestUriString, {}, {}, helper, IRestClient::Information{ details.Identifier, { "1.4.0" } })); + SearchRequest request; + request.Filters.emplace_back(cacheDuringSearch ? PackageMatchField::Moniker : PackageMatchField::Name, + MatchType::Exact, cacheDuringSearch ? "bar"sv : "Microsoft Teams"sv); + + auto result = source->Search(request); + REQUIRE(result.Matches.size() == 1); + REQUIRE(responses.ManifestRequests == (cacheDuringSearch ? size_t{ 1 } : size_t{ 0 })); + auto package = result.Matches[0].Package->GetAvailable().at(0); + REQUIRE(package->GetLatestVersion()->GetManifest().Version == "2.0.0"); + auto productCodes = package->GetMultiProperty(PackageMultiProperty::ProductCode); + REQUIRE(productCodes.size() == 3); + CHECK(productCodes[0].get() == firstCode); + CHECK(productCodes[1].get() == secondCode); + CHECK(productCodes[2].get() == searchOnlyCode); + auto keys = package->GetVersionKeys(); + REQUIRE(keys.size() == 2); + for (const auto& key : keys) + { + auto versionCodes = package->GetVersion(key)->GetMultiProperty(PackageVersionMultiProperty::ProductCode); + REQUIRE(versionCodes.size() == 1); + CHECK(versionCodes[0].get() == (key.Version == "1.0.0" ? firstCode : secondCode)); + } + REQUIRE(responses.SearchRequests == 1); + REQUIRE(responses.ManifestRequests == 1); +} + +TEST_CASE("Search_ManifestResolution_UnknownVersionChannel", "[RestSource][Interface_1_0]") +{ + std::string channel = GENERATE("", "preview"); + CAPTURE(channel); + SearchAndManifestResponses responses; + responses.SearchResponse[L"Data"][0][L"Versions"][0][L"PackageVersion"] = web::json::value::string(L"Unknown"); + responses.SearchResponse[L"Data"][0][L"Versions"][0][L"Channel"] = web::json::value::string(ConvertToUTF16(channel)); + responses.SearchResponse[L"Data"][0][L"Versions"][0][L"ProductCodes"][0] = web::json::value::string(L"Search.Product"); + auto firstManifest = responses.ManifestResponse[L"Data"][L"Versions"][0]; + firstManifest[L"DefaultLocale"][L"Moniker"] = web::json::value::string(L"target"); + firstManifest[L"Installers"][0][L"ProductCode"] = web::json::value::string(L"{00000000-0000-0000-0000-000000000001}"); + auto secondManifest = firstManifest; + secondManifest[L"PackageVersion"] = web::json::value::string(L"2.0.0"); + secondManifest[L"DefaultLocale"][L"Moniker"] = web::json::value::string(L"other"); + secondManifest[L"Installers"][0][L"ProductCode"] = web::json::value::string(L"{00000000-0000-0000-0000-000000000002}"); + responses.ManifestResponse[L"Data"][L"Versions"] = web::json::value::array({ firstManifest, secondManifest }); + size_t expectedCount = 1; + bool manifestFound = true; + + SECTION("All retrieved versions replace the unknown entry") {} + SECTION("Nonmatching manifests reject the candidate") + { + responses.ManifestResponse[L"Data"][L"Versions"][0][L"DefaultLocale"][L"Moniker"] = web::json::value::string(L"other"); + expectedCount = 0; + } + SECTION("Missing manifests preserve the unknown version") + { + responses.SetManifestNotFound(); + manifestFound = false; + } + + HttpClientHelper helper{ responses.GetHandler() }; + Interface rest{ TestRestUriString, helper }; + SearchRequest request; + request.Filters.emplace_back(PackageMatchField::Moniker, MatchType::Exact, "target"); + + auto result = rest.Search(request); + REQUIRE(result.Matches.size() == expectedCount); + if (expectedCount) + { + const auto& versions = result.Matches[0].Versions; + if (manifestFound) + { + REQUIRE(versions.size() == 2); + REQUIRE(versions[0].Manifest.has_value()); + REQUIRE(versions[0].VersionAndChannel.GetVersion().ToString() == "1.0.0"); + REQUIRE(versions[0].VersionAndChannel.GetChannel().ToString().empty()); + REQUIRE(versions[0].ProductCodes == std::vector{ "{00000000-0000-0000-0000-000000000001}" }); + REQUIRE(versions[1].Manifest.has_value()); + REQUIRE(versions[1].VersionAndChannel.GetVersion().ToString() == "2.0.0"); + REQUIRE(versions[1].VersionAndChannel.GetChannel().ToString().empty()); + REQUIRE(versions[1].ProductCodes == std::vector{ "{00000000-0000-0000-0000-000000000002}" }); + } + else + { + REQUIRE(versions.size() == 1); + REQUIRE_FALSE(versions[0].Manifest.has_value()); + REQUIRE(versions[0].VersionAndChannel.GetVersion().IsUnknown()); + REQUIRE(versions[0].VersionAndChannel.GetChannel().ToString() == channel); + REQUIRE(versions[0].ProductCodes == std::vector{ "Search.Product" }); + } + } + REQUIRE(responses.SearchRequests == 1); + REQUIRE(responses.ManifestRequests == 1); +} + +TEST_CASE("RestSource_MatrixProperty_DeduplicatesNameAndPublisher", "[RestSource]") +{ + bool cacheManifests = GENERATE(false, true); + CAPTURE(cacheManifests); + SearchAndManifestResponses responses; + auto& searchPackage = responses.SearchResponse[L"Data"][0]; + searchPackage[L"PackageName"] = web::json::value::string(L"Bar"); + searchPackage[L"Publisher"] = web::json::value::string(L"Foo"); + searchPackage[L"Versions"][1][L"PackageVersion"] = web::json::value::string(L"2.0.0"); + auto firstVersion = responses.ManifestResponse[L"Data"][L"Versions"][0]; + firstVersion[L"DefaultLocale"][L"Moniker"] = web::json::value::string(L"bar"); + firstVersion[L"Locales"] = web::json::value::parse(LR"([ + { "PackageLocale": "fr-FR", "PackageName": "Bar", "Publisher": "Foo" }, + { "PackageLocale": "de-DE", "PackageName": "Other Name", "Publisher": "Foo" }, + { "PackageLocale": "es-ES", "PackageName": "Bar", "Publisher": "Other Publisher" }, + { "PackageLocale": "ja-JP", "PackageName": "bar", "Publisher": "Foo" }, + { "PackageLocale": "nl-NL", "PackageName": "Bar", "Publisher": "foo" } + ])"); + firstVersion[L"Installers"][0][L"AppsAndFeaturesEntries"] = web::json::value::parse(LR"([ + { "DisplayName": "Bar", "Publisher": "Foo" }, + { "DisplayName": "Installed Name", "Publisher": "Installed Publisher" }, + { "DisplayName": "Installed Name", "Publisher": "Installed Publisher" }, + { "DisplayName": "Other Name" } + ])"); + auto secondVersion = firstVersion; + secondVersion[L"PackageVersion"] = web::json::value::string(L"2.0.0"); + firstVersion[L"Installers"][0][L"AppsAndFeaturesEntries"][4] = web::json::value::parse( + LR"({ "DisplayName": "Older Name", "Publisher": "Older Publisher" })"); + responses.ManifestResponse[L"Data"][L"Versions"] = web::json::value::array({ firstVersion, secondVersion }); + HttpClientHelper helper{ responses.GetHandler() }; + IRestClient::Information information{ "TestSource", { "1.4.0" } }; + SourceDetails details; + details.Identifier = "TestSource"; + auto source = std::make_shared(details, SourceInformation{}, + RestClient::Create(TestRestUriString, {}, {}, helper, information)); + SearchRequest request; + request.Filters.emplace_back(PackageMatchField::Name, MatchType::Exact, "Bar"); + if (cacheManifests) + { + request.Filters.emplace_back(PackageMatchField::Moniker, MatchType::Exact, "bar"); + } + + auto result = source->Search(request); + REQUIRE(result.Matches.size() == 1); + auto package = result.Matches[0].Package->GetAvailable().at(0); + REQUIRE(package->GetVersionKeys().size() == 2); + std::vector> expected{ { "Bar", "Foo" } }; + if (cacheManifests) + { + expected.insert(expected.end(), { + { "Other Name", "Foo" }, + { "Bar", "Other Publisher" }, + { "bar", "Foo" }, + { "Bar", "foo" }, + { "Installed Name", "Installed Publisher" }, + { "Older Name", "Older Publisher" }, + }); + } + for (size_t attempt = 0; attempt < 2; ++attempt) + { + CAPTURE(attempt); + REQUIRE(package->GetMatrixProperty(PackageMatrixProperty::NormalizedNameAndPublisher) == expected); + REQUIRE(responses.SearchRequests == 1); + REQUIRE(responses.ManifestRequests == (cacheManifests ? size_t{ 1 } : size_t{ 0 })); + } +} + +TEST_CASE("Search_ManifestResolution_SourceCapabilities", "[RestSource][Interface_1_1]") +{ + SearchAndManifestResponses responses; + IRestClient::Information information; + information.RequiredPackageMatchFields = { "Market" }; + information.RequiredQueryParameters = { "Market" }; + SearchRequest request; + request.Filters.emplace_back(PackageMatchField::Name, MatchType::Exact, "Bar"); + std::string expectedMarket = AppInstaller::Runtime::GetOSRegion(); + size_t expectedManifestRequests = 1; + size_t expectedQueryValidations = 1; + SECTION("Use the required market") {} + SECTION("Preserve an explicit market") + { + expectedMarket = "FR"; + request.Filters.emplace_back(PackageMatchField::Market, MatchType::Exact, expectedMarket); + } + SECTION("Conflicting markets cannot be represented") + { + request.Filters.emplace_back(PackageMatchField::Market, MatchType::Exact, "FR"); + request.Filters.emplace_back(PackageMatchField::Market, MatchType::Exact, "DE"); + expectedManifestRequests = 0; + expectedQueryValidations = 0; + } + SECTION("A market prefix cannot be represented") + { + request.Filters.emplace_back(PackageMatchField::Market, MatchType::StartsWith, "F"); + expectedManifestRequests = 0; + expectedQueryValidations = 0; + } + SECTION("Required version prevents an all-manifests lookup") + { + information.RequiredQueryParameters.emplace_back("Version"); + expectedManifestRequests = 0; + } + SECTION("An unsupported market prevents lookup") + { + information.UnsupportedQueryParameters.emplace_back("Market"); + expectedManifestRequests = 0; + } + SECTION("Unsupported version and channel parameters are not sent") + { + information.UnsupportedQueryParameters = { "Version", "Channel" }; + } + HttpClientHelper helper{ responses.GetHandler() }; + QueryValidationTrackingInterface rest{ TestRestUriString, helper, information, { { L"Windows-Package-Manager", L"TestHeader" } } }; + auto result = rest.Search(request); + REQUIRE(result.Matches.size() == 1); + REQUIRE(responses.SearchRequests == 1); + REQUIRE(responses.ManifestRequests == expectedManifestRequests); + REQUIRE(rest.QueryValidations == expectedQueryValidations); + REQUIRE(result.Matches[0].Versions[0].Manifest.has_value() == (expectedManifestRequests != 0)); + if (expectedManifestRequests) + { + auto query = web::uri::split_query(responses.LastManifestRequest.absolute_uri().query()); + REQUIRE(query.at(L"Market") == ConvertToUTF16(expectedMarket)); + REQUIRE(query.count(L"Version") == 0); + REQUIRE(query.count(L"Channel") == 0); + REQUIRE(responses.LastManifestRequest.headers()[L"Windows-Package-Manager"] == L"TestHeader"); + REQUIRE(responses.LastManifestRequest.headers()[L"Version"] == L"1.1.0"); + } +} + +TEST_CASE("Search_ManifestResolution_Continuation", "[RestSource][Interface_1_0]") +{ + bool exceedsResultLimit = GENERATE(false, true); + CAPTURE(exceedsResultLimit); + size_t searches = 0; + size_t lookups = 0; + std::vector tokens; + bool manifestReceivedContinuation = false; + bool manifestBeforeSearchComplete = false; + auto handler = std::make_shared( + [&](web::http::http_request request) -> pplx::task + { + web::http::http_response response{ web::http::status_codes::OK }; + response.headers().set_content_type(web::http::details::mime_types::application_json); + response.headers().set_cache_control(L"no-store"); + if (request.method() == web::http::methods::POST) + { + tokens.emplace_back(request.headers()[L"ContinuationToken"]); + ++searches; + response.set_body(web::json::value::parse(searches == 1 ? + GetSearchResponse_PackageIds({ L"Other.Package" }, L"next") : + (exceedsResultLimit ? + GetSearchResponse_PackageIds({ L"Other.Second", L"Other.Third", L"Foo.Bar" }, L"more") : + GetSearchResponse_PackageIds({ L"Foo.Bar" })))); + } + else if (request.method() == web::http::methods::GET) + { + ++lookups; + manifestReceivedContinuation |= request.headers().has(L"ContinuationToken"); + manifestBeforeSearchComplete |= searches < 2; + auto manifest = web::json::value::parse(GetGoodManifest_RequiredFields()); + auto packageId = web::uri::split_path(request.absolute_uri().path()).back(); + manifest[L"Data"][L"PackageIdentifier"] = web::json::value::string(packageId); + manifest[L"Data"][L"Versions"][0][L"PackageVersion"] = web::json::value::string(L"1.0.0"); + if (packageId == L"Foo.Bar") + { + manifest[L"Data"][L"Versions"][0][L"DefaultLocale"][L"Moniker"] = web::json::value::string(L"bar"); + } + response.set_body(manifest); + } + return pplx::task_from_result(response); + }); + HttpClientHelper helper{ handler }; + Interface rest{ TestRestUriString, helper }; + SearchRequest request; + request.Inclusions.emplace_back(PackageMatchField::Moniker, MatchType::Exact, "bar"); + request.MaximumResults = 1; + auto result = rest.Search(request); + REQUIRE(result.Matches.size() == 1); + REQUIRE(result.Matches[0].PackageInformation.PackageIdentifier == (exceedsResultLimit ? "Other.Package" : "Foo.Bar")); + REQUIRE(result.Truncated == exceedsResultLimit); + REQUIRE(searches == 2); + REQUIRE(lookups == (exceedsResultLimit ? size_t{ 0 } : size_t{ 2 })); + REQUIRE(tokens == std::vector{ L"", L"next" }); + REQUIRE_FALSE(manifestReceivedContinuation); + REQUIRE_FALSE(manifestBeforeSearchComplete); +} + +TEST_CASE("Search_ManifestResolution_ResultLimit", "[RestSource][Interface_1_0]") +{ + size_t packageCount = GENERATE(size_t{ 3 }, size_t{ 4 }); + bool paginated = GENERATE(false, true); + bool manifestAvailable = GENERATE(false, true); + bool manifestMatches = GENERATE(false, true); + bool useInclusions = GENERATE(false, true); + size_t maximumResults = GENERATE(size_t{ 0 }, size_t{ 1 }); + CAPTURE(packageCount, paginated, manifestAvailable, manifestMatches, useInclusions, maximumResults); + const std::vector identifiers{ "Foo.One", "Foo.Two", "Foo.Three", "Foo.Four" }; + std::vector pages; + if (paginated) + { + pages.emplace_back(GetSearchResponse_PackageIds({ L"Foo.One", L"Foo.Two" }, L"next")); + pages.emplace_back(packageCount == 3 ? + GetSearchResponse_PackageIds({ L"Foo.Three" }) : + GetSearchResponse_PackageIds({ L"Foo.Three", L"Foo.Four" })); + } + else + { + pages.emplace_back(packageCount == 3 ? + GetSearchResponse_PackageIds({ L"Foo.One", L"Foo.Two", L"Foo.Three" }) : + GetSearchResponse_PackageIds({ L"Foo.One", L"Foo.Two", L"Foo.Three", L"Foo.Four" })); + } + size_t searches = 0; + std::vector manifestPaths; + bool manifestBeforeSearchComplete = false; + auto handler = std::make_shared( + [&](web::http::http_request request) -> pplx::task + { + web::http::http_response response{ web::http::status_codes::OK }; + response.headers().set_content_type(web::http::details::mime_types::application_json); + response.headers().set_cache_control(L"no-store"); + if (request.method() == web::http::methods::POST) + { + response.set_body(web::json::value::parse(pages.at(searches++))); + } + else if (request.method() == web::http::methods::GET) + { + auto path = request.absolute_uri().path(); + manifestPaths.emplace_back(path); + manifestBeforeSearchComplete |= searches != pages.size(); + if (manifestAvailable) + { + auto manifest = web::json::value::parse(GetGoodManifest_RequiredFields()); + manifest[L"Data"][L"PackageIdentifier"] = web::json::value::string(web::uri::split_path(path).back()); + auto& version = manifest[L"Data"][L"Versions"][0]; + version[L"PackageVersion"] = web::json::value::string(L"1.0.0"); + version[L"DefaultLocale"][L"PackageName"] = web::json::value::string(manifestMatches ? L"Wanted" : L"Other"); + response.set_body(manifest); + } + else + { + response.set_status_code(web::http::status_codes::NotFound); + response.set_body(web::json::value::parse(LR"({"code":"DataNotFound","message":"Not found"})")); + } + } + return pplx::task_from_result(response); + }); + HttpClientHelper helper{ handler }; + Interface rest{ TestRestUriString, helper }; + SearchRequest request; + auto& criteria = useInclusions ? request.Inclusions : request.Filters; + criteria.emplace_back(PackageMatchField::Name, MatchType::Exact, "Wanted"); + request.MaximumResults = maximumResults; + + for (size_t attempt = 0; attempt < 2; ++attempt) + { + CAPTURE(attempt); + searches = 0; + manifestPaths.clear(); + manifestBeforeSearchComplete = false; + auto result = rest.Search(request); + bool enrich = packageCount <= 3; + size_t expectedLookups = enrich ? packageCount : 0; + REQUIRE(manifestPaths.size() == expectedLookups); + REQUIRE_FALSE(manifestBeforeSearchComplete); + for (size_t i = 0; i < expectedLookups; ++i) + { + REQUIRE(manifestPaths[i] == L"/api/packageManifests/" + ConvertToUTF16(identifiers[i])); + } + + size_t expectedCount = enrich && manifestAvailable && !manifestMatches ? 0 : packageCount; + bool expectedTruncated = maximumResults && expectedCount > maximumResults; + if (maximumResults) + { + expectedCount = std::min(expectedCount, maximumResults); + } + REQUIRE(result.Matches.size() == expectedCount); + REQUIRE(result.Truncated == expectedTruncated); + REQUIRE(searches == pages.size()); + for (size_t i = 0; i < expectedCount; ++i) + { + REQUIRE(result.Matches[i].PackageInformation.PackageIdentifier == identifiers[i]); + REQUIRE(result.Matches[i].Versions[0].Manifest.has_value() == (enrich && manifestAvailable)); + } + } +} + +TEST_CASE("Search_ManifestResolution_ResultLimit_AvailableMetadata", "[RestSource][Interface_1_0]") +{ + SearchAndManifestResponses responses; + responses.SearchResponse = web::json::value::parse(GetSearchResponse_PackageIds( + { L"Other.First", L"Foo.Known", L"Foo.One", L"Foo.Two", L"Foo.Three", L"Other.Last", L"Foo.Last", L"Foo.Unknown" })); + for (size_t index : { size_t{ 1 }, size_t{ 5 }, size_t{ 6 } }) + { + responses.SearchResponse[L"Data"][index][L"PackageName"] = web::json::value::string(L"Wanted"); + } + responses.SetManifestNotFound(); + HttpClientHelper helper{ responses.GetHandler() }; + Interface rest{ TestRestUriString, helper }; + SearchRequest request; + request.Filters.emplace_back(PackageMatchField::Name, MatchType::Exact, "Wanted"); + request.Filters.emplace_back(PackageMatchField::Id, MatchType::StartsWith, "Foo."); + std::vector expected{ "Foo.Known", "Foo.One", "Foo.Two", "Foo.Three", "Foo.Last", "Foo.Unknown" }; + + SECTION("Available metadata still filters larger result sets") {} + SECTION("Known mismatches do not reduce the source result count") + { + request.Filters[1].Type = MatchType::Exact; + request.Filters[1].Value = "Foo.Unknown"; + expected = { "Foo.Unknown" }; + } + + auto result = rest.Search(request); + REQUIRE(responses.SearchRequests == 1); + REQUIRE(responses.ManifestRequests == 0); + REQUIRE_FALSE(result.Truncated); + REQUIRE(result.Matches.size() == expected.size()); + for (size_t i = 0; i < expected.size(); ++i) + { + REQUIRE(result.Matches[i].PackageInformation.PackageIdentifier == expected[i]); + } +} + +TEST_CASE("Search_ManifestResolution_ResultLimit_SearchRequest", "[RestSource][Interface_1_0]") +{ + size_t maximumResults = GENERATE(size_t{ 0 }, size_t{ 1 }, size_t{ 3 }, size_t{ 4 }); + CAPTURE(maximumResults); + SearchAndManifestResponses responses; + SearchRequest request; + request.MaximumResults = maximumResults; + request.Filters.emplace_back(PackageMatchField::Name, MatchType::Exact, "Microsoft Teams"); + size_t expectedMaximumResults = maximumResults ? std::max(maximumResults, size_t{ 4 }) : 0; + + SECTION("Request enough candidates to detect a larger result set") {} + SECTION("ID-only criteria do not require extra candidates") + { + request.Filters.clear(); + request.Filters.emplace_back(PackageMatchField::Id, MatchType::StartsWith, "Foo."); + expectedMaximumResults = maximumResults; + } + SECTION("Generic queries do not require extra candidates") + { + request.Filters.clear(); + request.Query.emplace(MatchType::Substring, "Teams"); + expectedMaximumResults = maximumResults; + } + SECTION("Correlation does not require extra candidates") + { + request.Purpose = SearchPurpose::CorrelationToAvailable; + expectedMaximumResults = maximumResults; + } + SECTION("Unsupported comparisons do not require extra candidates") + { + request.Filters[0].Type = MatchType::Fuzzy; + expectedMaximumResults = maximumResults; + } + SECTION("Unverifiable fields do not require extra candidates") + { + request.Filters.clear(); + request.Filters.emplace_back(PackageMatchField::NormalizedNameAndPublisher, MatchType::Exact, "Microsoft Teams", "Microsoft"); + expectedMaximumResults = maximumResults; + } + + HttpClientHelper helper{ responses.GetHandler() }; + Interface rest{ TestRestUriString, helper }; + auto result = rest.Search(request); + REQUIRE(result.Matches.size() == 1); + REQUIRE_FALSE(result.Truncated); + REQUIRE(responses.SearchRequests == 1); + REQUIRE(responses.ManifestRequests == 0); + REQUIRE(request.MaximumResults == maximumResults); + auto searchBody = responses.LastSearchRequest.extract_json().get(); + REQUIRE(searchBody.has_field(L"MaximumResults") == (maximumResults != 0)); + if (maximumResults) + { + REQUIRE(searchBody.at(L"MaximumResults").as_number().to_uint64() == expectedMaximumResults); + } +} + +TEST_CASE("Search_ExplicitIdFilters_UnsupportedMatchType", "[RestSource][Interface_1_0]") +{ + HttpClientHelper helper{ GetTestRestRequestHandler(web::http::status_codes::OK, + GetSearchResponse_PackageIds({ L"Foo.Bar" })) }; + Interface v1{ TestRestUriString, helper }; + SearchRequest request; + auto type = GENERATE(MatchType::Fuzzy, MatchType::FuzzySubstring, MatchType::Wildcard); + request.Filters.emplace_back(PackageMatchField::Id, type, "Other"); + + auto result = v1.Search(request); + REQUIRE(result.Matches.size() == 1); + REQUIRE(result.Matches[0].PackageInformation.PackageIdentifier == "Foo.Bar"); +} + +TEST_CASE("Search_ExplicitIdFilters_UnavailableMetadata", "[RestSource][Interface_1_0]") +{ + SearchAndManifestResponses responses; + responses.SetManifestNotFound(); + HttpClientHelper helper{ responses.GetHandler() }; + Interface v1{ TestRestUriString, helper }; + SearchRequest request; + request.Filters.emplace_back(PackageMatchField::Id, MatchType::Exact, "Foo.Bar"); + auto field = GENERATE(PackageMatchField::Name, PackageMatchField::Moniker, PackageMatchField::Tag, + PackageMatchField::Command, PackageMatchField::PackageFamilyName, PackageMatchField::ProductCode, + PackageMatchField::UpgradeCode, PackageMatchField::NormalizedNameAndPublisher, PackageMatchField::Market); + request.Filters.emplace_back(field, MatchType::Exact, "Not in the response"); + + auto result = v1.Search(request); + size_t expectedManifestRequests = (field == PackageMatchField::NormalizedNameAndPublisher || field == PackageMatchField::Market) ? 0 : 1; + REQUIRE(responses.SearchRequests == 1); + REQUIRE(responses.ManifestRequests == expectedManifestRequests); + REQUIRE(result.Matches.size() == 1); + REQUIRE(result.Matches[0].PackageInformation.PackageIdentifier == "Foo.Bar"); +} + +TEST_CASE("Search_ExplicitIdFilters_NoFilters", "[RestSource][Interface_1_0]") +{ + HttpClientHelper helper{ GetTestRestRequestHandler(web::http::status_codes::OK, + GetSearchResponse_PackageIds({ L"Foo.Bar" })) }; + Interface v1{ TestRestUriString, helper }; + SearchRequest request; + size_t expectedCount = 1; + + SECTION("Everything") {} + SECTION("Query") + { + request.Query.emplace(MatchType::Exact, "Not in the response"); + } + SECTION("Inclusions") + { + request.Inclusions.emplace_back(PackageMatchField::Id, MatchType::Exact, "Not in the response"); + expectedCount = 0; + } + SECTION("Correlation") + { + request.Purpose = SearchPurpose::CorrelationToAvailable; + request.Inclusions.emplace_back(PackageMatchField::ProductCode, MatchType::Exact, "Not in the response"); + } + + auto result = v1.Search(request); + REQUIRE(result.Matches.size() == expectedCount); + if (expectedCount) + { + REQUIRE(result.Matches[0].PackageInformation.PackageIdentifier == "Foo.Bar"); + } +} + +TEST_CASE("Search_ExplicitIdFilters_Continuation", "[RestSource][Interface_1_0]") +{ + bool allFiltered = GENERATE(false, true); + bool useInclusions = GENERATE(false, true); + CAPTURE(allFiltered, useInclusions); + std::vector pages + { + GetSearchResponse_PackageIds({ L"Other.One", L"Other.Two" }, L"next"), + GetSearchResponse_PackageIds({ allFiltered ? L"Other.Three" : L"Match.One" }, L"last"), + GetSearchResponse_PackageIds({ allFiltered ? L"Other.Four" : L"Match.Two", + allFiltered ? L"Other.Five" : L"Match.Three" }), + }; + std::vector continuationTokens; + size_t requestCount = 0; + auto handler = std::make_shared( + [&](web::http::http_request request) -> pplx::task + { + web::http::http_response response{ web::http::status_codes::BadRequest }; + response.headers().set_content_type(web::http::details::mime_types::application_json); + response.headers().set_cache_control(L"no-store"); + if (request.method() == web::http::methods::POST && requestCount < pages.size()) + { + continuationTokens.emplace_back(request.headers()[L"ContinuationToken"]); + response.set_status_code(web::http::status_codes::OK); + response.set_body(web::json::value::parse(pages[requestCount])); + } + ++requestCount; + return pplx::task_from_result(response); + }); + HttpClientHelper helper{ handler }; + Interface v1{ TestRestUriString, helper }; + SearchRequest request; + auto& criteria = useInclusions ? request.Inclusions : request.Filters; + criteria.emplace_back(PackageMatchField::Id, MatchType::StartsWith, "Match."); + request.MaximumResults = GENERATE(0, 1, 2, 3, 9); + + auto result = v1.Search(request); + size_t expectedCount = allFiltered ? 0 : (request.MaximumResults ? std::min(size_t{ 3 }, request.MaximumResults) : 3); + REQUIRE(result.Matches.size() == expectedCount); + REQUIRE(result.Truncated == (!allFiltered && expectedCount < 3)); + REQUIRE(requestCount == (!allFiltered && request.MaximumResults == 1 ? size_t{ 2 } : size_t{ 3 })); + REQUIRE(continuationTokens[0].empty()); + REQUIRE(continuationTokens[1] == L"next"); + if (requestCount == 3) + { + REQUIRE(continuationTokens[2] == L"last"); + } + const std::vector expectedIds{ "Match.One", "Match.Two", "Match.Three" }; + for (size_t i = 0; i < expectedCount; ++i) + { + REQUIRE(result.Matches[i].PackageInformation.PackageIdentifier == expectedIds[i]); + } +} + +TEST_CASE("Search_ContinuationToken", "[RestSource][Interface_1_0]") +{ + size_t requestCount = 0; + std::vector sentTokens; + auto handler = std::make_shared( + [&](web::http::http_request request) -> pplx::task + { + web::http::http_response response{ web::http::status_codes::BadRequest }; + response.headers().set_content_type(web::http::details::mime_types::application_json); + response.headers().set_cache_control(L"no-store"); + if (request.method() == web::http::methods::POST) + { + sentTokens.emplace_back(request.headers()[L"ContinuationToken"]); + ++requestCount; + response.set_status_code(web::http::status_codes::OK); + response.set_body(web::json::value::parse( + GetSearchResponse_PackageIds({ L"git.package", L"foo.package" }, std::to_wstring(requestCount)))); + } + return pplx::task_from_result(response); + }); + HttpClientHelper helper{ handler }; Interface v1{ TestRestUriString, std::move(helper) }; - SearchRequest request{}; - request.MaximumResults = 9; - Schema::IRestClient::SearchResult results = v1.Search(request); - REQUIRE(results.Matches.size() == request.MaximumResults); + for (size_t maximumResults : { size_t{ 9 }, size_t{ 1 }, size_t{ 9 } }) + { + CAPTURE(maximumResults); + requestCount = 0; + sentTokens.clear(); + SearchRequest request; + request.MaximumResults = maximumResults; + auto result = v1.Search(request); + REQUIRE(result.Matches.size() == maximumResults); + REQUIRE(result.Truncated); + REQUIRE(requestCount == (maximumResults + 1) / 2); + REQUIRE(sentTokens[0].empty()); + for (size_t i = 1; i < sentTokens.size(); ++i) + { + REQUIRE(sentTokens[i] == std::to_wstring(i)); + } + } +} + +TEST_CASE("Search_ContinuationToken_Cycle", "[RestSource][Interface_1_0]") +{ + bool longerCycle = GENERATE(false, true); + CAPTURE(longerCycle); + std::vector returnedTokens{ L"next", L"next" }; + if (longerCycle) + { + returnedTokens.insert(returnedTokens.begin() + 1, L"NEXT"); + } + bool keepFirstResult = false; + bool reachResultLimit = false; + SearchRequest request; + request.Filters.emplace_back(PackageMatchField::Id, MatchType::StartsWith, "Match."); + request.MaximumResults = 1; - SearchRequest requestWithSize1{}; - requestWithSize1.MaximumResults = 1; - Schema::IRestClient::SearchResult resultsWithSize1 = v1.Search(requestWithSize1); - REQUIRE(resultsWithSize1.Matches.size() == requestWithSize1.MaximumResults); + SECTION("All pages are rejected") {} + SECTION("Partial results do not hide the invalid response") + { + keepFirstResult = true; + request.MaximumResults = 2; + } + SECTION("Unlimited results still detect cycles") + { + request.MaximumResults = 0; + } + SECTION("A satisfied result limit does not follow the repeated token") + { + reachResultLimit = true; + } + + size_t requestCount = 0; + std::vector sentTokens; + auto handler = std::make_shared( + [&](web::http::http_request httpRequest) -> pplx::task + { + web::http::http_response response{ web::http::status_codes::BadRequest }; + response.headers().set_content_type(web::http::details::mime_types::application_json); + response.headers().set_cache_control(L"no-store"); + if (httpRequest.method() == web::http::methods::POST) + { + sentTokens.emplace_back(httpRequest.headers()[L"ContinuationToken"]); + size_t page = requestCount++; + response.set_status_code(web::http::status_codes::OK); + if (page < returnedTokens.size()) + { + bool matches = (keepFirstResult && page == 0) || (reachResultLimit && page + 1 == returnedTokens.size()); + response.set_body(web::json::value::parse( + GetSearchResponse_PackageIds({ matches ? L"Match.One" : L"Other.App" }, returnedTokens[page]))); + } + else + { + // End the fake chain if the client fails to detect the cycle. + response.set_body(web::json::value::parse(GetSearchResponse_PackageIds({}))); + } + } + return pplx::task_from_result(response); + }); + HttpClientHelper helper{ handler }; + Interface v1{ TestRestUriString, helper }; + if (reachResultLimit) + { + auto result = v1.Search(request); + REQUIRE(result.Matches.size() == 1); + REQUIRE(result.Matches[0].PackageInformation.PackageIdentifier == "Match.One"); + REQUIRE(result.Truncated); + } + else + { + REQUIRE_THROWS_HR(v1.Search(request), APPINSTALLER_CLI_ERROR_RESTSOURCE_INVALID_DATA); + } + REQUIRE(requestCount == returnedTokens.size()); + REQUIRE(sentTokens[0].empty()); + for (size_t i = 1; i < sentTokens.size(); ++i) + { + REQUIRE(sentTokens[i] == returnedTokens[i - 1]); + } } TEST_CASE("Search_BadResponse_NoVersions", "[RestSource][Interface_1_0]") @@ -490,7 +1974,7 @@ TEST_CASE("Search_Optimized_ManifestResponse", "[RestSource][Interface_1_0]") utility::string_t sample = GetGoodManifest_RequiredFields(); HttpClientHelper helper{ GetTestRestRequestHandler(web::http::status_codes::OK, std::move(sample)) }; AppInstaller::Repository::SearchRequest request; - PackageMatchFilter filter{ PackageMatchField::Id, MatchType::Exact, "Foo" }; + PackageMatchFilter filter{ PackageMatchField::Id, MatchType::Exact, "Foo.Bar" }; request.Filters.emplace_back(std::move(filter)); Interface v1{ TestRestUriString, std::move(helper) }; Schema::IRestClient::SearchResult result = v1.Search(request); @@ -542,12 +2026,56 @@ TEST_CASE("Search_Optimized_NoResponse_NotFoundCode", "[RestSource][Interface_1_ REQUIRE_THROWS_HR(v1.Search(request), APPINSTALLER_CLI_ERROR_RESTAPI_ENDPOINT_NOT_FOUND); } +TEST_CASE("Search_Optimized_ExplicitIdFilter", "[RestSource][Interface_1_0]") +{ + auto type = GENERATE(MatchType::Exact, MatchType::CaseInsensitive); + std::string id = GENERATE("Foo.Bar", "foo.bar", "Foo", "Other.Package"); + bool manifestFound = GENERATE(false, true); + CAPTURE(ToString(type), id, manifestFound); + SearchAndManifestResponses responses; + if (!manifestFound) + { + responses.SetManifestNotFound(); + } + HttpClientHelper helper{ responses.GetHandler() }; + Interface v1{ TestRestUriString, helper }; + SearchRequest request; + request.Filters.emplace_back(PackageMatchField::Id, type, id); + + bool expected = manifestFound && (id == "Foo.Bar" || (type == MatchType::CaseInsensitive && id == "foo.bar")); + if (manifestFound && !expected) + { + REQUIRE_THROWS_HR(v1.Search(request), APPINSTALLER_CLI_ERROR_RESTSOURCE_INVALID_DATA); + } + else + { + auto result = v1.Search(request); + REQUIRE(result.Matches.size() == (expected ? size_t{ 1 } : size_t{ 0 })); + REQUIRE_FALSE(result.Truncated); + if (expected) + { + REQUIRE(result.Matches[0].PackageInformation.PackageIdentifier == "Foo.Bar"); + REQUIRE(result.Matches[0].Versions.size() == 1); + REQUIRE(result.Matches[0].Versions[0].Manifest.has_value()); + } + } + REQUIRE(responses.SearchRequests == 0); + REQUIRE(responses.ManifestRequests == 1); + REQUIRE(responses.LastManifestRequest.absolute_uri().path() == L"/api/packageManifests/" + ConvertToUTF16(id)); +} + TEST_CASE("Search_SubstringIdFallback_ManifestResponse", "[RestSource][Interface_1_0]") { - utility::string_t emptySearchResponse = _XPLATSTR(R"delimiter({ "Data" : [] })delimiter"); + bool filteredSearch = GENERATE(false, true); + std::string_view id = GENERATE("Foo.Bar", "foo.bar", "Other.Id"); + bool manifestMatches = id != "Other.Id"; + CAPTURE(filteredSearch, id); + auto searchResponse = filteredSearch ? GetSearchResponse_PackageIds({ L"Unrelated.Package" }) : GetSearchResponse_PackageIds({}); + size_t searchCount = 0; + size_t manifestCount = 0; auto handler = std::make_shared( - [emptySearchResponse](web::http::http_request request) -> pplx::task + [&](web::http::http_request request) -> pplx::task { web::http::http_response response; response.headers().set_content_type(web::http::details::mime_types::application_json); @@ -555,11 +2083,13 @@ TEST_CASE("Search_SubstringIdFallback_ManifestResponse", "[RestSource][Interface if (request.method() == web::http::methods::POST) { + ++searchCount; response.set_status_code(web::http::status_codes::OK); - response.set_body(web::json::value::parse(emptySearchResponse)); + response.set_body(web::json::value::parse(searchResponse)); } else if (request.method() == web::http::methods::GET) { + ++manifestCount; response.set_status_code(web::http::status_codes::OK); response.set_body(web::json::value::parse(GetGoodManifest_RequiredFields())); } @@ -573,15 +2103,24 @@ TEST_CASE("Search_SubstringIdFallback_ManifestResponse", "[RestSource][Interface HttpClientHelper helper{ std::move(handler) }; AppInstaller::Repository::SearchRequest request; - request.Filters.emplace_back(PackageMatchFilter{ PackageMatchField::Id, MatchType::Substring, "Foo.Bar" }); + request.Filters.emplace_back(PackageMatchField::Id, MatchType::Substring, id); Interface v1{ TestRestUriString, std::move(helper) }; - Schema::IRestClient::SearchResult result = v1.Search(request); - REQUIRE(result.Matches.size() == 1); - REQUIRE(result.Matches[0].PackageInformation.PackageIdentifier == "Foo.Bar"); - REQUIRE(result.Matches[0].Versions.size() == 1); - REQUIRE(result.Matches[0].Versions[0].VersionAndChannel.GetVersion().ToString() == "5.0.0"); - REQUIRE(result.Matches[0].Versions[0].Manifest.has_value()); + if (manifestMatches) + { + Schema::IRestClient::SearchResult result = v1.Search(request); + REQUIRE(result.Matches.size() == 1); + REQUIRE(result.Matches[0].PackageInformation.PackageIdentifier == "Foo.Bar"); + REQUIRE(result.Matches[0].Versions.size() == 1); + REQUIRE(result.Matches[0].Versions[0].VersionAndChannel.GetVersion().ToString() == "5.0.0"); + REQUIRE(result.Matches[0].Versions[0].Manifest.has_value()); + } + else + { + REQUIRE_THROWS_HR(v1.Search(request), APPINSTALLER_CLI_ERROR_RESTSOURCE_INVALID_DATA); + } + REQUIRE(searchCount == 1); + REQUIRE(manifestCount == 1); } TEST_CASE("Search_SubstringId_NoFallbackWhenSearchMatches", "[RestSource][Interface_1_0]") @@ -798,3 +2337,39 @@ TEST_CASE("GetManifestByVersion_GoodResponse_MultipleVersions_VersionNotFound", std::optional manifest = v1.GetManifestByVersion("Foo.Bar", "7.0.0", ""); REQUIRE_FALSE(manifest.has_value()); } + +TEST_CASE("GetManifestByVersion_VersionAndChannelMatching", "[RestSource][Interface_1_0]") +{ + std::string version = GENERATE("5.0.0-beta", "5.0.0-BETA", "5.0-beta", "6.0.0-beta"); + std::string channel = GENERATE("", "missing"); + CAPTURE(version, channel); + SearchAndManifestResponses responses; + auto firstVersion = responses.ManifestResponse[L"Data"][L"Versions"][0]; + firstVersion[L"PackageVersion"] = web::json::value::string(L"4.0.0"); + auto secondVersion = firstVersion; + secondVersion[L"PackageVersion"] = web::json::value::string(L"5.0.0-beta"); + responses.ManifestResponse[L"Data"][L"Versions"] = web::json::value::array({ firstVersion, secondVersion }); + HttpClientHelper helper{ responses.GetHandler() }; + Interface rest{ TestRestUriString, helper }; + + auto manifest = rest.GetManifestByVersion("Foo.Bar", version, channel); + bool expected = (version == "5.0.0-beta" || version == "5.0.0-BETA") && channel.empty(); + REQUIRE(manifest.has_value() == expected); + if (expected) + { + REQUIRE(manifest->Version == "5.0.0-beta"); + REQUIRE(manifest->Channel.empty()); + } + REQUIRE(responses.SearchRequests == 0); + REQUIRE(responses.ManifestRequests == 1); + auto query = web::uri::split_query(responses.LastManifestRequest.absolute_uri().query()); + REQUIRE(query.at(L"Version") == ConvertToUTF16(version)); + if (!channel.empty()) + { + REQUIRE(query.at(L"Channel") == ConvertToUTF16(channel)); + } + else + { + REQUIRE(query.count(L"Channel") == 0); + } +} diff --git a/src/AppInstallerCLITests/RestInterface_1_1.cpp b/src/AppInstallerCLITests/RestInterface_1_1.cpp index eaefeda053..9b4a7de914 100644 --- a/src/AppInstallerCLITests/RestInterface_1_1.cpp +++ b/src/AppInstallerCLITests/RestInterface_1_1.cpp @@ -398,13 +398,64 @@ TEST_CASE("Search_GoodRequest_OnlyMarketRequired", "[RestSource][Interface_1_1]" }] })delimiter"); - HttpClientHelper helper{ GetTestRestRequestHandler(web::http::status_codes::OK, std::move(sample)) }; + web::json::value searchBody; + auto handler = std::make_shared( + [&](web::http::http_request httpRequest) -> pplx::task + { + web::http::http_response response{ web::http::status_codes::BadRequest }; + response.headers().set_content_type(web::http::details::mime_types::application_json); + response.headers().set_cache_control(L"no-store"); + if (httpRequest.method() == web::http::methods::POST) + { + searchBody = httpRequest.extract_json().get(); + response.set_status_code(web::http::status_codes::OK); + response.set_body(web::json::value::parse(sample)); + } + return pplx::task_from_result(response); + }); + HttpClientHelper helper{ handler }; Interface v1_1{ TestRestUriString, std::move(helper), GetTestSourceInformation(), {} }; AppInstaller::Repository::SearchRequest request; - PackageMatchFilter filter{ PackageMatchField::Name, MatchType::Exact, "Foo" }; + PackageMatchFilter filter{ PackageMatchField::Name, MatchType::Exact, "package" }; request.Filters.emplace_back(std::move(filter)); + size_t expectedCount = 1; + + SECTION("Name filter") {} + SECTION("Matching ID filter") + { + request.Filters.emplace_back(PackageMatchField::Id, MatchType::CaseInsensitive, "GIT.PACKAGE"); + } + SECTION("Mismatching ID filter") + { + request.Filters.emplace_back(PackageMatchField::Id, MatchType::CaseInsensitive, "Other.Package"); + expectedCount = 0; + } + SECTION("Unsupported inclusion is removed") + { + request.Inclusions.emplace_back(PackageMatchField::Moniker, MatchType::CaseInsensitive, "git"); + request.Inclusions.emplace_back(PackageMatchField::Id, MatchType::CaseInsensitive, "GIT.PACKAGE"); + } + Schema::IRestClient::SearchResult searchResponse = v1_1.Search(request); - REQUIRE(searchResponse.Matches.size() == 1); + const auto& filters = searchBody.at(L"Filters").as_array(); + REQUIRE(filters.size() == request.Filters.size() + 1); + REQUIRE(filters.at(0).at(L"PackageMatchField").as_string() == L"PackageName"); + REQUIRE(filters.at(0).at(L"RequestMatch").at(L"KeyWord").as_string() == L"package"); + REQUIRE(filters.at(request.Filters.size()).at(L"PackageMatchField").as_string() == L"Market"); + if (!request.Inclusions.empty()) + { + REQUIRE(request.Inclusions.size() == 2); + const auto& inclusions = searchBody.at(L"Inclusions").as_array(); + REQUIRE(inclusions.size() == 1); + REQUIRE(inclusions.at(0).at(L"PackageMatchField").as_string() == L"PackageIdentifier"); + REQUIRE(inclusions.at(0).at(L"RequestMatch").at(L"KeyWord").as_string() == L"GIT.PACKAGE"); + } + REQUIRE(searchResponse.Matches.size() == expectedCount); + if (!expectedCount) + { + return; + } + Schema::IRestClient::Package package = searchResponse.Matches.at(0); REQUIRE(package.PackageInformation.PackageIdentifier.compare("git.package") == 0); REQUIRE(package.PackageInformation.Publisher.compare("git") == 0); @@ -414,6 +465,85 @@ TEST_CASE("Search_GoodRequest_OnlyMarketRequired", "[RestSource][Interface_1_1]" REQUIRE(package.Versions.at(1).VersionAndChannel.GetVersion().ToString().compare("2.0.0") == 0); } +TEST_CASE("Search_Inclusions_SourceCapabilities", "[RestSource][Interface_1_1]") +{ + utility::string_t sample = _XPLATSTR( + R"delimiter({ + "Data": [{ + "PackageIdentifier": "git.package", + "PackageName": "package", + "Publisher": "git", + "Versions": [{ "PackageVersion": "1.0.0" }] + }] + })delimiter"); + + size_t requestCount = 0; + auto handler = std::make_shared( + [&](web::http::http_request httpRequest) -> pplx::task + { + ++requestCount; + web::http::http_response response{ web::http::status_codes::BadRequest }; + response.headers().set_content_type(web::http::details::mime_types::application_json); + response.headers().set_cache_control(L"no-store"); + if (httpRequest.method() == web::http::methods::POST) + { + response.set_status_code(web::http::status_codes::OK); + response.set_body(web::json::value::parse(sample)); + } + return pplx::task_from_result(response); + }); + HttpClientHelper helper{ handler }; + Interface v1_1{ TestRestUriString, helper, GetTestSourceInformation(), {} }; + SearchRequest request; + request.Inclusions.emplace_back(PackageMatchField::Moniker, MatchType::CaseInsensitive, "git"); + size_t expectedCount = 1; + size_t expectedRequestCount = 1; + + SECTION("Matching supported inclusion") + { + request.Inclusions.emplace_back(PackageMatchField::Id, MatchType::CaseInsensitive, "GIT.PACKAGE"); + } + SECTION("Unsupported inclusion cannot admit an ID mismatch") + { + request.Inclusions.emplace_back(PackageMatchField::Id, MatchType::Exact, "Other.Package"); + expectedCount = 0; + } + SECTION("Only unsupported inclusions must not become an unrestricted search") + { + expectedCount = 0; + expectedRequestCount = 0; + } + SECTION("Removed inclusions must not trigger ID fallback") + { + request.Filters.emplace_back(PackageMatchField::Id, MatchType::Substring, "git.package"); + expectedCount = 0; + expectedRequestCount = 0; + } + SECTION("A query can still select when all inclusions are unsupported") + { + request.Query.emplace(MatchType::Substring, "git"); + } + SECTION("No selectors were requested") + { + request.Inclusions.clear(); + } + SECTION("Correlation retains unverified reference matches") + { + request.Inclusions.clear(); + request.Purpose = SearchPurpose::CorrelationToAvailable; + request.Inclusions.emplace_back(PackageMatchField::ProductCode, MatchType::Exact, "Not in the response"); + } + + auto result = v1_1.Search(request); + REQUIRE(requestCount == expectedRequestCount); + REQUIRE(result.Matches.size() == expectedCount); + REQUIRE_FALSE(result.Truncated); + if (expectedCount) + { + REQUIRE(result.Matches[0].PackageInformation.PackageIdentifier == "git.package"); + } +} + TEST_CASE("GetManifests_BadRequest_UnsupportedQueryParameters", "[RestSource][Interface_1_1]") { utility::string_t sample = _XPLATSTR( diff --git a/src/AppInstallerCLITests/SourceFlow.cpp b/src/AppInstallerCLITests/SourceFlow.cpp index 485682c2e9..d6f4084c26 100644 --- a/src/AppInstallerCLITests/SourceFlow.cpp +++ b/src/AppInstallerCLITests/SourceFlow.cpp @@ -4,6 +4,8 @@ #include "WorkflowCommon.h" #include "TestHooks.h" #include "TestSettings.h" +#include "TestRestRequestHandler.h" +#include #include #include #include @@ -109,6 +111,95 @@ TEST_CASE("SourcePriority_SearchResult", "[SourcePriority][workflow]") } } +TEST_CASE("Search_ManifestResolution_BeforeSourcePriority", "[RestSource][SourcePriority][workflow]") +{ + namespace RepositoryRest = AppInstaller::Repository::Rest; + bool restMatches = GENERATE(false, true); + CAPTURE(restMatches); + auto searchResponse = web::json::value::parse(LR"({ + "Data": [{ + "PackageIdentifier": "Foo.Bar", "PackageName": "Unrelated application", "Publisher": "Foo", + "Versions": [{ "PackageVersion": "Unknown" }] + }] + })"); + auto manifestResponse = web::json::value::parse(LR"({ + "Data": { + "PackageIdentifier": "Foo.Bar", + "Versions": [{ + "PackageVersion": "1.0.0", + "DefaultLocale": { + "PackageLocale": "en-US", "PackageName": "Bar", "Publisher": "Foo", + "License": "MIT", "ShortDescription": "Example application" + }, + "Installers": [{ + "Architecture": "x64", "InstallerType": "exe", "InstallerUrl": "https://example.com/installer.exe", + "InstallerSha256": "011048877dfaef109801b3f3ab2b60afc74f3fc4f7b3430e0c897f5da1df84b6" + }] + }] + } + })"); + if (restMatches) + { + manifestResponse[L"Data"][L"Versions"][0][L"DefaultLocale"][L"Moniker"] = web::json::value::string(L"tool"); + } + size_t searches = 0; + size_t lookups = 0; + auto handler = std::make_shared( + [&](web::http::http_request request) -> pplx::task + { + web::http::http_response response{ web::http::status_codes::BadRequest }; + response.headers().set_content_type(web::http::details::mime_types::application_json); + response.headers().set_cache_control(L"no-store"); + if (request.method() == web::http::methods::POST) + { + ++searches; + response.set_status_code(web::http::status_codes::OK); + response.set_body(searchResponse); + } + else if (request.method() == web::http::methods::GET) + { + ++lookups; + response.set_status_code(web::http::status_codes::OK); + response.set_body(manifestResponse); + } + return pplx::task_from_result(response); + }); + AppInstaller::Http::HttpClientHelper helper{ handler }; + SourceDetails details; + details.Identifier = "RestSource"; + details.Priority = 10; + auto rest = std::make_shared(details, SourceInformation{}, + RepositoryRest::RestClient::Create("https://restsource.com/api", {}, {}, helper, + RepositoryRest::Schema::IRestClient::Information{ "RestSource", { "1.4.0" } })); + + std::ostringstream output; + TestContext context{ output, std::cin }; + auto previousThreadGlobals = context.SetForCurrentThread(); + context.Args.AddArg(Execution::Args::Type::Query, "tool"sv); + context << GetSearchRequestForSingle; + auto results = rest->Search(context.Get()); + REQUIRE(results.Matches.size() == (restMatches ? size_t{ 1 } : size_t{ 0 })); + + AppInstaller::Manifest::Manifest manifest; + manifest.Id = "Example.Tool"; + manifest.Version = "1.0.0"; + manifest.Moniker = "tool"; + manifest.DefaultLocalization.Add("Example Tool"); + auto otherSource = std::make_shared(); + otherSource->Details.Priority = 0; + auto otherPackage = TestCompositePackage::Make(std::vector{ manifest }, otherSource); + auto expectedPackage = restMatches ? results.Matches[0].Package : otherPackage; + results.Matches.emplace_back(otherPackage, PackageMatchFilter{ PackageMatchField::Moniker, MatchType::Exact, "tool" }); + context.Add(std::move(results)); + context << EnsureOneMatchFromSearchResult(OperationType::Install); + + INFO(output.str()); + REQUIRE(context.GetTerminationHR() == S_OK); + REQUIRE(context.Get() == expectedPackage); + REQUIRE(searches == 1); + REQUIRE(lookups == 1); +} + TEST_CASE("SourcePriority_SourceOutput", "[SourcePriority][workflow]") { GroupPolicyTestOverride policies; diff --git a/src/AppInstallerCLITests/Strings.cpp b/src/AppInstallerCLITests/Strings.cpp index 249926dada..b131e6720e 100644 --- a/src/AppInstallerCLITests/Strings.cpp +++ b/src/AppInstallerCLITests/Strings.cpp @@ -136,6 +136,25 @@ TEST_CASE("CaseInsensitiveStartsWith", "[strings]") REQUIRE(!CaseInsensitiveStartsWith(" starts", "starts")); } +TEST_CASE("ICUCaseInsensitiveStartsWith", "[strings]") +{ + REQUIRE(ICUCaseInsensitiveStartsWith("startswith", "starts")); + REQUIRE(ICUCaseInsensitiveStartsWith("startswith", "STAR")); + REQUIRE(ICUCaseInsensitiveStartsWith("startswith", "STARTSWITH")); + REQUIRE(ICUCaseInsensitiveStartsWith("startswith", "")); + REQUIRE(ICUCaseInsensitiveStartsWith("", "")); + REQUIRE_FALSE(ICUCaseInsensitiveStartsWith("starts", "startswith")); + REQUIRE_FALSE(ICUCaseInsensitiveStartsWith("", "starts")); + REQUIRE_FALSE(ICUCaseInsensitiveStartsWith("withstarts", "starts")); + REQUIRE_FALSE(ICUCaseInsensitiveStartsWith(" starts", "starts")); + + REQUIRE(ICUCaseInsensitiveStartsWith(u8"Vendor.\u1E9EApp", u8"vendor.\u00DF")); + REQUIRE(ICUCaseInsensitiveStartsWith(u8"Vendor.\u00DFApp", u8"vendor.\u1E9E")); + REQUIRE(ICUCaseInsensitiveStartsWith(u8"Stra\u00DFe", "STRASSE")); + REQUIRE(ICUCaseInsensitiveStartsWith("STRASSE", u8"stra\u00DFe")); + REQUIRE_FALSE(ICUCaseInsensitiveStartsWith(u8"Vendor.\u00DFApp", "vendor.st")); +} + TEST_CASE("FoldCase", "[strings]") { REQUIRE(FoldCase(""sv) == FoldCase(""sv)); diff --git a/src/AppInstallerCLITests/YamlManifest.cpp b/src/AppInstallerCLITests/YamlManifest.cpp index c9d353861e..f893881f83 100644 --- a/src/AppInstallerCLITests/YamlManifest.cpp +++ b/src/AppInstallerCLITests/YamlManifest.cpp @@ -740,6 +740,110 @@ namespace } } +TEST_CASE("Manifest_PackageNames", "[ManifestValidation]") +{ + std::string_view defaultNameState = GENERATE("Missing", "Empty", "Present"); + CAPTURE(defaultNameState); + Manifest manifest; + REQUIRE(manifest.GetPackageNames().empty()); + REQUIRE(manifest.GetOriginalPackageNames().empty()); + if (defaultNameState != "Missing") + { + const std::string_view defaultName = defaultNameState == "Present" ? "Default Name" : ""; + manifest.DefaultLocalization.Add(defaultName); + } + manifest.Localizations.emplace_back().Add("Localized Name"); + manifest.Localizations.emplace_back().Add("localized name"); + manifest.Localizations.emplace_back().Add("Localized Name"); + manifest.Localizations.emplace_back().Add(u8"Caf\u00E9"); + manifest.Localizations.emplace_back().Add(u8"Cafe\u0301"); + manifest.Localizations.emplace_back().Add(""); + manifest.Localizations.emplace_back(); + auto& installer = manifest.Installers.emplace_back(); + installer.AppsAndFeaturesEntries.emplace_back().DisplayName = "Installed Name"; + installer.AppsAndFeaturesEntries.emplace_back().DisplayName = "Localized Name"; + installer.AppsAndFeaturesEntries.emplace_back().Publisher = "Unused Publisher"; + manifest.Installers.emplace_back().AppsAndFeaturesEntries.emplace_back().DisplayName = "Other Installed Name"; + manifest.CurrentLocalization.Add("Current Name"); + + std::vector expected{ u8"caf\u00E9" }; + if (defaultNameState == "Present") + { + expected.emplace_back("default name"); + } + expected.insert(expected.end(), { "installed name", "localized name", "other installed name" }); + REQUIRE(manifest.GetPackageNames() == expected); + + std::vector expectedOriginal{ u8"Caf\u00E9" }; + if (defaultNameState == "Present") + { + expectedOriginal.emplace_back("Default Name"); + } + expectedOriginal.insert(expectedOriginal.end(), { "Installed Name", "Localized Name", "Other Installed Name", "localized name" }); + REQUIRE(manifest.GetOriginalPackageNames() == expectedOriginal); +} + +TEST_CASE("Manifest_NameAndPublisherPairs", "[ManifestValidation]") +{ + std::string_view defaultNameState = GENERATE("Missing", "Empty", "Present"); + std::string_view defaultPublisherState = GENERATE("Missing", "Empty", "Present"); + bool emptyLocalizationValues = GENERATE(false, true); + CAPTURE(defaultNameState, defaultPublisherState, emptyLocalizationValues); + Manifest manifest; + const std::string defaultName = defaultNameState == "Present" ? "Default Name" : ""; + const std::string defaultPublisher = defaultPublisherState == "Present" ? "Default Publisher" : ""; + if (defaultNameState != "Missing") + { + manifest.DefaultLocalization.Add(defaultName); + } + if (defaultPublisherState != "Missing") + { + manifest.DefaultLocalization.Add(defaultPublisher); + } + auto& localization = manifest.Localizations.emplace_back(); + localization.Add("Localized Name"); + localization.Add("Localized Publisher"); + auto& nameOnly = manifest.Localizations.emplace_back(); + nameOnly.Add("Name Only"); + if (emptyLocalizationValues) + { + nameOnly.Add(""); + } + auto& publisherOnly = manifest.Localizations.emplace_back(); + publisherOnly.Add("Publisher Only"); + if (emptyLocalizationValues) + { + publisherOnly.Add(""); + } + auto& emptyLocalization = manifest.Localizations.emplace_back(); + if (emptyLocalizationValues) + { + emptyLocalization.Add(""); + emptyLocalization.Add(""); + } + auto& installer = manifest.Installers.emplace_back(); + auto& entry = installer.AppsAndFeaturesEntries.emplace_back(); + entry.DisplayName = "Installed Name"; + entry.Publisher = "Installed Publisher"; + installer.AppsAndFeaturesEntries.emplace_back().DisplayName = "Fallback Name"; + installer.AppsAndFeaturesEntries.emplace_back().Publisher = "Unused Publisher"; + + std::vector> expected; + if (!defaultName.empty()) + { + expected.emplace_back(defaultName, defaultPublisher); + } + expected.emplace_back("Localized Name", "Localized Publisher"); + expected.emplace_back("Name Only", defaultPublisher); + if (!defaultName.empty()) + { + expected.emplace_back(defaultName, "Publisher Only"); + } + expected.emplace_back("Installed Name", "Installed Publisher"); + expected.emplace_back("Fallback Name", defaultPublisher); + REQUIRE(manifest.GetNameAndPublisherPairs() == expected); +} + TEST_CASE("ReadPreviewGoodManifestAndVerifyContents", "[ManifestValidation]") { auto manifestFile = TestDataFile("Manifest-Good.yaml"); diff --git a/src/AppInstallerCommonCore/Manifest/Manifest.cpp b/src/AppInstallerCommonCore/Manifest/Manifest.cpp index 8e5dee34d4..31eb41b26c 100644 --- a/src/AppInstallerCommonCore/Manifest/Manifest.cpp +++ b/src/AppInstallerCommonCore/Manifest/Manifest.cpp @@ -187,10 +187,33 @@ namespace AppInstaller::Manifest { std::set set; - AddFoldedStringToSetIfNotEmpty(set, DefaultLocalization.Get()); + for (const auto& name : GetOriginalPackageNames()) + { + AddFoldedStringToSetIfNotEmpty(set, name); + } + + std::vector result( + std::make_move_iterator(set.begin()), + std::make_move_iterator(set.end())); + + return result; + } + + std::vector Manifest::GetOriginalPackageNames() const + { + std::set set; + auto addName = [&](const string_t& name) + { + if (!name.empty()) + { + set.emplace(name); + } + }; + + addName(DefaultLocalization.Get()); for (const auto& loc : Localizations) { - AddFoldedStringToSetIfNotEmpty(set, loc.Get()); + addName(loc.Get()); } // In addition to the names used for our display, add the display names from the ARP entries @@ -198,7 +221,7 @@ namespace AppInstaller::Manifest { for (const auto& appsAndFeaturesEntry : installer.AppsAndFeaturesEntries) { - AddFoldedStringToSetIfNotEmpty(set, appsAndFeaturesEntry.DisplayName); + addName(appsAndFeaturesEntry.DisplayName); } } @@ -235,6 +258,43 @@ namespace AppInstaller::Manifest return result; } + std::vector> Manifest::GetNameAndPublisherPairs() const + { + std::vector> result; + const auto defaultName = DefaultLocalization.Get(); + const auto defaultPublisher = DefaultLocalization.Get(); + if (!defaultName.empty()) + { + result.emplace_back(defaultName, defaultPublisher); + } + for (const auto& localization : Localizations) + { + const auto localizedName = localization.Get(); + const auto localizedPublisher = localization.Get(); + if (!localizedName.empty() || !localizedPublisher.empty()) + { + const auto& name = localizedName.empty() ? defaultName : localizedName; + const auto& publisher = localizedPublisher.empty() ? defaultPublisher : localizedPublisher; + if (!name.empty()) + { + result.emplace_back(name, publisher); + } + } + } + + for (const auto& installer : Installers) + { + for (const auto& entry : installer.AppsAndFeaturesEntries) + { + if (!entry.DisplayName.empty()) + { + result.emplace_back(entry.DisplayName, entry.Publisher.empty() ? defaultPublisher : entry.Publisher); + } + } + } + return result; + } + std::vector Manifest::GetSystemReferenceStrings( std::function extractStringFromInstaller, std::function extractStringFromAppsAndFeaturesEntry) const diff --git a/src/AppInstallerCommonCore/Public/winget/Manifest.h b/src/AppInstallerCommonCore/Public/winget/Manifest.h index ea4f9f23cd..27e50fee34 100644 --- a/src/AppInstallerCommonCore/Public/winget/Manifest.h +++ b/src/AppInstallerCommonCore/Public/winget/Manifest.h @@ -8,6 +8,7 @@ #include #include +#include #include namespace AppInstaller::Manifest @@ -62,9 +63,15 @@ namespace AppInstaller::Manifest // Get package names across localizations and installers, Case folded. std::vector GetPackageNames() const; + // Get unique, non-empty package names across localizations and installers, preserving case. + std::vector GetOriginalPackageNames() const; + // Get publishers across localizations and installers, Case folded. std::vector GetPublishers() const; + // Gets name/publisher pairs with non-empty names and default fallbacks, preserving case. + std::vector> GetNameAndPublisherPairs() const; + // If not empty, the SHA256 hash of the manifest stream itself. Utility::SHA256::HashBuffer StreamSha256; diff --git a/src/AppInstallerRepositoryCore/ARPCorrelation.cpp b/src/AppInstallerRepositoryCore/ARPCorrelation.cpp index 871a932d07..17ab5b3c73 100644 --- a/src/AppInstallerRepositoryCore/ARPCorrelation.cpp +++ b/src/AppInstallerRepositoryCore/ARPCorrelation.cpp @@ -165,27 +165,9 @@ namespace AppInstaller::Repository::Correlation // Also attempt to find the entry based on the manifest data SearchRequest manifestSearchRequest; - AppInstaller::Manifest::Manifest::string_t defaultPublisher; - if (manifest.DefaultLocalization.Contains(Localization::Publisher)) + for (const auto& [name, publisher] : manifest.GetNameAndPublisherPairs()) { - defaultPublisher = manifest.DefaultLocalization.Get(); - } - - // The default localization must contain the name or we cannot do this lookup - if (manifest.DefaultLocalization.Contains(Localization::PackageName)) - { - AppInstaller::Manifest::Manifest::string_t defaultName = manifest.DefaultLocalization.Get(); - manifestSearchRequest.Inclusions.emplace_back(PackageMatchFilter(PackageMatchField::NormalizedNameAndPublisher, MatchType::Exact, defaultName, defaultPublisher)); - - for (const auto& loc : manifest.Localizations) - { - if (loc.Contains(Localization::PackageName) || loc.Contains(Localization::Publisher)) - { - manifestSearchRequest.Inclusions.emplace_back(PackageMatchFilter(PackageMatchField::NormalizedNameAndPublisher, MatchType::Exact, - loc.Contains(Localization::PackageName) ? loc.Get() : defaultName, - loc.Contains(Localization::Publisher) ? loc.Get() : defaultPublisher)); - } - } + manifestSearchRequest.Inclusions.emplace_back(PackageMatchFilter(PackageMatchField::NormalizedNameAndPublisher, MatchType::Exact, name, publisher)); } std::set productCodes; @@ -203,13 +185,6 @@ namespace AppInstaller::Repository::Correlation for (const auto& appsAndFeaturesEntry : installer.AppsAndFeaturesEntries) { - if (!appsAndFeaturesEntry.DisplayName.empty()) - { - manifestSearchRequest.Inclusions.emplace_back(PackageMatchFilter(PackageMatchField::NormalizedNameAndPublisher, MatchType::Exact, - appsAndFeaturesEntry.DisplayName, - appsAndFeaturesEntry.Publisher.empty() ? defaultPublisher : appsAndFeaturesEntry.Publisher)); - } - // Add each ProductCode and UpgradeCode only once; if (!appsAndFeaturesEntry.ProductCode.empty() && productCodes.insert(appsAndFeaturesEntry.ProductCode).second) { diff --git a/src/AppInstallerRepositoryCore/CompositeSource.cpp b/src/AppInstallerRepositoryCore/CompositeSource.cpp index 9b47c95107..f6aa16bdeb 100644 --- a/src/AppInstallerRepositoryCore/CompositeSource.cpp +++ b/src/AppInstallerRepositoryCore/CompositeSource.cpp @@ -969,15 +969,12 @@ namespace AppInstaller::Repository { AddIfNotPresent(SystemReferenceString{ PackageMatchField::UpgradeCode, Utility::LocIndString{ upgradeCode } }); } - for (const auto& name : manifest.GetPackageNames()) + for (auto&& [name, publisher] : manifest.GetNameAndPublisherPairs()) { - for (const auto& publisher : manifest.GetPublishers()) - { - AddIfNotPresent(SystemReferenceString{ - PackageMatchField::NormalizedNameAndPublisher, - Utility::LocIndString{ name }, - Utility::LocIndString{ publisher } }); - } + AddIfNotPresent(SystemReferenceString{ + PackageMatchField::NormalizedNameAndPublisher, + Utility::LocIndString{ std::move(name) }, + Utility::LocIndString{ std::move(publisher) } }); } } @@ -996,21 +993,13 @@ namespace AppInstaller::Repository void GetNameAndPublisher( IPackage* package) { - // Unfortunately the names and publishers are unique and not tied to each other strictly, so we need - // to go broad on the matches. Future work can hopefully make name and publisher operate more as a unit, - // but for now we have to search for the cartesian of these... - auto names = package->GetMultiProperty(PackageMultiProperty::NormalizedName); - auto publishers = package->GetMultiProperty(PackageMultiProperty::NormalizedPublisher); - - for (const auto& name : names) + for (auto&& row : package->GetMatrixProperty(PackageMatrixProperty::NormalizedNameAndPublisher)) { - for (const auto& publisher : publishers) - { - AddIfNotPresent(SystemReferenceString{ - PackageMatchField::NormalizedNameAndPublisher, - name, - publisher }); - } + THROW_HR_IF(E_UNEXPECTED, row.size() != 2); + AddIfNotPresent(SystemReferenceString{ + PackageMatchField::NormalizedNameAndPublisher, + Utility::LocIndString{ std::move(row[0]) }, + Utility::LocIndString{ std::move(row[1]) } }); } } }; diff --git a/src/AppInstallerRepositoryCore/MatchCriteriaResolver.cpp b/src/AppInstallerRepositoryCore/MatchCriteriaResolver.cpp index 46d7fd72b5..69e65ed327 100644 --- a/src/AppInstallerRepositoryCore/MatchCriteriaResolver.cpp +++ b/src/AppInstallerRepositoryCore/MatchCriteriaResolver.cpp @@ -9,11 +9,6 @@ namespace AppInstaller::Repository { using ValueMatchFunction = bool (*)(const Utility::NormalizedString&, const Utility::NormalizedString&); - bool ValueMatchFunction_AlwaysFalse(const Utility::NormalizedString&, const Utility::NormalizedString&) - { - return false; - } - bool ValueMatchFunction_Exact(const Utility::NormalizedString& a, const Utility::NormalizedString& b) { return a == b; @@ -50,7 +45,7 @@ namespace AppInstaller::Repository case MatchType::FuzzySubstring: case MatchType::Wildcard: default: - return ValueMatchFunction_AlwaysFalse; + return nullptr; } } @@ -105,7 +100,7 @@ namespace AppInstaller::Repository auto matchFunction = GetMatchTypeFunction(matchType); - if (matchFunction(value, request.Value)) + if (matchFunction && matchFunction(value, request.Value)) { return matchType; } @@ -167,6 +162,170 @@ namespace AppInstaller::Repository return MatchType::Exact == result.Type; } + + // Keep definitive results; new metadata may answer previously unknown fields. + std::optional EvaluateRequest(const SearchRequest& request, + const std::function(const PackageMatchFilter&)>& matchesAvailableField, + std::vector>& filterMatches, + std::vector>& inclusionMatches, + bool& selectionMatches) + { + bool allFiltersMatch = true; + for (size_t i = 0; i < request.Filters.size(); ++i) + { + auto& match = filterMatches[i]; + if (!match.has_value()) + { + match = matchesAvailableField(request.Filters[i]); + } + if (match == false) + { + return false; + } + if (!match.has_value()) + { + allFiltersMatch = false; + } + } + + selectionMatches = !request.Query && request.Inclusions.empty(); + bool selectionUnknown = request.Query.has_value(); + for (size_t i = 0; i < request.Inclusions.size(); ++i) + { + auto& match = inclusionMatches[i]; + if (!match.has_value()) + { + match = matchesAvailableField(request.Inclusions[i]); + } + if (match == true) + { + selectionMatches = true; + break; + } + if (!match.has_value()) + { + selectionUnknown = true; + } + } + + if (!selectionMatches && !selectionUnknown) + { + return false; + } + if (allFiltersMatch && selectionMatches) + { + return true; + } + return std::nullopt; + } + } + + std::optional MatchesRequest(const RequestMatch& request, const Utility::NormalizedString& value) + { + if (auto matchFunction = GetMatchTypeFunction(request.Type)) + { + return matchFunction(value, request.Value); + } + + return std::nullopt; + } + + std::optional MatchesRequest(const PackageMatchFilter& request, const Utility::NormalizedString& value) + { + if (request.Type == MatchType::Exact && + (request.Field == PackageMatchField::PackageFamilyName || request.Field == PackageMatchField::ProductCode || + request.Field == PackageMatchField::UpgradeCode)) + { + return ValueMatchFunction_CaseInsensitive(value, request.Value); + } + + return MatchesRequest(static_cast(request), value); + } + + std::optional MatchesRequest(const PackageMatchFilter& request, const Manifest::Manifest& manifest) + { + if (!GetMatchTypeFunction(request.Type)) + { + return std::nullopt; + } + + auto matches = [&](const Utility::NormalizedString& value) + { + return !value.empty() && MatchesRequest(request, value).value_or(false); + }; + auto matchesAny = [&](const auto& values) + { + return std::any_of(values.begin(), values.end(), matches); + }; + + switch (request.Field) + { + case PackageMatchField::Id: + return matches(manifest.Id); + case PackageMatchField::Name: + return matchesAny(manifest.GetOriginalPackageNames()); + case PackageMatchField::Moniker: + return matches(manifest.Moniker); + case PackageMatchField::Tag: + return matchesAny(manifest.GetAggregatedTags()); + case PackageMatchField::Command: + return matchesAny(manifest.GetAggregatedCommands()); + case PackageMatchField::PackageFamilyName: + return matchesAny(manifest.GetPackageFamilyNames()); + case PackageMatchField::ProductCode: + return matchesAny(manifest.GetProductCodes()); + case PackageMatchField::UpgradeCode: + return matchesAny(manifest.GetUpgradeCodes()); + default: + return std::nullopt; + } + } + + std::optional MatchesRequest(const SearchRequest& request, + const std::function(const PackageMatchFilter&)>& matchesAvailableField, + const std::function(const PackageMatchFilter&)>& resolveUnknownField) + { + std::vector> filterMatches(request.Filters.size()); + std::vector> inclusionMatches(request.Inclusions.size()); + bool selectionMatches = false; + + auto result = EvaluateRequest(request, matchesAvailableField, filterMatches, inclusionMatches, selectionMatches); + if (result.has_value() || !resolveUnknownField) + { + return result; + } + + // Resolve each unknown at most once, checking all available metadata after each attempt. + for (size_t i = 0; i < request.Filters.size(); ++i) + { + if (!filterMatches[i].has_value()) + { + filterMatches[i] = resolveUnknownField(request.Filters[i]); + result = EvaluateRequest(request, matchesAvailableField, filterMatches, inclusionMatches, selectionMatches); + if (result.has_value()) + { + return result; + } + } + } + + // A source-defined query can still match even if every inclusion fails. + if (!request.Query && !selectionMatches) + { + for (size_t i = 0; i < request.Inclusions.size(); ++i) + { + if (!inclusionMatches[i].has_value()) + { + inclusionMatches[i] = resolveUnknownField(request.Inclusions[i]); + result = EvaluateRequest(request, matchesAvailableField, filterMatches, inclusionMatches, selectionMatches); + if (result.has_value() || selectionMatches) + { + return result; + } + } + } + } + return std::nullopt; } PackageMatchFilter FindBestMatchCriteria(const SearchRequest& request, const IPackageVersion* packageVersion) diff --git a/src/AppInstallerRepositoryCore/MatchCriteriaResolver.h b/src/AppInstallerRepositoryCore/MatchCriteriaResolver.h index 6ad225275f..19b02d49cd 100644 --- a/src/AppInstallerRepositoryCore/MatchCriteriaResolver.h +++ b/src/AppInstallerRepositoryCore/MatchCriteriaResolver.h @@ -2,9 +2,24 @@ // Licensed under the MIT License. #pragma once #include "Public/winget/RepositorySearch.h" +#include namespace AppInstaller::Repository { + // Returns whether the value matches, or nullopt if the match type cannot be evaluated locally. + std::optional MatchesRequest(const RequestMatch& request, const Utility::NormalizedString& value); + + // Applies field-specific casing rules when comparing a value. + std::optional MatchesRequest(const PackageMatchFilter& request, const Utility::NormalizedString& value); + + // Manifest matching leaves unsupported fields (including NormalizedNameAndPublisher) and match types unknown. + std::optional MatchesRequest(const PackageMatchFilter& request, const Manifest::Manifest& manifest); + + // Evaluates (Query OR Inclusions) AND Filters; source-defined queries remain unknown. + std::optional MatchesRequest(const SearchRequest& request, + const std::function(const PackageMatchFilter&)>& matchesAvailableField, + const std::function(const PackageMatchFilter&)>& resolveUnknownField = {}); + // Finds the highest rated match criteria for the package based on the search request, PackageMatchFilter FindBestMatchCriteria(const SearchRequest& request, const IPackageVersion* packageVersion); } diff --git a/src/AppInstallerRepositoryCore/Public/winget/RepositorySearch.h b/src/AppInstallerRepositoryCore/Public/winget/RepositorySearch.h index 2a1bff82a1..8ac190e7fe 100644 --- a/src/AppInstallerRepositoryCore/Public/winget/RepositorySearch.h +++ b/src/AppInstallerRepositoryCore/Public/winget/RepositorySearch.h @@ -12,6 +12,7 @@ #include #include #include +#include #include @@ -307,6 +308,13 @@ namespace AppInstaller::Repository // Maps the package multi-property value to its package version multi-property value for internal use. PackageVersionMultiProperty PackageMultiPropertyToPackageVersionMultiProperty(PackageMultiProperty property); + // A property of a package that contains rows of related values. + enum class PackageMatrixProperty + { + // Each row contains exactly two values: name, then publisher. + NormalizedNameAndPublisher, + }; + // To allow for runtime casting from IPackage to the specific types, this enum contains all of the IPackage implementations. enum class IPackageType { @@ -346,6 +354,9 @@ namespace AppInstaller::Repository // Gets a property of this package that can have multiple values. virtual std::vector GetMultiProperty(PackageMultiProperty property) const = 0; + // Gets rows of related values with columns defined by the matrix property. + virtual std::vector> GetMatrixProperty(PackageMatrixProperty property) const; + // Gets the source that this package is from. virtual Source GetSource() const = 0; diff --git a/src/AppInstallerRepositoryCore/RepositorySearch.cpp b/src/AppInstallerRepositoryCore/RepositorySearch.cpp index 358600a9d1..d2166e734d 100644 --- a/src/AppInstallerRepositoryCore/RepositorySearch.cpp +++ b/src/AppInstallerRepositoryCore/RepositorySearch.cpp @@ -123,6 +123,29 @@ namespace AppInstaller::Repository } } + std::vector> IPackage::GetMatrixProperty(PackageMatrixProperty property) const + { + switch (property) + { + case PackageMatrixProperty::NormalizedNameAndPublisher: + { + auto names = GetMultiProperty(PackageMultiProperty::NormalizedName); + auto publishers = GetMultiProperty(PackageMultiProperty::NormalizedPublisher); + std::vector> result; + for (const auto& name : names) + { + for (const auto& publisher : publishers) + { + result.push_back({ name.get(), publisher.get() }); + } + } + return result; + } + default: + THROW_HR(E_UNEXPECTED); + } + } + const char* UnsupportedRequestException::what() const noexcept { if (m_whatMessage.empty()) diff --git a/src/AppInstallerRepositoryCore/Rest/RestSource.cpp b/src/AppInstallerRepositoryCore/Rest/RestSource.cpp index ebfcde708b..6606cff198 100644 --- a/src/AppInstallerRepositoryCore/Rest/RestSource.cpp +++ b/src/AppInstallerRepositoryCore/Rest/RestSource.cpp @@ -57,6 +57,8 @@ namespace AppInstaller::Repository::Rest std::vector GetMultiProperty(PackageMultiProperty property) const override; + std::vector> GetMatrixProperty(PackageMatrixProperty property) const override; + std::vector GetVersionKeys() const override { std::shared_ptr source = GetReferenceSource(); @@ -144,6 +146,7 @@ namespace AppInstaller::Repository::Rest if (result.Matches.size() == 1) { + m_package.SearchVersions = std::move(m_package.Versions); m_package.Versions = std::move(result.Matches[0].Versions); SortVersionsInternal(); } @@ -269,25 +272,55 @@ namespace AppInstaller::Repository::Rest std::scoped_lock versionsLock{ m_packageVersionsLock }; std::vector result; PackageVersionMultiProperty mappedProperty = PackageMultiPropertyToPackageVersionMultiProperty(property); + auto addValue = [](std::vector& values, Utility::LocIndString&& string) + { + auto itr = std::lower_bound(values.begin(), values.end(), string); + + if (itr == values.end() || *itr != string) + { + values.emplace(itr, std::move(string)); + } + }; + + for (const auto& versionInfo : m_package.SearchVersions) + { + GetMultiPropertyValues( + this, versionInfo, mappedProperty, result, addValue); + } for (const auto& versionInfo : m_package.Versions) { GetMultiPropertyValues( - this, - versionInfo, - mappedProperty, - result, - [](std::vector& result, Utility::LocIndString&& string) - { - auto itr = std::lower_bound(result.begin(), result.end(), string); + this, versionInfo, mappedProperty, result, addValue); + } + + return result; + } - if (itr == result.end() || *itr != string) + std::vector> RestPackage::GetMatrixProperty(PackageMatrixProperty property) const + { + if (property != PackageMatrixProperty::NormalizedNameAndPublisher) + { + return IPackage::GetMatrixProperty(property); + } + + std::scoped_lock versionsLock{ m_packageVersionsLock }; + std::vector> result; + result.push_back({ m_package.PackageInformation.PackageName, m_package.PackageInformation.Publisher }); + for (const auto& version : m_package.Versions) + { + if (version.Manifest) + { + for (auto&& [name, publisher] : version.Manifest->GetNameAndPublisherPairs()) + { + std::vector row{ std::move(name), std::move(publisher) }; + if (std::find(result.begin(), result.end(), row) == result.end()) { - result.emplace(itr, std::move(string)); + result.emplace_back(std::move(row)); } - }); + } + } } - return result; } diff --git a/src/AppInstallerRepositoryCore/Rest/Schema/1_0/Interface.h b/src/AppInstallerRepositoryCore/Rest/Schema/1_0/Interface.h index 12e77136b5..725f1d7c3a 100644 --- a/src/AppInstallerRepositoryCore/Rest/Schema/1_0/Interface.h +++ b/src/AppInstallerRepositoryCore/Rest/Schema/1_0/Interface.h @@ -31,8 +31,8 @@ namespace AppInstaller::Repository::Rest::Schema::V1_0 // Check query params against source information and update if necessary. virtual std::map GetValidatedQueryParams(const std::map& params) const; - // Check search request against source information and get json search body. - virtual web::json::value GetValidatedSearchBody(const SearchRequest& searchRequest) const; + // Check search request against source information and update if necessary. + virtual SearchRequest GetValidatedSearchRequest(const SearchRequest& searchRequest) const; virtual SearchResult GetSearchResult(const web::json::value& searchResponseObject) const; virtual std::vector GetParsedManifests(const web::json::value& manifestsResponseObject) const; @@ -43,6 +43,10 @@ namespace AppInstaller::Repository::Rest::Schema::V1_0 Http::HttpClientHelper::HttpRequestHeaders m_requiredRestApiHeaders; private: + // Returns whether manifest retrieval was deferred for a supported criterion. + bool FilterSearchResult(const SearchRequest& request, SearchResult& result, bool allowManifestRetrieval) const; + std::vector GetManifestsInternal(const std::string& packageId, const std::map& validatedParams) const; + std::string m_restApiUri; utility::string_t m_searchEndpoint; Http::HttpClientHelper m_httpClientHelper; diff --git a/src/AppInstallerRepositoryCore/Rest/Schema/1_0/Json/SearchRequestSerializer_1_0.cpp b/src/AppInstallerRepositoryCore/Rest/Schema/1_0/Json/SearchRequestSerializer_1_0.cpp index a44ce132c2..29b6e7144f 100644 --- a/src/AppInstallerRepositoryCore/Rest/Schema/1_0/Json/SearchRequestSerializer_1_0.cpp +++ b/src/AppInstallerRepositoryCore/Rest/Schema/1_0/Json/SearchRequestSerializer_1_0.cpp @@ -158,6 +158,7 @@ namespace AppInstaller::Repository::Rest::Schema::V1_0::Json std::optional SearchRequestSerializer::GetRequestMatchJsonObject(const AppInstaller::Repository::RequestMatch& requestMatch) const { + // This schema has no field for Additional; normalized-pair requests send the name without the publisher. web::json::value match = web::json::value::object(); match[JSON::GetUtilityString(KeyWord)] = web::json::value::string(JSON::GetUtilityString(requestMatch.Value)); diff --git a/src/AppInstallerRepositoryCore/Rest/Schema/1_0/RestInterface_1_0.cpp b/src/AppInstallerRepositoryCore/Rest/Schema/1_0/RestInterface_1_0.cpp index 5a9fb399e7..5b9ef51e9a 100644 --- a/src/AppInstallerRepositoryCore/Rest/Schema/1_0/RestInterface_1_0.cpp +++ b/src/AppInstallerRepositoryCore/Rest/Schema/1_0/RestInterface_1_0.cpp @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. #include "pch.h" +#include "MatchCriteriaResolver.h" #include "Rest/Schema/1_0/Interface.h" #include "Rest/Schema/IRestClient.h" #include @@ -21,6 +22,158 @@ namespace AppInstaller::Repository::Rest::Schema::V1_0 // Query params constexpr std::string_view VersionQueryParam = "Version"sv; constexpr std::string_view ChannelQueryParam = "Channel"sv; + constexpr std::string_view MarketQueryParam = "Market"sv; + + bool CanResolveWithManifest(const PackageMatchFilter& filter) + { + switch (filter.Type) + { + case MatchType::Exact: + case MatchType::CaseInsensitive: + case MatchType::StartsWith: + case MatchType::Substring: + break; + default: + return false; + } + + switch (filter.Field) + { + case PackageMatchField::Name: + case PackageMatchField::Moniker: + case PackageMatchField::Tag: + case PackageMatchField::Command: + case PackageMatchField::PackageFamilyName: + case PackageMatchField::ProductCode: + case PackageMatchField::UpgradeCode: + return true; + default: + return false; + } + } + + std::optional MatchesPackage(const PackageMatchFilter& filter, const IRestClient::Package& package) + { + if (filter.Field == PackageMatchField::Id) + { + return MatchesRequest(filter, package.PackageInformation.PackageIdentifier); + } + if (filter.Field == PackageMatchField::Name && + MatchesRequest(filter, package.PackageInformation.PackageName).value_or(false)) + { + return true; + } + + std::optional result = package.Versions.empty() ? std::nullopt : std::optional{ false }; + for (const auto& version : package.Versions) + { + const std::vector* values = nullptr; + switch (filter.Field) + { + case PackageMatchField::PackageFamilyName: + values = &version.PackageFamilyNames; + break; + case PackageMatchField::ProductCode: + values = &version.ProductCodes; + break; + case PackageMatchField::UpgradeCode: + values = &version.UpgradeCodes; + break; + } + + if (values && std::any_of(values->begin(), values->end(), [&](const auto& value) + { + return !value.empty() && MatchesRequest(filter, value).value_or(false); + })) + { + return true; + } + + auto match = version.Manifest ? MatchesRequest(filter, version.Manifest.value()) : std::nullopt; + if (match && match.value()) + { + return true; + } + if (!match) + { + result = std::nullopt; + } + } + + return result; + } + + std::vector CreateVersionInfos(std::vector manifests) + { + std::vector versions; + versions.reserve(manifests.size()); + for (auto& manifest : manifests) + { + auto packageFamilyNames = manifest.GetPackageFamilyNames(); + auto productCodes = manifest.GetProductCodes(); + auto arpVersionRange = manifest.GetArpVersionRange(); + auto upgradeCodes = manifest.GetUpgradeCodes(); + AppInstaller::Utility::VersionAndChannel versionAndChannel{ manifest.Version, manifest.Channel }; + + versions.emplace_back( + IRestClient::VersionInfo{ + std::move(versionAndChannel), + std::move(manifest), + std::vector{ packageFamilyNames.begin(), packageFamilyNames.end() }, + std::vector{ productCodes.begin(), productCodes.end() }, + arpVersionRange.IsEmpty() ? std::vector{} : std::vector{ arpVersionRange.GetMinVersion(), arpVersionRange.GetMaxVersion() }, + std::vector{ upgradeCodes.begin(), upgradeCodes.end() } }); + } + + return versions; + } + + std::optional FindManifestByVersionAndChannel( + const std::vector& manifests, std::string_view version, std::string_view channel) + { + for (const auto& manifest : manifests) + { + if (Utility::CaseInsensitiveEquals(manifest.Version, version) && + Utility::CaseInsensitiveEquals(manifest.Channel, channel)) + { + return manifest; + } + } + + return std::nullopt; + } + + void PopulateManifestCache(IRestClient::Package& package, std::vector manifests) + { + Utility::NormalizedString packageIdentifier = package.PackageInformation.PackageIdentifier; + for (const auto& manifest : manifests) + { + if (!Utility::ICUCaseInsensitiveEquals(manifest.Id, packageIdentifier)) + { + AICLI_LOG(Repo, Error, << "Manifest response identifier '" << manifest.Id << + "' does not match '" << package.PackageInformation.PackageIdentifier << "'."); + THROW_HR(APPINSTALLER_CLI_ERROR_RESTSOURCE_INVALID_DATA); + } + } + + if (!manifests.empty() && package.Versions.size() == 1 && + package.Versions[0].VersionAndChannel.GetVersion().IsUnknown()) + { + package.SearchVersions = std::move(package.Versions); + package.Versions = CreateVersionInfos(std::move(manifests)); + } + else + { + for (auto& version : package.Versions) + { + if (!version.Manifest) + { + version.Manifest = FindManifestByVersionAndChannel(manifests, + version.VersionAndChannel.GetVersion().ToString(), version.VersionAndChannel.GetChannel().ToString()); + } + } + } + } utility::string_t GetSearchEndpoint(const std::string& restApiUri) { @@ -117,39 +270,77 @@ namespace AppInstaller::Repository::Rest::Schema::V1_0 IRestClient::SearchResult Interface::SearchInternal(const SearchRequest& request) const { + SearchRequest validatedRequest = GetValidatedSearchRequest(request); + if (!validatedRequest.Query && !request.Inclusions.empty() && validatedRequest.Inclusions.empty()) + { + AICLI_LOG(Repo, Info, << "No supported inclusions remain in the search request."); + return {}; + } + + constexpr size_t c_manifestRetrievalResultLimit = 3; + // Probe beyond the enrichment threshold even when the caller requests fewer results. + if (validatedRequest.Purpose == SearchPurpose::Default && validatedRequest.MaximumResults && + (std::any_of(validatedRequest.Filters.begin(), validatedRequest.Filters.end(), CanResolveWithManifest) || + (!validatedRequest.Query && std::any_of(validatedRequest.Inclusions.begin(), validatedRequest.Inclusions.end(), CanResolveWithManifest)))) + { + validatedRequest.MaximumResults = std::max(validatedRequest.MaximumResults, c_manifestRetrievalResultLimit + 1); + } + + const auto searchBody = SearchRequestComposer{ GetVersion() }.Serialize(validatedRequest); + size_t candidateCount = 0; + bool manifestRetrievalDeferred = false; SearchResult results; utility::string_t continuationToken; + std::set usedContinuationTokens; Http::HttpClientHelper::HttpRequestHeaders searchHeaders = m_requiredRestApiHeaders; do { if (!continuationToken.empty()) { + if (!usedContinuationTokens.emplace(continuationToken).second) + { + AICLI_LOG(Repo, Error, << "REST source returned a repeated continuation token."); + THROW_HR(APPINSTALLER_CLI_ERROR_RESTSOURCE_INVALID_DATA); + } + AICLI_LOG(Repo, Verbose, << "Received continuation token. Retrieving more results."); searchHeaders.insert_or_assign(AppInstaller::JSON::GetUtilityString(ContinuationToken), continuationToken); } - std::optional jsonObject = m_httpClientHelper.HandlePost(m_searchEndpoint, GetValidatedSearchBody(request), searchHeaders, GetAuthHeaders(), CustomRestCallResponseHandler); + std::optional jsonObject = m_httpClientHelper.HandlePost(m_searchEndpoint, searchBody, searchHeaders, GetAuthHeaders(), CustomRestCallResponseHandler); utility::string_t ct; if (jsonObject) { SearchResult currentResult = GetSearchResult(jsonObject.value()); - - size_t insertElements = !request.MaximumResults ? currentResult.Matches.size() : - std::min(currentResult.Matches.size(), request.MaximumResults - results.Matches.size()); - - if (insertElements < currentResult.Matches.size()) - { - results.Truncated = true; - } - - std::move(currentResult.Matches.begin(), std::next(currentResult.Matches.begin(), insertElements), std::inserter(results.Matches, results.Matches.end())); + candidateCount += currentResult.Matches.size(); + manifestRetrievalDeferred |= FilterSearchResult(validatedRequest, currentResult, false); + std::move(currentResult.Matches.begin(), currentResult.Matches.end(), std::inserter(results.Matches, results.Matches.end())); ct = GetContinuationToken(jsonObject.value()).value_or(L""); } continuationToken = ct; - } while (!continuationToken.empty() && (!request.MaximumResults || results.Matches.size() < request.MaximumResults)); + } while (!continuationToken.empty() && (!request.MaximumResults || results.Matches.size() < request.MaximumResults || + (manifestRetrievalDeferred && candidateCount <= c_manifestRetrievalResultLimit))); + + if (manifestRetrievalDeferred) + { + if (continuationToken.empty() && candidateCount <= c_manifestRetrievalResultLimit) + { + FilterSearchResult(validatedRequest, results, true); + } + else + { + AICLI_LOG(Repo, Verbose, << "Skipping manifest retrieval because the search exceeds the complete-result limit."); + } + } + + if (request.MaximumResults && results.Matches.size() > request.MaximumResults) + { + results.Matches.erase(std::next(results.Matches.begin(), request.MaximumResults), results.Matches.end()); + results.Truncated = true; + } if (!continuationToken.empty()) { @@ -164,6 +355,87 @@ namespace AppInstaller::Repository::Rest::Schema::V1_0 return results; } + bool Interface::FilterSearchResult(const SearchRequest& request, SearchResult& result, bool allowManifestRetrieval) const + { + bool manifestRetrievalDeferred = false; + std::vector matches; + matches.reserve(result.Matches.size()); + for (auto& package : result.Matches) + { + bool retrievalAttempted = false; + auto matchesField = [&](const PackageMatchFilter& filter) + { + return MatchesPackage(filter, package); + }; + + std::function(const PackageMatchFilter&)> resolveField; + if (request.Purpose == SearchPurpose::Default) + { + resolveField = [&](const PackageMatchFilter& filter) -> std::optional + { + if (!CanResolveWithManifest(filter)) + { + return std::nullopt; + } + + if (!allowManifestRetrieval) + { + manifestRetrievalDeferred = true; + return std::nullopt; + } + + if (!retrievalAttempted) + { + retrievalAttempted = true; + // Use the search market rather than defaulting the manifest lookup to the OS region. + std::map queryParams; + for (const auto& requestFilter : request.Filters) + { + if (requestFilter.Field == PackageMatchField::Market) + { + auto [market, inserted] = queryParams.emplace(MarketQueryParam, requestFilter.Value); + if ((requestFilter.Type != MatchType::Exact && requestFilter.Type != MatchType::CaseInsensitive) || + (!inserted && !Utility::ICUCaseInsensitiveEquals(market->second, requestFilter.Value))) + { + AICLI_LOG(Repo, Info, << "Manifest lookup cannot represent the requested market filters."); + return std::nullopt; + } + } + } + try + { + queryParams = GetValidatedQueryParams(queryParams); + } + catch (const UnsupportedRequestException& e) + { + AICLI_LOG(Repo, Info, << "Manifest lookup cannot validate search metadata for " << + package.PackageInformation.PackageIdentifier << ": " << e.what()); + return std::nullopt; + } + + AICLI_LOG(Repo, Verbose, << "Retrieving manifests to validate search criteria for " << package.PackageInformation.PackageIdentifier); + auto manifests = GetManifestsInternal(package.PackageInformation.PackageIdentifier, queryParams); + PopulateManifestCache(package, std::move(manifests)); + } + + return matchesField(filter); + }; + } + + // Only proven mismatches are removed; retained NormalizedNameAndPublisher candidates are unvalidated. + auto match = MatchesRequest(request, matchesField, resolveField); + if (match && !match.value()) + { + AICLI_LOG(Repo, Verbose, << "Discarding REST package " << package.PackageInformation.PackageIdentifier << + ": does not match search request " << request.ToString()); + continue; + } + matches.emplace_back(std::move(package)); + } + result.Matches = std::move(matches); + return manifestRetrievalDeferred; + } + std::optional Interface::GetManifestByVersion(const std::string& packageId, const std::string& version, const std::string& channel) const { std::map queryParams; @@ -177,21 +449,7 @@ namespace AppInstaller::Repository::Rest::Schema::V1_0 queryParams.emplace(ChannelQueryParam, channel); } - std::vector manifests = GetManifests(packageId, queryParams); - - if (!manifests.empty()) - { - for (Manifest::Manifest manifest : manifests) - { - if (Utility::CaseInsensitiveEquals(manifest.Version, version) && - Utility::CaseInsensitiveEquals(manifest.Channel, channel)) - { - return manifest; - } - } - } - - return {}; + return FindManifestByVersionAndChannel(GetManifests(packageId, queryParams), version, channel); } bool Interface::MeetsOptimizedSearchCriteria(const SearchRequest& request, bool allowSubstringMatch) const @@ -217,36 +475,25 @@ namespace AppInstaller::Repository::Rest::Schema::V1_0 IRestClient::SearchResult Interface::OptimizedSearch(const SearchRequest& request) const { SearchResult searchResult; - std::vector manifests = GetManifests(request.Filters[0].Value); + const auto& idFilter = request.Filters[0]; + std::vector manifests = GetManifests(idFilter.Value); if (!manifests.empty()) { auto& manifest = manifests.at(0); + if (MatchesRequest(idFilter, manifest.Id) == false) + { + AICLI_LOG(Repo, Error, << "Manifest response identifier '" << manifest.Id << + "' does not match search request " << request.ToString()); + THROW_HR(APPINSTALLER_CLI_ERROR_RESTSOURCE_INVALID_DATA); + } + PackageInfo packageInfo = PackageInfo{ manifest.Id, manifest.DefaultLocalization.Get(), manifest.DefaultLocalization.Get() }; - // Add all the versions to the package info object - std::vector versions; - for (auto& manifestVersion : manifests) - { - auto packageFamilyNames = manifestVersion.GetPackageFamilyNames(); - auto productCodes = manifestVersion.GetProductCodes(); - auto arpVersionRange = manifestVersion.GetArpVersionRange(); - auto upgradeCodes = manifestVersion.GetUpgradeCodes(); - - versions.emplace_back( - VersionInfo{ - AppInstaller::Utility::VersionAndChannel {manifestVersion.Version, manifestVersion.Channel}, - manifestVersion, - std::vector{ packageFamilyNames.begin(), packageFamilyNames.end()}, - std::vector{ productCodes.begin(), productCodes.end()}, - arpVersionRange.IsEmpty() ? std::vector{} : std::vector{ arpVersionRange.GetMinVersion(), arpVersionRange.GetMaxVersion() }, - std::vector{ upgradeCodes.begin(), upgradeCodes.end()} }); - } - - Package package = Package{ std::move(packageInfo), std::move(versions) }; + Package package = Package{ std::move(packageInfo), CreateVersionInfos(std::move(manifests)) }; searchResult.Matches.emplace_back(std::move(package)); } @@ -255,8 +502,11 @@ namespace AppInstaller::Repository::Rest::Schema::V1_0 std::vector Interface::GetManifests(const std::string& packageId, const std::map& params) const { - auto validatedParams = GetValidatedQueryParams(params); + return GetManifestsInternal(packageId, GetValidatedQueryParams(params)); + } + std::vector Interface::GetManifestsInternal(const std::string& packageId, const std::map& validatedParams) const + { std::vector results; utility::string_t continuationToken; Http::HttpClientHelper::HttpRequestHeaders searchHeaders = m_requiredRestApiHeaders; @@ -300,10 +550,9 @@ namespace AppInstaller::Repository::Rest::Schema::V1_0 return params; } - web::json::value Interface::GetValidatedSearchBody(const SearchRequest& searchRequest) const + SearchRequest Interface::GetValidatedSearchRequest(const SearchRequest& searchRequest) const { - SearchRequestComposer searchRequestComposer{ GetVersion() }; - return searchRequestComposer.Serialize(searchRequest); + return searchRequest; } IRestClient::SearchResult Interface::GetSearchResult(const web::json::value& searchResponseObject) const diff --git a/src/AppInstallerRepositoryCore/Rest/Schema/1_1/Interface.h b/src/AppInstallerRepositoryCore/Rest/Schema/1_1/Interface.h index bd070c0543..b7ed879081 100644 --- a/src/AppInstallerRepositoryCore/Rest/Schema/1_1/Interface.h +++ b/src/AppInstallerRepositoryCore/Rest/Schema/1_1/Interface.h @@ -23,8 +23,8 @@ namespace AppInstaller::Repository::Rest::Schema::V1_1 // Check query params against source information and update if necessary. std::map GetValidatedQueryParams(const std::map& params) const override; - // Check search request against source information and get json search body. - web::json::value GetValidatedSearchBody(const SearchRequest& searchRequest) const override; + // Check search request against source information and update if necessary. + SearchRequest GetValidatedSearchRequest(const SearchRequest& searchRequest) const override; SearchResult GetSearchResult(const web::json::value& searchResponseObject) const override; std::vector GetParsedManifests(const web::json::value& manifestsResponseObject) const override; diff --git a/src/AppInstallerRepositoryCore/Rest/Schema/1_1/RestInterface_1_1.cpp b/src/AppInstallerRepositoryCore/Rest/Schema/1_1/RestInterface_1_1.cpp index 9bc0644c68..fc582419be 100644 --- a/src/AppInstallerRepositoryCore/Rest/Schema/1_1/RestInterface_1_1.cpp +++ b/src/AppInstallerRepositoryCore/Rest/Schema/1_1/RestInterface_1_1.cpp @@ -78,7 +78,7 @@ namespace AppInstaller::Repository::Rest::Schema::V1_1 return result; } - web::json::value Interface::GetValidatedSearchBody(const SearchRequest& searchRequest) const + SearchRequest Interface::GetValidatedSearchRequest(const SearchRequest& searchRequest) const { SearchRequest resultSearchRequest = searchRequest; @@ -126,7 +126,7 @@ namespace AppInstaller::Repository::Rest::Schema::V1_1 } } - return V1_0::Interface::GetValidatedSearchBody(resultSearchRequest); + return V1_0::Interface::GetValidatedSearchRequest(resultSearchRequest); } IRestClient::SearchResult Interface::GetSearchResult(const web::json::value& searchResponseObject) const diff --git a/src/AppInstallerRepositoryCore/Rest/Schema/IRestClient.h b/src/AppInstallerRepositoryCore/Rest/Schema/IRestClient.h index ac248c9453..42760ad864 100644 --- a/src/AppInstallerRepositoryCore/Rest/Schema/IRestClient.h +++ b/src/AppInstallerRepositoryCore/Rest/Schema/IRestClient.h @@ -45,6 +45,8 @@ namespace AppInstaller::Repository::Rest::Schema { PackageInfo PackageInformation; std::vector Versions; + // Aggregate search metadata retained when an unknown version is expanded. + std::vector SearchVersions; Package(PackageInfo packageInfo, std::vector versions) : PackageInformation(std::move(packageInfo)), Versions(std::move(versions)) {} diff --git a/src/AppInstallerSharedLib/AppInstallerStrings.cpp b/src/AppInstallerSharedLib/AppInstallerStrings.cpp index 3261470157..d68a039342 100644 --- a/src/AppInstallerSharedLib/AppInstallerStrings.cpp +++ b/src/AppInstallerSharedLib/AppInstallerStrings.cpp @@ -217,7 +217,9 @@ namespace AppInstaller::Utility bool ICUCaseInsensitiveStartsWith(std::string_view a, std::string_view b) { - return a.length() >= b.length() && ICUCaseInsensitiveEquals(a.substr(0, b.length()), b); + const auto foldedA = FoldCase(a); + const auto foldedB = FoldCase(b); + return foldedA.compare(0, foldedB.size(), foldedB) == 0; } std::string ConvertToUTF8(std::wstring_view input) From af420278b358149d93ad422fdecf9eff5e60ebd2 Mon Sep 17 00:00:00 2001 From: AmirMS <104940545+AmelBawa-msft@users.noreply.github.com> Date: Fri, 2 Oct 2026 09:51:10 -0700 Subject: [PATCH 3/3] Add interactive selection for ambiguous package matches (#6575) --- .github/actions/spelling/allow.txt | 1 + doc/ReleaseNotes.md | 6 + doc/Settings.md | 12 + .../#5345 - Interactive package selection.md | 127 ++ .../JSON/settings/settings.schema.0.2.json | 5 + .../AppInstallerCLICore.vcxproj | 1 + .../AppInstallerCLICore.vcxproj.filters | 3 + .../Commands/DownloadCommand.cpp | 2 +- .../Commands/DscPackageResource.cpp | 2 +- .../Commands/InstallCommand.cpp | 2 +- .../Commands/ShowCommand.cpp | 4 +- src/AppInstallerCLICore/ExecutionContext.h | 2 +- .../ExecutionContextData.h | 7 + src/AppInstallerCLICore/ExecutionReporter.cpp | 167 +++ src/AppInstallerCLICore/ExecutionReporter.h | 20 + src/AppInstallerCLICore/Resources.h | 6 + src/AppInstallerCLICore/TableOutput.cpp | 143 ++ src/AppInstallerCLICore/TableOutput.h | 206 +-- .../Workflows/MultiQueryFlow.cpp | 2 +- .../Workflows/PromptFlow.cpp | 36 + .../Workflows/PromptFlow.h | 17 + .../Workflows/ShowFlow.cpp | 2 +- src/AppInstallerCLICore/Workflows/ShowFlow.h | 4 +- .../Workflows/UpdateFlow.cpp | 4 +- .../Workflows/UpdateFlow.h | 11 +- .../Workflows/WorkflowBase.cpp | 172 +-- .../Workflows/WorkflowBase.h | 23 +- .../Shared/Strings/en-us/winget.resw | 24 + .../ExperimentalFeature.cpp | 32 + src/AppInstallerCLITests/PromptFlow.cpp | 1199 +++++++++++++++++ src/AppInstallerCLITests/TableOutput.cpp | 124 +- src/AppInstallerCLITests/TestHooks.h | 19 + .../ExperimentalFeature.cpp | 4 + .../Public/winget/ExperimentalFeature.h | 1 + .../Public/winget/UserSettings.h | 2 + src/AppInstallerCommonCore/UserSettings.cpp | 1 + 36 files changed, 2102 insertions(+), 291 deletions(-) create mode 100644 doc/specs/#5345 - Interactive package selection.md create mode 100644 src/AppInstallerCLICore/TableOutput.cpp diff --git a/.github/actions/spelling/allow.txt b/.github/actions/spelling/allow.txt index adfa886dfa..cdf611bbdb 100644 --- a/.github/actions/spelling/allow.txt +++ b/.github/actions/spelling/allow.txt @@ -22,6 +22,7 @@ aspirational Authenticode AUTOLISTEN azureedge +Bawa binlog binver bstr diff --git a/doc/ReleaseNotes.md b/doc/ReleaseNotes.md index dad9051542..151f337e5b 100644 --- a/doc/ReleaseNotes.md +++ b/doc/ReleaseNotes.md @@ -2,6 +2,12 @@ ## New Features +### Interactive package selection (experimental) + +Set `experimentalFeatures.interactivePackageSelection` to `true` in settings to enable numbered choices when multiple packages match a single-package `install`, `show`, or `download` command in an interactive terminal. Enter a package number to continue or `0` to cancel. + +This feature is disabled by default. Redirected and noninteractive callers retain the existing ambiguity error. Use `--id --exact --source ` to select a package explicitly, or `--disable-interactivity` to prevent prompts. + ### Source priority Source priority is now available without enabling an experimental feature. Use `winget source add --priority ` or `winget source edit --name --priority ` to configure it. Higher values take precedence; sources with equal priority still require disambiguation when multiple matches remain. diff --git a/doc/Settings.md b/doc/Settings.md index 9e0fa4033f..269a3d029e 100644 --- a/doc/Settings.md +++ b/doc/Settings.md @@ -453,3 +453,15 @@ This feature enables support for fonts via `winget settings`. The `winget font l "fonts": true }, ``` + +### interactivePackageSelection + +Enables numbered choices for ambiguous single-package `install`, `show`, and `download` commands, including `show --versions`. Disabled by default. + +```json + "experimentalFeatures": { + "interactivePackageSelection": true + }, +``` + +`--disable-interactivity` and redirected input or output still prevent prompting. diff --git a/doc/specs/#5345 - Interactive package selection.md b/doc/specs/#5345 - Interactive package selection.md new file mode 100644 index 0000000000..0f48497320 --- /dev/null +++ b/doc/specs/#5345 - Interactive package selection.md @@ -0,0 +1,127 @@ +--- +author: AmelBawa-msft, GitHub Copilot +created on: 2026-09-28 +last updated: 2026-10-01 +issue id: 5345 +--- + +# Interactive package selection + +For [#5345](https://github.com/microsoft/winget-cli/issues/5345) + +## Abstract + +Let users resolve ambiguous package matches without restarting their command. An experimental setting enables numbered choices for single-package `install`, `show`, and `download` when interactive input and output are available. + +## Inspiration + +The same query can match multiple packages, including packages from different sources. Users should be able to choose deliberately without copying an ID into another invocation. + +## Solution Design + +This feature is disabled by default. Enable it in settings: + +```json +{ + "experimentalFeatures": { + "interactivePackageSelection": true + } +} +``` + +Apply existing search matching and source-priority rules first. If multiple candidates remain, eligible CLI call sites opt into selection. Shared workflows remain noninteractive by default. + +Display candidates in their existing order with stable, one-indexed numbers. A valid number selects the existing package object without searching again. Preserve command options and continue normal version selection, applicability checks, and agreement handling. + +| Situation | Behavior | +| --- | --- | +| Experimental feature disabled (default) | No selection prompts; retain ambiguity errors with refinement guidance. | +| No match | Existing no-match error. | +| One match after existing policy | Continue without prompting. | +| Multiple matches for single-package `install`, `show`, or `download` | Prompt if eligible, including `show --versions`. | +| Truncated results | Retain ambiguity error and request refinement. | +| Invalid or empty input | Explain the valid range and prompt again; no default. | +| `0` | Cancel without acting on a package. | +| Ctrl+C | Cancel immediately, including while waiting for input. | +| EOF or input failure | Report the existing prompt input error. | +| `--disable-interactivity`, interactivity disabled in settings or context | Retain ambiguity error without reading input. | +| `--silent` | Controls installer UI, not selection prompts; normal interactivity rules apply. | +| Redirected input or output, or disabled informational output | Do not prompt. | +| `--no-vt` | Use the same text and numeric input without terminal escape sequences. | +| Multi-package operations, including individual package contexts | No disambiguation prompts, either per package or up front; retain existing ambiguity errors. | +| Upgrade, uninstall, repair, pin, search, list, or completion | No selection prompts. | +| COM API, PowerShell cmdlets, or configuration/DSC | No new prompts or API changes. | + +Apart from the experimental setting, the prompt adds no command-line flags, group policies, manifest fields, or schema versions. Existing interactivity controls and the experimental-features group policy apply. Package validation pipelines and manifest authoring tools are unchanged; manifest examples and schema snippets are not applicable. + +## UI/UX Design + +Reuse the existing ambiguity table with a leading selection number. Show Name, Id, and Source, including Source when all candidates use the same source. For example: + +```text +Multiple packages match. Choose one. + +# Name Id Source +------------------------------------------ +1 Contoso Editor Contoso.Editor winget +2 Contoso Editor Contoso.Editor.Pro winget + +Enter a number (1-2), or 0 to cancel: 1 +Selected: Contoso Editor [Contoso.Editor] +``` + +For distinct candidates from different sources: + +```text +# Name Id Source +--------------------------------------- +1 Contoso Editor Contoso.Editor winget +2 Contoso Editor Contoso.Editor private +``` + +Each candidate occupies one row, using the existing ambiguity report's package identity and source. Sources grouped within a candidate do not add rows. Use the same introductory text for every command. Do not add another confirmation after selection. Existing consent prompts still apply. + +Ambiguity errors include the candidate list and refinement guidance, even when interactive selection is disabled: + +```text +Specify a package with --id --exact --source . +``` + +For `configure export`, use `--package-id --source ` instead. + +The original version option remains authoritative. + +## Capabilities + +### Accessibility + +Numeric, line-oriented input works without color, cursor navigation, or arrow keys. All identifying information is text and uses localized labels. Invalid input includes recovery instructions. + +### Security + +There is no default selection or inferred equivalence. Choosing a package does not accept agreements or bypass existing source, trust, or installer checks. + +### Reliability + +Selection uses the displayed candidate object rather than re-running a potentially different search. EOF fails explicitly; cancellation never starts installation or download. + +### Compatibility + +The feature is disabled by default. After opt-in, scripts can preserve ambiguity errors with `--disable-interactivity`, or avoid ambiguity with exact ID and source selectors. + +### Performance, Power, and Efficiency + +Rendering uses available package metadata, without downloading manifests for display. No terminal redraw loop is required. + +## Potential Issues + +Long candidate lists require scrolling. Narrow terminals truncate table cells using the existing formatter; users can widen the terminal or cancel and refine their query if candidates are indistinguishable. The selection message includes the full name and ID. Source-defined result truncation must not be presented as a complete selectable list. Matching names and IDs do not prove that packages from different sources are equivalent. + +## Future Considerations + +Cross-source equivalence heuristics, arrow-key navigation, and selection for installed-package operations are separate changes. + +## Resources + +- [Package matching background](%23292%20-%20winget%20should%20install%20an%20app%20if%20there%20is%20an%20exact%20match.md) +- [Settings reference](../Settings.md) diff --git a/schemas/JSON/settings/settings.schema.0.2.json b/schemas/JSON/settings/settings.schema.0.2.json index de8dc24389..e162aaac12 100644 --- a/schemas/JSON/settings/settings.schema.0.2.json +++ b/schemas/JSON/settings/settings.schema.0.2.json @@ -339,6 +339,11 @@ "type": "boolean", "default": false }, + "interactivePackageSelection": { + "description": "Enable interactive selection for ambiguous package matches", + "type": "boolean", + "default": false + }, "resume": { "description": "Enable support for some commands to resume", "type": "boolean", diff --git a/src/AppInstallerCLICore/AppInstallerCLICore.vcxproj b/src/AppInstallerCLICore/AppInstallerCLICore.vcxproj index 8dd3db7bd9..f0698daaa3 100644 --- a/src/AppInstallerCLICore/AppInstallerCLICore.vcxproj +++ b/src/AppInstallerCLICore/AppInstallerCLICore.vcxproj @@ -432,6 +432,7 @@ + Create diff --git a/src/AppInstallerCLICore/AppInstallerCLICore.vcxproj.filters b/src/AppInstallerCLICore/AppInstallerCLICore.vcxproj.filters index 753d2451d8..7f8606c35a 100644 --- a/src/AppInstallerCLICore/AppInstallerCLICore.vcxproj.filters +++ b/src/AppInstallerCLICore/AppInstallerCLICore.vcxproj.filters @@ -346,6 +346,9 @@ Source Files + + Source Files + Commands diff --git a/src/AppInstallerCLICore/Commands/DownloadCommand.cpp b/src/AppInstallerCLICore/Commands/DownloadCommand.cpp index 21854d985c..7ee7a857af 100644 --- a/src/AppInstallerCLICore/Commands/DownloadCommand.cpp +++ b/src/AppInstallerCLICore/Commands/DownloadCommand.cpp @@ -126,7 +126,7 @@ namespace AppInstaller::CLI Workflow::OpenSource() << Workflow::SearchSourceForSingle << Workflow::HandleSearchResultFailures << - Workflow::EnsureOneMatchFromSearchResult(OperationType::Download) << + Workflow::EnsureOneMatchFromSearchResult(OperationType::Download, PackageSelectionBehavior::Prompt) << Workflow::GetManifestFromPackage(false); } diff --git a/src/AppInstallerCLICore/Commands/DscPackageResource.cpp b/src/AppInstallerCLICore/Commands/DscPackageResource.cpp index d43aac207d..c45dfb2e4f 100644 --- a/src/AppInstallerCLICore/Commands/DscPackageResource.cpp +++ b/src/AppInstallerCLICore/Commands/DscPackageResource.cpp @@ -237,7 +237,7 @@ namespace AppInstaller::CLI } *SubContext << - Workflow::SelectSinglePackageVersionForInstallOrUpgrade(Workflow::OperationType::Install, allowDowngrade) << + Workflow::SelectSinglePackageVersionForInstallOrUpgrade(Workflow::OperationType::Install, Workflow::PackageSelectionBehavior::Disabled, allowDowngrade) << Workflow::InstallSinglePackage; if (SubContext->IsTerminated()) diff --git a/src/AppInstallerCLICore/Commands/InstallCommand.cpp b/src/AppInstallerCLICore/Commands/InstallCommand.cpp index ab52f9076c..9d61c1235c 100644 --- a/src/AppInstallerCLICore/Commands/InstallCommand.cpp +++ b/src/AppInstallerCLICore/Commands/InstallCommand.cpp @@ -167,7 +167,7 @@ namespace AppInstaller::CLI { context << Checkpoint("PreInstallCheckpoint", {}) << // TODO: Capture context data - InstallOrUpgradeSinglePackage(OperationType::Install); + InstallOrUpgradeSinglePackage(OperationType::Install, PackageSelectionBehavior::Prompt); } } } diff --git a/src/AppInstallerCLICore/Commands/ShowCommand.cpp b/src/AppInstallerCLICore/Commands/ShowCommand.cpp index 796711bf19..4813195f8e 100644 --- a/src/AppInstallerCLICore/Commands/ShowCommand.cpp +++ b/src/AppInstallerCLICore/Commands/ShowCommand.cpp @@ -93,7 +93,7 @@ namespace AppInstaller::CLI Workflow::OpenSource() << Workflow::SearchSourceForSingle << Workflow::HandleSearchResultFailures << - Workflow::EnsureOneMatchFromSearchResult(OperationType::Show) << + Workflow::EnsureOneMatchFromSearchResult(OperationType::Show, PackageSelectionBehavior::Prompt) << Workflow::ReportPackageIdentity << Workflow::ShowAppVersions; } @@ -101,7 +101,7 @@ namespace AppInstaller::CLI else { context << - GetManifest( /* considerPins */ false) << + GetManifest( /* considerPins */ false, PackageSelectionBehavior::Prompt) << Workflow::ReportManifestIdentity << Workflow::SelectInstaller << Workflow::ShowManifestInfo; diff --git a/src/AppInstallerCLICore/ExecutionContext.h b/src/AppInstallerCLICore/ExecutionContext.h index d4cfd96fe7..eae42fbdee 100644 --- a/src/AppInstallerCLICore/ExecutionContext.h +++ b/src/AppInstallerCLICore/ExecutionContext.h @@ -202,7 +202,7 @@ namespace AppInstaller::CLI::Execution private: DestructionToken m_disableSignalTerminationHandlerOnExit = false; - bool m_isTerminated = false; + std::atomic m_isTerminated = false; HRESULT m_terminationHR = S_OK; size_t m_CtrlSignalCount = 0; ContextFlag m_flags = ContextFlag::None; diff --git a/src/AppInstallerCLICore/ExecutionContextData.h b/src/AppInstallerCLICore/ExecutionContextData.h index 4609893dc2..40cf11537d 100644 --- a/src/AppInstallerCLICore/ExecutionContextData.h +++ b/src/AppInstallerCLICore/ExecutionContextData.h @@ -69,6 +69,7 @@ namespace AppInstaller::CLI::Execution RepairString, MsixDigests, InstallerDownloadAuthenticators, + SelectedIndex, Max }; @@ -100,6 +101,12 @@ namespace AppInstaller::CLI::Execution using value_t = Repository::SearchResult; }; + template <> + struct DataMapping + { + using value_t = std::optional; + }; + template <> struct DataMapping { diff --git a/src/AppInstallerCLICore/ExecutionReporter.cpp b/src/AppInstallerCLICore/ExecutionReporter.cpp index 6bf720e6e9..9286f22998 100644 --- a/src/AppInstallerCLICore/ExecutionReporter.cpp +++ b/src/AppInstallerCLICore/ExecutionReporter.cpp @@ -3,6 +3,8 @@ #include "pch.h" #include "ExecutionReporter.h" #include +#include +#include namespace AppInstaller::CLI::Execution @@ -10,6 +12,16 @@ namespace AppInstaller::CLI::Execution using namespace Settings; using namespace VirtualTerminal; +#ifndef AICLI_DISABLE_TEST_HOOKS + using ReadConsoleFunction = std::function; + static ReadConsoleFunction* s_readConsoleOverride = nullptr; + + void TestHook_SetReadConsole_Override(ReadConsoleFunction* value) + { + s_readConsoleOverride = value; + } +#endif + const Sequence& HelpCommandEmphasis = TextFormat::Foreground::Bright; const Sequence& HelpArgumentEmphasis = TextFormat::Foreground::Bright; const Sequence& ManifestInfoEmphasis = TextFormat::Foreground::Bright; @@ -25,6 +37,17 @@ namespace AppInstaller::CLI::Execution namespace { + BOOL ReadConsoleChunk(wchar_t* buffer, DWORD size, DWORD* charactersRead) + { +#ifndef AICLI_DISABLE_TEST_HOOKS + if (s_readConsoleOverride) + { + return (*s_readConsoleOverride)(buffer, size, charactersRead); + } +#endif + return ReadConsoleW(GetStdHandle(STD_INPUT_HANDLE), buffer, size, charactersRead, nullptr); + } + DWORD GetStdHandleType(DWORD stdHandle) { DWORD result = FILE_TYPE_UNKNOWN; @@ -44,6 +67,9 @@ namespace AppInstaller::CLI::Execution { m_outStreamFileType = GetStdHandleType(STD_OUTPUT_HANDLE); m_inStreamFileType = GetStdHandleType(STD_INPUT_HANDLE); + DWORD mode = 0; + m_consoleStreams = GetConsoleMode(GetStdHandle(STD_INPUT_HANDLE), &mode) && + GetConsoleMode(GetStdHandle(STD_OUTPUT_HANDLE), &mode); } Reporter::Reporter(std::ostream& outStream, std::istream& inStream) : @@ -79,6 +105,7 @@ namespace AppInstaller::CLI::Execution { m_outStreamFileType = other.m_outStreamFileType; m_inStreamFileType = other.m_inStreamFileType; + m_consoleStreams = other.m_consoleStreams; SetChannel(other.m_channel); @@ -176,6 +203,146 @@ namespace AppInstaller::CLI::Execution return m_inStreamFileType == FILE_TYPE_CHAR; } + bool Reporter::CanPrompt(Level level) + { + return m_consoleStreams && GetOutputStream(level).IsEnabled(); + } + + std::optional Reporter::ReadLine(std::function isCancelled) + { + THROW_HR_IF(HRESULT_FROM_WIN32(ERROR_INVALID_STATE), !m_consoleStreams); + + if (isCancelled && isCancelled()) + { + return std::nullopt; + } + + wil::unique_handle inputThread; + THROW_IF_WIN32_BOOL_FALSE(DuplicateHandle(GetCurrentProcess(), GetCurrentThread(), GetCurrentProcess(), + inputThread.put(), THREAD_TERMINATE, FALSE, 0)); + + std::string response; + bool readSucceeded = false; + DWORD readError = ERROR_SUCCESS; + ProgressCallback progress; + wil::unique_event readCompleted{ wil::EventOptions::ManualReset }; + wil::unique_event cancellationCompleted{ wil::EventOptions::ManualReset }; + auto cancellation = progress.SetCancellationFunction([&]() + { + // Retry until the read ends to cover cancellation immediately before it starts. + while (!readCompleted.wait(10)) + { + if (!CancelSynchronousIo(inputThread.get())) + { + DWORD error = GetLastError(); + if (error != ERROR_NOT_FOUND) + { + LOG_WIN32(error); + } + } + } + cancellationCompleted.SetEvent(); + }); + SetProgressCallback(&progress); + { + auto unregister = wil::scope_exit([&]() + { + readCompleted.SetEvent(); + SetProgressCallback(nullptr); + }); + if (!isCancelled || !isCancelled()) + { + if (m_inStreamFileType == FILE_TYPE_CHAR) + { + std::wstring consoleResponse; + do + { + wchar_t buffer[256]; + DWORD charactersRead = 0; + SetLastError(ERROR_SUCCESS); + // The CRT loses ERROR_OPERATION_ABORTED when Ctrl+C ends a console read. + bool succeeded = ReadConsoleChunk(buffer, ARRAYSIZE(buffer), &charactersRead); + readError = GetLastError(); + readSucceeded = succeeded && charactersRead != 0; + if (!readSucceeded || readError == ERROR_OPERATION_ABORTED) + { + break; + } + consoleResponse.append(buffer, charactersRead); + } while (consoleResponse.back() != L'\n'); + if (readError == ERROR_OPERATION_ABORTED) + { + readCompleted.SetEvent(); + // The console read can end before the Ctrl+C handler records cancellation. + if (!isCancelled || !isCancelled()) + { + cancellationCompleted.wait(); + } + } + response = Utility::ConvertToUTF8(consoleResponse); + readSucceeded = readSucceeded && response.find('\x1a') == std::string::npos; + } + else + { + SetLastError(ERROR_SUCCESS); + readSucceeded = static_cast(std::getline(m_in, response)); + readError = GetLastError(); + } + } + } + if (progress.IsCancelledBy(CancelReason::Any) || (isCancelled && isCancelled()) || readError == ERROR_OPERATION_ABORTED) + { + return std::nullopt; + } + THROW_HR_IF(APPINSTALLER_CLI_ERROR_PROMPT_INPUT_ERROR, !readSucceeded); + return response; + } + + std::optional Reporter::PromptForIntegerResponse(Resource::LocString message, Level level, + Resource::LocString invalid, std::function isCancelled) + { + return PromptForIntegerResponseWithinRange(std::move(message), 0, std::numeric_limits::max(), + level, std::move(invalid), std::move(isCancelled)); + } + + std::optional Reporter::PromptForIntegerResponseWithinRange(Resource::LocString message, uint64_t minimum, uint64_t maximum, + Level level, Resource::LocString invalid, std::function isCancelled) + { + THROW_HR_IF(E_INVALIDARG, minimum > maximum); + + if (!CanPrompt(level)) + { + AICLI_LOG(CLI, Verbose, << "Skipping integer prompt. Console streams or output are unavailable."); + return std::nullopt; + } + + auto out = GetOutputStream(level); + for (;;) + { + if (isCancelled && isCancelled()) + { + return std::nullopt; + } + + out << message << ' ' << std::flush; + auto response = ReadLine(isCancelled); + if (!response || (isCancelled && isCancelled())) + { + return std::nullopt; + } + + Utility::Trim(*response); + uint64_t value = 0; + auto result = std::from_chars(response->data(), response->data() + response->size(), value); + if (result.ec == std::errc{} && result.ptr == response->data() + response->size() && value >= minimum && value <= maximum) + { + return value; + } + + out << invalid << std::endl; + } + } + bool Reporter::PromptForBoolResponse(Resource::LocString message, Level level, bool resultIfDisabled) { auto out = GetOutputStream(level); diff --git a/src/AppInstallerCLICore/ExecutionReporter.h b/src/AppInstallerCLICore/ExecutionReporter.h index d4598b8d67..52f03253f1 100644 --- a/src/AppInstallerCLICore/ExecutionReporter.h +++ b/src/AppInstallerCLICore/ExecutionReporter.h @@ -11,6 +11,7 @@ #include #include +#include #include #include #include @@ -113,6 +114,24 @@ namespace AppInstaller::CLI::Execution // Check if the input stream is interactive or not. bool InputStreamIsInteractive() const; + bool CanPrompt(Level level = Level::Info); + + // Reads one line without output; returns nullopt on cancellation. + std::optional ReadLine(std::function isCancelled = {}); + +#ifndef AICLI_DISABLE_TEST_HOOKS + void SetConsoleStreamsForTest(bool value) { m_consoleStreams = value; } + void SetInputStreamFileTypeForTest(DWORD value) { m_inStreamFileType = value; } +#endif + + // Prompts for a non-negative integer; returns nullopt if unavailable or cancelled. + std::optional PromptForIntegerResponse(Resource::LocString message, Level level = Level::Info, + Resource::LocString invalid = Resource::String::NumberedSelectionInvalid, std::function isCancelled = {}); + + // Prompts for an integer in [minimum, maximum]; returns nullopt if unavailable or cancelled. + std::optional PromptForIntegerResponseWithinRange(Resource::LocString message, uint64_t minimum, uint64_t maximum, + Level level = Level::Info, Resource::LocString invalid = Resource::String::NumberedSelectionInvalid, std::function isCancelled = {}); + // Prompts the user, return true if they consented. bool PromptForBoolResponse(Resource::LocString message, Level level = Level::Info, bool resultIfDisabled = false); @@ -210,6 +229,7 @@ namespace AppInstaller::CLI::Execution std::atomic m_progressSink; DWORD m_outStreamFileType = FILE_TYPE_UNKNOWN; DWORD m_inStreamFileType = FILE_TYPE_UNKNOWN; + bool m_consoleStreams = false; // Enable all levels by default Level m_enabledLevels = Level::All; diff --git a/src/AppInstallerCLICore/Resources.h b/src/AppInstallerCLICore/Resources.h index 005ace81a6..4b574f5155 100644 --- a/src/AppInstallerCLICore/Resources.h +++ b/src/AppInstallerCLICore/Resources.h @@ -515,6 +515,8 @@ namespace AppInstaller::CLI::Resource WINGET_DEFINE_RESOURCE_STRINGID(NoUninstallInfoFound); WINGET_DEFINE_RESOURCE_STRINGID(NoUpgradeArgumentDescription); WINGET_DEFINE_RESOURCE_STRINGID(NoVTArgumentDescription); + WINGET_DEFINE_RESOURCE_STRINGID(NumberedSelectionInvalid); + WINGET_DEFINE_RESOURCE_STRINGID(NumberedSelectionPrompt); WINGET_DEFINE_RESOURCE_STRINGID(OpenLogsArgumentDescription); WINGET_DEFINE_RESOURCE_STRINGID(OpenSourceFailedNoMatch); WINGET_DEFINE_RESOURCE_STRINGID(OpenSourceFailedNoMatchHelp); @@ -533,6 +535,10 @@ namespace AppInstaller::CLI::Resource WINGET_DEFINE_RESOURCE_STRINGID(PackageDependencies); WINGET_DEFINE_RESOURCE_STRINGID(PackageIsPinned); WINGET_DEFINE_RESOURCE_STRINGID(PackageRequiresDependencies); + WINGET_DEFINE_RESOURCE_STRINGID(PackageSelectionRefine); + WINGET_DEFINE_RESOURCE_STRINGID(PackageSelectionRefineForExport); + WINGET_DEFINE_RESOURCE_STRINGID(PackageSelectionSelected); + WINGET_DEFINE_RESOURCE_STRINGID(PackageSelectionTitle); WINGET_DEFINE_RESOURCE_STRINGID(PendingWorkError); WINGET_DEFINE_RESOURCE_STRINGID(PinAddBlockingArgumentDescription); WINGET_DEFINE_RESOURCE_STRINGID(PinAddCommandLongDescription); diff --git a/src/AppInstallerCLICore/TableOutput.cpp b/src/AppInstallerCLICore/TableOutput.cpp new file mode 100644 index 0000000000..5af774dde1 --- /dev/null +++ b/src/AppInstallerCLICore/TableOutput.cpp @@ -0,0 +1,143 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +#include "pch.h" +#include "TableOutput.h" +#include + +namespace AppInstaller::CLI::Execution +{ + TableOutputBase::TableOutputBase(Reporter& reporter, std::vector header) : + m_reporter(reporter) + { + THROW_HR_IF(E_INVALIDARG, header.empty()); + for (auto& name : header) + { + auto width = Utility::UTF8ColumnWidth(name.get()); + m_columns.push_back({ std::move(name), width }); + } + } + + void TableOutputBase::OutputLine(std::vector line) + { + THROW_HR_IF(E_INVALIDARG, line.size() != m_columns.size()); + m_buffer.emplace_back(std::move(line)); + } + + void TableOutputBase::Complete(bool showLineNumbers) + { + if (!IsEmpty() && !m_bufferEvaluated) + { + EvaluateAndFlushBuffer(showLineNumbers); + } + } + + void TableOutputBase::EvaluateAndFlushBuffer(bool showLineNumbers) + { + for (const auto& row : m_buffer) + { + for (size_t i = 0; i < m_columns.size(); ++i) + { + m_columns[i].MaxLength = std::max(m_columns[i].MaxLength, Utility::UTF8ColumnWidth(row[i])); + } + } + + for (auto& column : m_columns) + { + if (column.MaxLength) + { + column.MaxLength = std::max(column.MaxLength, column.MinLength); + } + } + + m_columns.back().SpaceAfter = false; + for (size_t i = m_columns.size() - 1; i > 0; --i) + { + if (m_columns[i].MaxLength) + { + break; + } + m_columns[i - 1].SpaceAfter = false; + } + + m_lineNumberWidth = showLineNumbers ? std::to_string(m_buffer.size()).size() : 0; + size_t totalRequired = m_lineNumberWidth ? m_lineNumberWidth + 1 : 0; + for (const auto& column : m_columns) + { + totalRequired += column.MaxLength + (column.SpaceAfter ? 1 : 0); + } + + auto consoleWidth = GetConsoleWidth(); + if (consoleWidth && totalRequired >= *consoleWidth) + { + size_t extra = (totalRequired - *consoleWidth) + 1; + while (extra) + { + auto widest = std::max_element(m_columns.begin(), m_columns.end(), + [](const auto& left, const auto& right) { return left.MaxLength < right.MaxLength; }); + if (!widest->MaxLength) + { + break; + } + --widest->MaxLength; + --totalRequired; + --extra; + } + } + + std::vector header; + for (const auto& column : m_columns) + { + header.emplace_back(column.Name.get()); + } + OutputLineToStream(header); + m_reporter.Info() << std::string(totalRequired, '-') << std::endl; + size_t lineNumber = 0; + for (const auto& row : m_buffer) + { + OutputLineToStream(row, ++lineNumber); + } + m_bufferEvaluated = true; + } + + void TableOutputBase::OutputLineToStream(const std::vector& line, size_t lineNumber) + { + auto out = m_reporter.Info(); + if (m_lineNumberWidth) + { + const std::string number = lineNumber ? std::to_string(lineNumber) : "#"; + out << number << std::string(m_lineNumberWidth - number.size() + 1, ' '); + } + for (size_t i = 0; i < m_columns.size(); ++i) + { + const auto& column = m_columns[i]; + if (column.MaxLength) + { + std::string_view value = line[i]; + size_t valueLength = Utility::UTF8ColumnWidth(value); + if (valueLength > column.MaxLength) + { + size_t actualWidth; + out << Utility::UTF8TrimRightToColumnWidth(value, column.MaxLength - 1, actualWidth) << "\xE2\x80\xA6"; + // Wide characters can leave one column unused before the ellipsis. + if (actualWidth != column.MaxLength - 1) + { + out << ' '; + } + if (column.SpaceAfter) + { + out << ' '; + } + } + else + { + out << value; + if (column.SpaceAfter) + { + out << std::string(column.MaxLength - valueLength + 1, ' '); + } + } + } + } + out << std::endl; + } +} diff --git a/src/AppInstallerCLICore/TableOutput.h b/src/AppInstallerCLICore/TableOutput.h index 6bfb59848b..c6439f0d42 100644 --- a/src/AppInstallerCLICore/TableOutput.h +++ b/src/AppInstallerCLICore/TableOutput.h @@ -5,56 +5,22 @@ #include "Resources.h" #include -#include +#include #include #include namespace AppInstaller::CLI::Execution { - // Enables output data in a table format. - // TODO: Improve for use with sparse data. - template - struct TableOutput + struct TableOutputBase { - using header_t = std::array; - using line_t = std::array; - - TableOutput(Reporter& reporter, header_t&& header) : - m_reporter(reporter), - m_hasConsole(GetConsoleWidth().has_value()) - { - for (size_t i = 0; i < FieldCount; ++i) - { - m_columns[i].Name = std::move(header[i]); - m_columns[i].MinLength = Utility::UTF8ColumnWidth(m_columns[i].Name.get()); - m_columns[i].MaxLength = 0; - } - } - - void OutputLine(line_t&& line) - { - m_empty = false; - - // Always buffer every row so that column widths are computed from the full dataset - // before any output is written. This guarantees that the widest value in any column - // is always fully visible and columns are perfectly aligned, whether output goes to - // a console or is redirected. Complete() triggers the actual output. - m_buffer.emplace_back(std::move(line)); - } + TableOutputBase(Reporter& reporter, std::vector header); - void Complete() - { - if (!m_empty) - { - EvaluateAndFlushBuffer(); - } - } - - bool IsEmpty() - { - return m_empty; - } + // Buffers rows until Complete() computes column widths and renders the table. + void OutputLine(std::vector line); + void Complete(bool showLineNumbers = false); + bool IsEmpty() const { return m_buffer.empty(); } + size_t GetRowCount() const { return m_buffer.size(); } private: // A column in the table. @@ -67,152 +33,28 @@ namespace AppInstaller::CLI::Execution }; Reporter& m_reporter; - std::array m_columns; - std::vector m_buffer; + std::vector m_columns; + std::vector> m_buffer; + size_t m_lineNumberWidth = 0; bool m_bufferEvaluated = false; - bool m_empty = true; - bool m_hasConsole = false; - - void EvaluateAndFlushBuffer() - { - if (m_bufferEvaluated) - { - return; - } - - // Determine the maximum length for all columns - for (const auto& line : m_buffer) - { - for (size_t i = 0; i < FieldCount; ++i) - { - m_columns[i].MaxLength = std::max(m_columns[i].MaxLength, Utility::UTF8ColumnWidth(line[i])); - } - } - - // If there are actually columns with data, then also bring in the minimum size - for (size_t i = 0; i < FieldCount; ++i) - { - if (m_columns[i].MaxLength) - { - m_columns[i].MaxLength = std::max(m_columns[i].MaxLength, m_columns[i].MinLength); - } - } - - // Only output the extra space if: - // 1. Not the last field - m_columns[FieldCount - 1].SpaceAfter = false; - - // 2. Not empty (taken care of by not doing anything if empty) - // 3. There are non-empty fields after - for (size_t i = FieldCount - 1; i > 0; --i) - { - if (m_columns[i].MaxLength) - { - break; - } - else - { - m_columns[i - 1].SpaceAfter = false; - } - } - - // Determine the total width required to not truncate any columns - size_t totalRequired = 0; - for (size_t i = 0; i < FieldCount; ++i) - { - totalRequired += m_columns[i].MaxLength + (m_columns[i].SpaceAfter ? 1 : 0); - } - - auto consoleWidthOpt = GetConsoleWidth(); - - // If there is a console and the total space would be too big, shrink columns. - // We don't want to use the last column, lest we auto-wrap. - // When there is no console (e.g. output redirected to a file), skip truncation entirely. - if (consoleWidthOpt && totalRequired >= *consoleWidthOpt) - { - size_t extra = (totalRequired - *consoleWidthOpt) + 1; - - while (extra) - { - size_t targetIndex = 0; - size_t targetVal = m_columns[0].MaxLength; - for (size_t j = 1; j < FieldCount; ++j) - { - if (m_columns[j].MaxLength > targetVal) - { - targetIndex = j; - targetVal = m_columns[j].MaxLength; - } - } - m_columns[targetIndex].MaxLength -= 1; - extra -= 1; - } - - totalRequired = *consoleWidthOpt - 1; - } - - // Header line - line_t headerLine; - - for (size_t i = 0; i < FieldCount; ++i) - { - headerLine[i] = m_columns[i].Name.get(); - } - - OutputLineToStream(headerLine); - - m_reporter.Info() << std::string(totalRequired, '-') << std::endl; + void EvaluateAndFlushBuffer(bool showLineNumbers); + void OutputLineToStream(const std::vector& line, size_t lineNumber = 0); + }; - for (const auto& line : m_buffer) - { - OutputLineToStream(line); - } + // Retains fixed-size headers and rows for existing table callers. + template + struct TableOutput : public TableOutputBase + { + using header_t = std::array; + using line_t = std::array; - m_bufferEvaluated = true; - } + TableOutput(Reporter& reporter, header_t&& header) : + TableOutputBase(reporter, { std::make_move_iterator(header.begin()), std::make_move_iterator(header.end()) }) {} - void OutputLineToStream(const line_t& line) + void OutputLine(line_t&& line) { - auto out = m_reporter.Info(); - - for (size_t i = 0; i < FieldCount; ++i) - { - const auto& col = m_columns[i]; - - if (col.MaxLength) - { - size_t valueLength = Utility::UTF8ColumnWidth(line[i]); - - if (valueLength > col.MaxLength) - { - size_t actualWidth; - out << Utility::UTF8TrimRightToColumnWidth(line[i], col.MaxLength - 1, actualWidth) << "\xE2\x80\xA6"; // UTF8 encoding of ellipsis (…) character - - // Some characters take 2 unit space, the trimmed string length might be 1 less than the expected length. - if (actualWidth != col.MaxLength - 1) - { - out << ' '; - } - - if (col.SpaceAfter) - { - out << ' '; - } - } - else - { - out << line[i]; - - if (col.SpaceAfter) - { - out << std::string(col.MaxLength - valueLength + 1, ' '); - } - } - } - } - - out << std::endl; + TableOutputBase::OutputLine({ std::make_move_iterator(line.begin()), std::make_move_iterator(line.end()) }); } }; } diff --git a/src/AppInstallerCLICore/Workflows/MultiQueryFlow.cpp b/src/AppInstallerCLICore/Workflows/MultiQueryFlow.cpp index c673b13f3d..bb26671cc6 100644 --- a/src/AppInstallerCLICore/Workflows/MultiQueryFlow.cpp +++ b/src/AppInstallerCLICore/Workflows/MultiQueryFlow.cpp @@ -71,7 +71,7 @@ namespace AppInstaller::CLI::Workflow { case OperationType::Install: case OperationType::Upgrade: - searchContext << Workflow::SelectSinglePackageVersionForInstallOrUpgrade(m_operationType); + searchContext << Workflow::SelectSinglePackageVersionForInstallOrUpgrade(m_operationType, PackageSelectionBehavior::Disabled); break; case OperationType::Uninstall: searchContext << diff --git a/src/AppInstallerCLICore/Workflows/PromptFlow.cpp b/src/AppInstallerCLICore/Workflows/PromptFlow.cpp index 5e298f5c92..bf4712be47 100644 --- a/src/AppInstallerCLICore/Workflows/PromptFlow.cpp +++ b/src/AppInstallerCLICore/Workflows/PromptFlow.cpp @@ -395,6 +395,42 @@ namespace AppInstaller::CLI::Workflow } } + void PromptForSelection::operator()(Execution::Context& context) const + { + context.Add(std::optional{}); + AICLI_RETURN_IF_TERMINATED(context); + const size_t count = m_table.GetRowCount(); + THROW_HR_IF(E_INVALIDARG, !count); + + if (!IsInteractivityAllowed(context)) + { + return; + } + if (!context.Reporter.CanPrompt()) + { + AICLI_LOG(CLI, Verbose, << "Skipping selection prompt. Console streams or output are unavailable."); + return; + } + + auto out = context.Reporter.Info(); + out << m_title << std::endl << std::endl; + m_table.Complete(true); + out << std::endl; + + const auto prompt = Resource::String::NumberedSelectionPrompt(count); + AICLI_RETURN_IF_TERMINATED(context); + auto response = context.Reporter.PromptForIntegerResponseWithinRange(prompt, 0, count, Reporter::Level::Info, + Resource::String::NumberedSelectionInvalid, [&]() { return context.IsTerminated(); }); + AICLI_RETURN_IF_TERMINATED(context); + if (!response || *response == 0) + { + out << Resource::String::Cancelled << std::endl; + AICLI_TERMINATE_CONTEXT(E_ABORT); + } + + context.Add(std::optional{ static_cast(*response - 1) }); + } + void HandleSourceAgreements::operator()(Execution::Context& context) const { bool allAccepted = true; diff --git a/src/AppInstallerCLICore/Workflows/PromptFlow.h b/src/AppInstallerCLICore/Workflows/PromptFlow.h index 7219cb1fff..97652fd54d 100644 --- a/src/AppInstallerCLICore/Workflows/PromptFlow.h +++ b/src/AppInstallerCLICore/Workflows/PromptFlow.h @@ -2,9 +2,26 @@ // Licensed under the MIT License. #pragma once #include "ExecutionContext.h" +#include "TableOutput.h" namespace AppInstaller::CLI::Workflow { + // Prompts for a numbered choice among the table's rows. + // Required Args: None + // Inputs: None + // Outputs: SelectedIndex (zero-based index, or nullopt if prompting is unavailable) + struct PromptForSelection : public WorkflowTask + { + PromptForSelection(Execution::TableOutputBase& table, Resource::LocString title) : + WorkflowTask("PromptForSelection"), m_table(table), m_title(std::move(title)) {} + + void operator()(Execution::Context& context) const override; + + private: + Execution::TableOutputBase& m_table; + Resource::LocString m_title; + }; + // Handles all opened source(s) agreements if needed. // Required Args: The source to be checked for agreements // Inputs: None diff --git a/src/AppInstallerCLICore/Workflows/ShowFlow.cpp b/src/AppInstallerCLICore/Workflows/ShowFlow.cpp index eb872b2110..c465225a2f 100644 --- a/src/AppInstallerCLICore/Workflows/ShowFlow.cpp +++ b/src/AppInstallerCLICore/Workflows/ShowFlow.cpp @@ -226,7 +226,7 @@ namespace AppInstaller::CLI::Workflow OpenSource() << SearchSourceForSingle << HandleSearchResultFailures << - EnsureOneMatchFromSearchResult(OperationType::Show) << + EnsureOneMatchFromSearchResult(OperationType::Show, m_selectionBehavior) << GetManifestFromPackage(m_considerPins); } } diff --git a/src/AppInstallerCLICore/Workflows/ShowFlow.h b/src/AppInstallerCLICore/Workflows/ShowFlow.h index 618a1795cb..6992adf387 100644 --- a/src/AppInstallerCLICore/Workflows/ShowFlow.h +++ b/src/AppInstallerCLICore/Workflows/ShowFlow.h @@ -44,12 +44,14 @@ namespace AppInstaller::CLI::Workflow // Outputs: Manifest struct GetManifest : public WorkflowTask { - GetManifest(bool considerPins) : WorkflowTask("GetManifest"), m_considerPins(considerPins) {} + GetManifest(bool considerPins, PackageSelectionBehavior selectionBehavior = PackageSelectionBehavior::Disabled) : + WorkflowTask("GetManifest"), m_considerPins(considerPins), m_selectionBehavior(selectionBehavior) {} void operator()(Execution::Context& context) const override; private: bool m_considerPins; + PackageSelectionBehavior m_selectionBehavior; }; // Reusable helpers for `show` style line output diff --git a/src/AppInstallerCLICore/Workflows/UpdateFlow.cpp b/src/AppInstallerCLICore/Workflows/UpdateFlow.cpp index 2e8f35100d..a2c243673e 100644 --- a/src/AppInstallerCLICore/Workflows/UpdateFlow.cpp +++ b/src/AppInstallerCLICore/Workflows/UpdateFlow.cpp @@ -322,7 +322,7 @@ namespace AppInstaller::CLI::Workflow context << HandleSearchResultFailures << - EnsureOneMatchFromSearchResult(m_operationType) << + EnsureOneMatchFromSearchResult(m_operationType, m_selectionBehavior) << GetInstalledPackageVersion; if ( m_operationType != OperationType::Upgrade && @@ -372,7 +372,7 @@ namespace AppInstaller::CLI::Workflow context << SearchSourceForSingle << - SelectSinglePackageVersionForInstallOrUpgrade(m_operationType) << + SelectSinglePackageVersionForInstallOrUpgrade(m_operationType, m_selectionBehavior) << InstallSinglePackage; } } diff --git a/src/AppInstallerCLICore/Workflows/UpdateFlow.h b/src/AppInstallerCLICore/Workflows/UpdateFlow.h index cb84b3452d..0d686f6483 100644 --- a/src/AppInstallerCLICore/Workflows/UpdateFlow.h +++ b/src/AppInstallerCLICore/Workflows/UpdateFlow.h @@ -39,14 +39,16 @@ namespace AppInstaller::CLI::Workflow // Outputs: None struct SelectSinglePackageVersionForInstallOrUpgrade : public WorkflowTask { - SelectSinglePackageVersionForInstallOrUpgrade(OperationType operation, bool allowDowngrade = false) : - WorkflowTask("SelectSinglePackageVersionForInstallOrUpgrade"), m_operationType(operation), m_allowDowngrade(allowDowngrade) {} + SelectSinglePackageVersionForInstallOrUpgrade(OperationType operation, PackageSelectionBehavior selectionBehavior, bool allowDowngrade = false) : + WorkflowTask("SelectSinglePackageVersionForInstallOrUpgrade"), m_operationType(operation), m_allowDowngrade(allowDowngrade), + m_selectionBehavior(selectionBehavior) {} void operator()(Execution::Context& context) const override; private: mutable OperationType m_operationType; bool m_allowDowngrade; + PackageSelectionBehavior m_selectionBehavior; }; // Install or upgrade a single package @@ -55,12 +57,13 @@ namespace AppInstaller::CLI::Workflow // Outputs: None struct InstallOrUpgradeSinglePackage : public WorkflowTask { - InstallOrUpgradeSinglePackage(OperationType operation) : - WorkflowTask("InstallOrUpgradeSinglePackage"), m_operationType(operation) {} + InstallOrUpgradeSinglePackage(OperationType operation, PackageSelectionBehavior selectionBehavior = PackageSelectionBehavior::Disabled) : + WorkflowTask("InstallOrUpgradeSinglePackage"), m_operationType(operation), m_selectionBehavior(selectionBehavior) {} void operator()(Execution::Context& context) const override; private: mutable OperationType m_operationType; + PackageSelectionBehavior m_selectionBehavior; }; } diff --git a/src/AppInstallerCLICore/Workflows/WorkflowBase.cpp b/src/AppInstallerCLICore/Workflows/WorkflowBase.cpp index 3a238f57fc..d4994cf59d 100644 --- a/src/AppInstallerCLICore/Workflows/WorkflowBase.cpp +++ b/src/AppInstallerCLICore/Workflows/WorkflowBase.cpp @@ -48,6 +48,65 @@ namespace AppInstaller::CLI::Workflow } } + Execution::TableOutputBase GetMultiplePackageFoundResultTable(Execution::Context& context) + { + auto& searchResult = context.Get(); + + Execution::TableOutput<2> table(context.Reporter, + { + Resource::String::SearchName, + Resource::String::SearchId + }); + + for (size_t i = 0; i < searchResult.Matches.size(); ++i) + { + auto package = searchResult.Matches[i].Package; + + table.OutputLine({ + package->GetProperty(PackageProperty::Name), + package->GetProperty(PackageProperty::Id) + }); + } + + return table; + } + + Execution::TableOutputBase GetMultiplePackageFoundResultTableWithSource(Execution::Context& context) + { + auto& searchResult = context.Get(); + + Execution::TableOutput<3> table(context.Reporter, + { + Resource::String::SearchName, + Resource::String::SearchId, + Resource::String::SearchSource + }); + + for (size_t i = 0; i < searchResult.Matches.size(); ++i) + { + auto package = searchResult.Matches[i].Package; + + std::string sourceName; + auto available = package->GetAvailable(); + if (!available.empty()) + { + auto source = available[0]->GetSource(); + if (source) + { + sourceName = source.GetDetails().Name; + } + } + + table.OutputLine({ + package->GetProperty(PackageProperty::Name), + package->GetProperty(PackageProperty::Id), + std::move(sourceName) + }); + } + + return table; + } + void ReportIdentity( Execution::Context& context, Utility::LocIndView prefix, @@ -1043,7 +1102,12 @@ namespace AppInstaller::CLI::Workflow else { context.Reporter.Info() << std::endl << Resource::String::SearchFailureErrorListMatches << std::endl; - context << ReportMultiplePackageFoundResultWithSource; + auto table = GetMultiplePackageFoundResultTableWithSource(context); + table.Complete(); + if (searchResult.Truncated) + { + context.Reporter.Info() << '<' << Resource::String::SearchTruncated << '>' << std::endl; + } } } @@ -1052,75 +1116,6 @@ namespace AppInstaller::CLI::Workflow } } - void ReportMultiplePackageFoundResult(Execution::Context& context) - { - auto& searchResult = context.Get(); - - Execution::TableOutput<2> table(context.Reporter, - { - Resource::String::SearchName, - Resource::String::SearchId - }); - - for (size_t i = 0; i < searchResult.Matches.size(); ++i) - { - auto package = searchResult.Matches[i].Package; - - table.OutputLine({ - package->GetProperty(PackageProperty::Name), - package->GetProperty(PackageProperty::Id) - }); - } - - table.Complete(); - - if (searchResult.Truncated) - { - context.Reporter.Info() << '<' << Resource::String::SearchTruncated << '>' << std::endl; - } - } - - void ReportMultiplePackageFoundResultWithSource(Execution::Context& context) - { - auto& searchResult = context.Get(); - - Execution::TableOutput<3> table(context.Reporter, - { - Resource::String::SearchName, - Resource::String::SearchId, - Resource::String::SearchSource - }); - - for (size_t i = 0; i < searchResult.Matches.size(); ++i) - { - auto package = searchResult.Matches[i].Package; - - std::string sourceName; - auto available = package->GetAvailable(); - if (!available.empty()) - { - auto source = available[0]->GetSource(); - if (source) - { - sourceName = source.GetDetails().Name; - } - } - - table.OutputLine({ - package->GetProperty(PackageProperty::Name), - package->GetProperty(PackageProperty::Id), - std::move(sourceName) - }); - } - - table.Complete(); - - if (searchResult.Truncated) - { - context.Reporter.Info() << '<' << Resource::String::SearchTruncated << '>' << std::endl; - } - } - void ReportListResult::operator()(Execution::Context& context) const { auto& searchResult = context.Get(); @@ -1396,25 +1391,46 @@ namespace AppInstaller::CLI::Workflow } } + size_t selectedIndex = 0; if (searchResult.Matches.size() > 1) { Logging::Telemetry().LogMultiAppMatch(); - if (operationTargetsInstalled) + auto table = operationTargetsInstalled ? GetMultiplePackageFoundResultTable(context) : + GetMultiplePackageFoundResultTableWithSource(context); + bool selectionSupported = m_selectionBehavior == PackageSelectionBehavior::Prompt && + Settings::ExperimentalFeature::IsEnabled(Settings::ExperimentalFeature::Feature::InteractivePackageSelection); + std::optional selection; + if (selectionSupported && !searchResult.Truncated) { - context.Reporter.Warn() << Resource::String::MultipleInstalledPackagesFound << std::endl; - context << ReportMultiplePackageFoundResult; + context << PromptForSelection(table, Resource::String::PackageSelectionTitle); + AICLI_RETURN_IF_TERMINATED(context); + selection = context.Get(); + } + + if (selection) + { + selectedIndex = *selection; + auto package = searchResult.Matches[selectedIndex].Package; + context.Reporter.Info() << Resource::String::PackageSelectionSelected(package->GetProperty(PackageProperty::Name), + package->GetProperty(PackageProperty::Id)) << std::endl; } else { - context.Reporter.Warn() << Resource::String::MultiplePackagesFound << std::endl; - context << ReportMultiplePackageFoundResultWithSource; + context.Reporter.Warn() << (operationTargetsInstalled ? Resource::String::MultipleInstalledPackagesFound : + Resource::String::MultiplePackagesFound) << std::endl; + table.Complete(); + if (searchResult.Truncated) + { + context.Reporter.Info() << '<' << Resource::String::SearchTruncated << '>' << std::endl; + } + context.Reporter.Info() << (m_operationType == OperationType::Export ? + Resource::String::PackageSelectionRefineForExport : Resource::String::PackageSelectionRefine) << std::endl; + AICLI_TERMINATE_CONTEXT(APPINSTALLER_CLI_ERROR_MULTIPLE_APPLICATIONS_FOUND); } - - AICLI_TERMINATE_CONTEXT(APPINSTALLER_CLI_ERROR_MULTIPLE_APPLICATIONS_FOUND); } - std::shared_ptr package = searchResult.Matches.at(0).Package; + std::shared_ptr package = searchResult.Matches.at(selectedIndex).Package; Logging::Telemetry().LogAppFound(package->GetProperty(PackageProperty::Name), package->GetProperty(PackageProperty::Id)); context.Add(std::move(package)); diff --git a/src/AppInstallerCLICore/Workflows/WorkflowBase.h b/src/AppInstallerCLICore/Workflows/WorkflowBase.h index 7d9cba0ae3..3c026e0310 100644 --- a/src/AppInstallerCLICore/Workflows/WorkflowBase.h +++ b/src/AppInstallerCLICore/Workflows/WorkflowBase.h @@ -46,6 +46,12 @@ namespace AppInstaller::CLI::Workflow Repair, }; + enum class PackageSelectionBehavior + { + Disabled, + Prompt, + }; + // A task in the workflow. struct WorkflowTask { @@ -230,18 +236,6 @@ namespace AppInstaller::CLI::Workflow // Outputs: None void HandleSearchResultFailures(Execution::Context& context); - // Outputs the search results when multiple packages found but only one expected. - // Required Args: None - // Inputs: SearchResult - // Outputs: None - void ReportMultiplePackageFoundResult(Execution::Context& context); - - // Outputs the search results when multiple packages found but only one expected. - // Required Args: None - // Inputs: SearchResult - // Outputs: None - void ReportMultiplePackageFoundResultWithSource(Execution::Context& context); - // Ensures that there is at least one result in the search. // Required Args: bool indicating if the search result is from installed source // Inputs: SearchResult @@ -263,13 +257,14 @@ namespace AppInstaller::CLI::Workflow // Outputs: Package struct EnsureOneMatchFromSearchResult : public WorkflowTask { - EnsureOneMatchFromSearchResult(OperationType operation) : - WorkflowTask("EnsureOneMatchFromSearchResult"), m_operationType(operation) {} + EnsureOneMatchFromSearchResult(OperationType operation, PackageSelectionBehavior selectionBehavior = PackageSelectionBehavior::Disabled) : + WorkflowTask("EnsureOneMatchFromSearchResult"), m_operationType(operation), m_selectionBehavior(selectionBehavior) {} void operator()(Execution::Context& context) const override; private: OperationType m_operationType; + PackageSelectionBehavior m_selectionBehavior; }; // Gets the manifest from package. diff --git a/src/AppInstallerCLIPackage/Shared/Strings/en-us/winget.resw b/src/AppInstallerCLIPackage/Shared/Strings/en-us/winget.resw index 0fb618e4ec..af04b9f319 100644 --- a/src/AppInstallerCLIPackage/Shared/Strings/en-us/winget.resw +++ b/src/AppInstallerCLIPackage/Shared/Strings/en-us/winget.resw @@ -407,6 +407,30 @@ They can be configured through the settings file 'winget settings'. Multiple packages found matching input criteria. Please refine the input. + + Multiple packages match. Choose one. + Introduces a numbered list of packages the user can choose from. + + + Enter a number (1-{0}), or 0 to cancel: + {Locked="{0}"} {0} is the number of choices in the list. The input is a numeric choice; 0 cancels the command. + + + Invalid selection. + Shown after invalid or empty input to a numbered selection prompt. + + + Selected: {0} [{1}] + {Locked="{0}","{1}"} {0} is the package name and {1} is its identifier. Confirms the user's choice before continuing the original command. + + + Specify a package with --id <ID> --exact --source <SOURCE>. + {Locked="--id","--exact","--source","ID","SOURCE"} Guidance when multiple packages match. ID and SOURCE are placeholders. + + + Specify a package with --package-id <ID> --source <SOURCE>. + {Locked="--package-id","--source","ID","SOURCE"} Guidance for configure export when multiple packages match. ID and SOURCE are placeholders. + Filter results by name diff --git a/src/AppInstallerCLITests/ExperimentalFeature.cpp b/src/AppInstallerCLITests/ExperimentalFeature.cpp index d14bbbf5ea..6c75f6040b 100644 --- a/src/AppInstallerCLITests/ExperimentalFeature.cpp +++ b/src/AppInstallerCLITests/ExperimentalFeature.cpp @@ -7,6 +7,8 @@ #include #include +#include +#include using namespace AppInstaller::Settings; using namespace TestCommon; @@ -72,4 +74,34 @@ TEST_CASE("ExperimentalFeature ExperimentalCmd", "[experimentalFeature]") REQUIRE_FALSE(ExperimentalFeature::IsEnabled(ExperimentalFeature::Feature::ExperimentalCmd, userSettingTest)); } +} + +TEST_CASE("ExperimentalFeature InteractivePackageSelection", "[experimentalFeature]") +{ + auto again = DeleteUserSettingsFiles(); + auto [json, enabled] = GENERATE( + std::make_pair(std::string_view{ "{}" }, false), + std::make_pair(std::string_view{ R"({ "experimentalFeatures": { "interactivePackageSelection": true } })" }, true), + std::make_pair(std::string_view{ R"({ "experimentalFeatures": { "interactivePackageSelection": false } })" }, false), + std::make_pair(std::string_view{ R"({ "experimentalFeatures": { "interactivePackageSelection": "string" } })" }, false)); + bool policyEnabled = GENERATE(false, true); + auto policiesKey = RegCreateVolatileTestRoot(); + SetRegistryValue(policiesKey.get(), ExperimentalFeaturesPolicyValueName, policyEnabled); + GroupPolicyTestOverride policies{ policiesKey.get() }; + SetSetting(Stream::PrimaryUserSettings, json); + UserSettingsTest userSettingTest; + + CAPTURE(json, policyEnabled); + REQUIRE(userSettingTest.Get() == enabled); + REQUIRE(ExperimentalFeature::IsEnabled(ExperimentalFeature::Feature::InteractivePackageSelection, userSettingTest) == + (enabled && policyEnabled)); + + auto feature = ExperimentalFeature::GetFeature(ExperimentalFeature::Feature::InteractivePackageSelection); + std::string_view jsonName = feature.JsonName(); + REQUIRE(jsonName == "interactivePackageSelection"); + auto features = ExperimentalFeature::GetAllFeatures(); + REQUIRE(std::any_of(features.begin(), features.end(), [](const auto& item) + { + return item.GetFeature() == ExperimentalFeature::Feature::InteractivePackageSelection; + })); } \ No newline at end of file diff --git a/src/AppInstallerCLITests/PromptFlow.cpp b/src/AppInstallerCLITests/PromptFlow.cpp index f07e22c45d..d1e2ce5d7a 100644 --- a/src/AppInstallerCLITests/PromptFlow.cpp +++ b/src/AppInstallerCLITests/PromptFlow.cpp @@ -2,13 +2,1212 @@ // Licensed under the MIT License. #include "pch.h" #include "WorkflowCommon.h" +#include "TestHooks.h" #include +#include +#include #include +#include +#include +#include +#include +#include using namespace TestCommon; using namespace AppInstaller::CLI; +using namespace AppInstaller::CLI::Workflow; +using namespace AppInstaller::Repository; using namespace AppInstaller::Settings; +TEST_CASE("PackageSelection_Prompt", "[PackageSelection][PromptFlow]") +{ + TestUserSettings settings; + auto response = GENERATE("1", "2", "10", " 2 \t", "0"); + std::istringstream input{ response }; + std::ostringstream output; + TestContext context{ output, input }; + context.Reporter.SetConsoleStreamsForTest(true); + Execution::TableOutput<1> table(context.Reporter, { Resource::String::SearchName }); + for (size_t i = 1; i <= 10; ++i) + { + table.OutputLine({ std::to_string(i) }); + } + + context << PromptForSelection(table, Resource::String::PackageSelectionTitle); + auto selection = context.Get(); + if (std::string_view{ response } == "0") + { + REQUIRE_FALSE(selection); + REQUIRE_TERMINATED_WITH(context, E_ABORT); + } + else + { + REQUIRE_FALSE(context.IsTerminated()); + REQUIRE(selection == static_cast(std::stoul(response) - 1)); + } + REQUIRE(output.str().find(Resource::String::NumberedSelectionPrompt(10).get()) != std::string::npos); +} + +TEST_CASE("PackageSelection_InvalidInput", "[PackageSelection][PromptFlow]") +{ + TestUserSettings settings; + auto response = GENERATE("", " ", "-1", "-0", "+1", "3", "1x", "1.0", "1 2", + "4294967297", "18446744073709551615", "99999999999999999999999999"); + std::istringstream input{ std::string{ response } + "\n2\n" }; + std::ostringstream output; + TestContext context{ output, input }; + context.Reporter.SetConsoleStreamsForTest(true); + Execution::TableOutput<2> table(context.Reporter, { Resource::String::SearchName, Resource::String::SearchSource }); + table.OutputLine({ GENERATE("First", ""), "FirstSource" }); + table.OutputLine({ "Second", "FirstSource" }); + + context << PromptForSelection(table, Resource::String::PackageSelectionTitle); + REQUIRE_FALSE(context.IsTerminated()); + REQUIRE(context.Get() == size_t{1}); + const std::string prompt = Resource::String::NumberedSelectionPrompt(2).get(); + const std::string invalid = Resource::LocString{ Resource::String::NumberedSelectionInvalid }.get(); + REQUIRE(output.str().find(prompt + " " + invalid + '\n' + prompt + " ") != std::string::npos); +} + +TEST_CASE("PromptFlow_Selection_CustomTitle", "[PromptFlow]") +{ + TestUserSettings settings; + std::istringstream input{ "wrong\n2\n" }; + std::ostringstream output; + TestContext context{ output, input }; + context.Reporter.SetConsoleStreamsForTest(true); + context.Reporter.SetStyle(VisualStyle::NoVT); + TestHook::SetConsoleWidth_Override widthOverride{ std::optional{120} }; + auto text = [](std::string value) + { + return Resource::LocString{ AppInstaller::Utility::LocIndString{ std::move(value) } }; + }; + Execution::TableOutput<1> table(context.Reporter, { text("Choice") }); + table.OutputLine({ "First" }); + table.OutputLine({ "Second" }); + context << PromptForSelection(table, text("Choose a value")); + + REQUIRE_FALSE(context.IsTerminated()); + REQUIRE(context.Get() == size_t{1}); + const std::string prompt = Resource::String::NumberedSelectionPrompt(2).get(); + const std::string invalid = Resource::LocString{ Resource::String::NumberedSelectionInvalid }.get(); + REQUIRE_FALSE(prompt.empty()); + REQUIRE_FALSE(invalid.empty()); + REQUIRE(output.str() == "Choose a value\n\n# Choice\n--------\n1 First\n2 Second\n\n" + prompt + " " + invalid + '\n' + prompt + " "); +} + +TEST_CASE("PromptFlow_Selection_Unavailable", "[PromptFlow]") +{ + TestUserSettings settings; + std::istringstream input{ "1\n" }; + std::ostringstream output; + TestContext context{ output, input }; + context.Reporter.SetConsoleStreamsForTest(true); + context.Add(std::optional{0}); + Execution::TableOutput<1> table(context.Reporter, { Resource::String::SearchName }); + table.OutputLine({ "First" }); + + SECTION("Context disabled") + { + context.SetFlags(Execution::ContextFlag::DisableInteractivity); + } + SECTION("Argument disabled") + { + context.Args.AddArg(Execution::Args::Type::DisableInteractivity); + } + SECTION("Setting disabled") + { + settings.Set(true); + } + SECTION("Redirected streams") + { + context.Reporter.SetConsoleStreamsForTest(false); + } + SECTION("Hidden output") + { + context.Reporter.SetLevelMask(Execution::Reporter::Level::Info, false); + } + + context << PromptForSelection(table, Resource::String::PackageSelectionTitle); + REQUIRE_FALSE(context.IsTerminated()); + REQUIRE_FALSE(context.Get()); + REQUIRE(output.str().empty()); + REQUIRE(input.peek() == '1'); +} + +TEST_CASE("PromptFlow_Selection_InputFailure", "[PromptFlow]") +{ + TestUserSettings settings; + std::istringstream input; + std::ostringstream output; + TestContext context{ output, input }; + context.Reporter.SetConsoleStreamsForTest(true); + Execution::TableOutput<1> table(context.Reporter, { Resource::String::SearchName }); + auto count = GENERATE(size_t{0}, size_t{1}, size_t{2}); + for (size_t i = 0; i < count; ++i) + { + table.OutputLine({ "First" }); + } + PromptForSelection prompt(table, Resource::String::PackageSelectionTitle); + + REQUIRE_THROWS_HR(prompt(context), count ? APPINSTALLER_CLI_ERROR_PROMPT_INPUT_ERROR : E_INVALIDARG); + REQUIRE_FALSE(context.Get()); +} + +TEST_CASE("ReporterPromptForIntegerResponse", "[PromptFlow]") +{ + auto response = GENERATE("0", "1", "42", " 42 \t", "18446744073709551615"); + auto level = GENERATE(Execution::Reporter::Level::Info, Execution::Reporter::Level::Warning, Execution::Reporter::Level::Error); + std::istringstream input{ std::string{ response } + '\n' + "next\n" }; + std::ostringstream output; + Execution::Reporter reporter{ output, input }; + reporter.SetConsoleStreamsForTest(true); + reporter.SetStyle(VisualStyle::NoVT); + reporter.SetLevelMask(Execution::Reporter::Level::All, false); + reporter.SetLevelMask(level); + const Resource::LocString message{ AppInstaller::Utility::LocIndString{ std::string_view{ "Number:" } } }; + + auto result = reporter.PromptForIntegerResponse(message, level); + REQUIRE(result.has_value()); + REQUIRE(*result == std::stoull(response)); + REQUIRE(input.peek() == 'n'); + REQUIRE(output.str() == "Number: "); +} + +TEST_CASE("ReporterPromptForIntegerResponse_InvalidInput", "[PromptFlow]") +{ + auto response = GENERATE("", " ", "-1", "-0", "+1", "1x", "1.0", "1 2", "18446744073709551616"); + std::istringstream input{ std::string{ response } + '\n' + "2\n" + "next\n" }; + std::ostringstream output; + Execution::Reporter reporter{ output, input }; + reporter.SetConsoleStreamsForTest(true); + reporter.SetStyle(VisualStyle::NoVT); + const Resource::LocString message{ AppInstaller::Utility::LocIndString{ std::string_view{ "Number:" } } }; + + REQUIRE(reporter.PromptForIntegerResponse(message) == uint64_t{2}); + REQUIRE(input.peek() == 'n'); + const std::string invalid = Resource::LocString{ Resource::String::NumberedSelectionInvalid }.get(); + REQUIRE_FALSE(invalid.empty()); + REQUIRE(output.str() == "Number: " + invalid + '\n' + "Number: "); +} + +TEST_CASE("ReporterPromptForIntegerResponse_InputFailure", "[PromptFlow]") +{ + std::istringstream input; + std::ostringstream output; + Execution::Reporter reporter{ output, input }; + reporter.SetConsoleStreamsForTest(true); + reporter.SetStyle(VisualStyle::NoVT); + const Resource::LocString message{ AppInstaller::Utility::LocIndString{ std::string_view{ "Number:" } } }; + std::string expectedOutput = "Number: "; + + SECTION("EOF") + { + REQUIRE_THROWS_HR(reporter.PromptForIntegerResponse(message), APPINSTALLER_CLI_ERROR_PROMPT_INPUT_ERROR); + } + SECTION("Cancelled before prompting") + { + input.str("1\n"); + REQUIRE_FALSE(reporter.PromptForIntegerResponse(message, Execution::Reporter::Level::Info, + Resource::String::NumberedSelectionInvalid, []() { return true; })); + REQUIRE(input.peek() == '1'); + expectedOutput.clear(); + } + REQUIRE(output.str() == expectedOutput); +} + +TEST_CASE("ReporterPromptForIntegerResponseWithinRange", "[PromptFlow]") +{ + const uint64_t minimum = GENERATE(uint64_t{0}, uint64_t{2}, std::numeric_limits::max() - 2); + const uint64_t maximum = minimum + GENERATE(uint64_t{0}, uint64_t{2}); + const uint64_t response = GENERATE_COPY(minimum, maximum, minimum + (maximum - minimum) / 2); + auto level = GENERATE(Execution::Reporter::Level::Info, Execution::Reporter::Level::Warning, Execution::Reporter::Level::Error); + std::istringstream input{ std::to_string(response) + "\n" "next\n" }; + std::ostringstream output; + Execution::Reporter reporter{ output, input }; + reporter.SetConsoleStreamsForTest(true); + reporter.SetStyle(VisualStyle::NoVT); + reporter.SetLevelMask(Execution::Reporter::Level::All, false); + reporter.SetLevelMask(level); + const Resource::LocString message{ AppInstaller::Utility::LocIndString{ std::string_view{ "Number:" } } }; + + auto result = reporter.PromptForIntegerResponseWithinRange(message, minimum, maximum, level); + REQUIRE(result.has_value()); + REQUIRE(*result == response); + REQUIRE(input.peek() == 'n'); + REQUIRE(output.str() == "Number: "); +} + +TEST_CASE("ReporterPromptForIntegerResponseWithinRange_InvalidInput", "[PromptFlow]") +{ + auto response = GENERATE("wrong", "0", "1", "5", "18446744073709551615", "18446744073709551616"); + auto level = GENERATE(Execution::Reporter::Level::Info, Execution::Reporter::Level::Warning, Execution::Reporter::Level::Error); + std::istringstream input{ std::string{ response } + "\n3\n" "next\n" }; + std::ostringstream output; + Execution::Reporter reporter{ output, input }; + reporter.SetConsoleStreamsForTest(true); + reporter.SetStyle(VisualStyle::NoVT); + reporter.SetLevelMask(Execution::Reporter::Level::All, false); + reporter.SetLevelMask(level); + const Resource::LocString message{ AppInstaller::Utility::LocIndString{ std::string_view{ "Number:" } } }; + const Resource::LocString invalid{ AppInstaller::Utility::LocIndString{ std::string_view{ "Try again" } } }; + + REQUIRE(reporter.PromptForIntegerResponseWithinRange(message, 2, 4, level, invalid) == uint64_t{3}); + REQUIRE(input.peek() == 'n'); + REQUIRE(output.str() == "Number: Try again\nNumber: "); +} + +TEST_CASE("ReporterPromptForIntegerResponseWithinRange_CancelRetry", "[PromptFlow]") +{ + auto response = GENERATE("1", "5"); + std::istringstream input{ std::string{ response } + "\n3\n" }; + std::ostringstream output; + Execution::Reporter reporter{ output, input }; + reporter.SetConsoleStreamsForTest(true); + reporter.SetStyle(VisualStyle::NoVT); + const Resource::LocString message{ AppInstaller::Utility::LocIndString{ std::string_view{ "Number:" } } }; + const Resource::LocString invalid{ AppInstaller::Utility::LocIndString{ std::string_view{ "Try again" } } }; + auto isCancelled = [&]() { return output.str().find("Try again\n") != std::string::npos; }; + + REQUIRE_FALSE(reporter.PromptForIntegerResponseWithinRange(message, 2, 4, Execution::Reporter::Level::Info, invalid, isCancelled)); + REQUIRE(input.peek() == '3'); + REQUIRE(output.str() == "Number: Try again\n"); +} + +TEST_CASE("ReporterPromptForIntegerResponseWithinRange_InvalidRange", "[PromptFlow]") +{ + std::istringstream input{ "1\n" }; + std::ostringstream output; + Execution::Reporter reporter{ output, input }; + reporter.SetConsoleStreamsForTest(GENERATE(false, true)); + const Resource::LocString message{ AppInstaller::Utility::LocIndString{ std::string_view{ "Number:" } } }; + + REQUIRE_THROWS_HR(reporter.PromptForIntegerResponseWithinRange(message, 2, 1), E_INVALIDARG); + REQUIRE(input.peek() == '1'); + REQUIRE(output.str().empty()); +} + +TEST_CASE("ReporterReadLine", "[PromptFlow]") +{ + auto response = GENERATE("", " text \t", "0", "invalid", "99999999999999999999999999"); + std::istringstream input{ std::string{ response } + '\n' + "next\n" }; + std::ostringstream output; + Execution::Reporter reporter{ output, input }; + reporter.SetConsoleStreamsForTest(true); + reporter.SetLevelMask(Execution::Reporter::Level::Info, GENERATE(false, true)); + + REQUIRE(reporter.ReadLine() == response); + REQUIRE(input.peek() == 'n'); + REQUIRE(output.str().empty()); +} + +TEST_CASE("ReporterReadLine_InputFailure", "[PromptFlow]") +{ + std::istringstream input; + std::ostringstream output; + Execution::Reporter reporter{ output, input }; + reporter.SetConsoleStreamsForTest(true); + + SECTION("EOF") + { + REQUIRE_THROWS_HR(reporter.ReadLine(), APPINSTALLER_CLI_ERROR_PROMPT_INPUT_ERROR); + } + SECTION("Redirected streams") + { + reporter.SetConsoleStreamsForTest(false); + REQUIRE_THROWS_HR(reporter.ReadLine(), HRESULT_FROM_WIN32(ERROR_INVALID_STATE)); + } + SECTION("Cancelled input failure") + { + int checks = 0; + REQUIRE_FALSE(reporter.ReadLine([&]() { return ++checks == 3; })); + } + SECTION("Aborted stream read") + { + struct AbortedInputBuffer : std::streambuf + { + int_type underflow() override + { + SetLastError(ERROR_OPERATION_ABORTED); + return traits_type::eof(); + } + } buffer; + std::istream abortedInput{ &buffer }; + Execution::Reporter abortedReporter{ output, abortedInput }; + abortedReporter.SetConsoleStreamsForTest(true); + REQUIRE_FALSE(abortedReporter.ReadLine([]() { return false; })); + } + REQUIRE(output.str().empty()); +} + +TEST_CASE("ReporterReadLine_CancelBeforeRead", "[PromptFlow]") +{ + auto cancelOnCheck = GENERATE(1, 2, 3); + std::istringstream input{ "invalid\n2\n" }; + std::ostringstream output; + TestContext context{ output, input }; + context.Reporter.SetConsoleStreamsForTest(true); + int checks = 0; + auto isCancelled = [&]() + { + if (++checks == cancelOnCheck) + { + context.Terminate(E_ABORT); + } + return context.IsTerminated(); + }; + + REQUIRE_FALSE(context.Reporter.ReadLine(isCancelled)); + REQUIRE_TERMINATED_WITH(context, E_ABORT); + REQUIRE(input.peek() == (cancelOnCheck >= 3 ? '2' : 'i')); + REQUIRE(output.str().empty()); +} + +TEST_CASE("ReporterReadLine_CancelPendingRead", "[PromptFlow]") +{ + struct PipeInputBuffer : std::streambuf + { + wil::unique_handle ReadHandle; + wil::unique_handle WriteHandle; + wil::unique_event ReadStarted{ wil::EventOptions::ManualReset }; + char Character = 0; + + PipeInputBuffer() + { + THROW_IF_WIN32_BOOL_FALSE(CreatePipe(ReadHandle.put(), WriteHandle.put(), nullptr, 0)); + } + + int_type underflow() override + { + ReadStarted.SetEvent(); + DWORD count = 0; + if (!ReadFile(ReadHandle.get(), &Character, 1, &count, nullptr) || !count) + { + return traits_type::eof(); + } + setg(&Character, &Character, &Character + 1); + return traits_type::to_int_type(Character); + } + }; + + bool integerPrompt = GENERATE(false, true); + PipeInputBuffer buffer; + std::istream input{ &buffer }; + std::ostringstream output; + TestContext context{ output, input }; + context.Reporter.SetConsoleStreamsForTest(true); + context.Reporter.SetStyle(VisualStyle::NoVT); + wil::unique_event finished{ wil::EventOptions::ManualReset }; + bool timedOut = false; + std::thread cancel([&]() + { + if (buffer.ReadStarted.wait(5000)) + { + context.Cancel(AppInstaller::CancelReason::CtrlCSignal); + } + }); + std::thread watchdog([&]() + { + if (!finished.wait(5000)) + { + timedOut = true; + DWORD written = 0; + LOG_IF_WIN32_BOOL_FALSE(WriteFile(buffer.WriteHandle.get(), "\n", 1, &written, nullptr)); + } + }); + auto join = wil::scope_exit([&]() + { + finished.SetEvent(); + cancel.join(); + watchdog.join(); + }); + + auto isCancelled = [&]() { return context.IsTerminated(); }; + if (integerPrompt) + { + REQUIRE_FALSE(context.Reporter.PromptForIntegerResponse(Resource::String::NumberedSelectionPrompt(2), + Execution::Reporter::Level::Info, Resource::String::NumberedSelectionInvalid, isCancelled)); + } + else + { + REQUIRE_FALSE(context.Reporter.ReadLine(isCancelled)); + } + finished.SetEvent(); + cancel.join(); + watchdog.join(); + join.release(); + REQUIRE_FALSE(timedOut); + REQUIRE_TERMINATED_WITH(context, E_ABORT); + REQUIRE(output.str() == (integerPrompt ? Resource::String::NumberedSelectionPrompt(2).get() + " " : std::string{})); +} + +TEST_CASE("ReporterReadLine_ConsoleCancellationWaitsForHandler", "[PromptFlow]") +{ + bool integerPrompt = GENERATE(false, true); + BOOL readSucceeded = GENERATE(FALSE, TRUE); + std::istringstream input; + std::ostringstream output; + TestContext context{ output, input }; + context.Reporter.SetConsoleStreamsForTest(true); + context.Reporter.SetInputStreamFileTypeForTest(FILE_TYPE_CHAR); + context.Reporter.SetStyle(VisualStyle::NoVT); + wil::unique_event readAborted{ wil::EventOptions::ManualReset }; + wil::unique_event readReturned{ wil::EventOptions::ManualReset }; + size_t readCount = 0; + TestHook::SetReadConsole_Override readConsoleOverride{ [&](wchar_t*, DWORD, DWORD* charactersRead) + { + ++readCount; + *charactersRead = 0; + readAborted.SetEvent(); + SetLastError(ERROR_OPERATION_ABORTED); + return readSucceeded; + } }; + + bool cancelled = false; + std::exception_ptr exception; + auto isCancelled = [&]() { return context.IsTerminated(); }; + std::thread reader([&]() + { + try + { + if (integerPrompt) + { + cancelled = !context.Reporter.PromptForIntegerResponse(Resource::String::NumberedSelectionPrompt(2), + Execution::Reporter::Level::Info, Resource::String::NumberedSelectionInvalid, isCancelled); + } + else + { + cancelled = !context.Reporter.ReadLine(isCancelled); + } + } + catch (...) + { + exception = std::current_exception(); + } + readReturned.SetEvent(); + }); + auto join = wil::scope_exit([&]() + { + context.Cancel(AppInstaller::CancelReason::Abort); + reader.join(); + }); + + bool aborted = readAborted.wait(5000); + bool returnedBeforeCancellation = readReturned.wait(100); + context.Cancel(AppInstaller::CancelReason::CtrlCSignal); + reader.join(); + join.release(); + if (exception) + { + std::rethrow_exception(exception); + } + + REQUIRE(aborted); + REQUIRE_FALSE(returnedBeforeCancellation); + REQUIRE(cancelled); + REQUIRE(readCount == size_t{1}); + REQUIRE_TERMINATED_WITH(context, E_ABORT); + REQUIRE(output.str() == (integerPrompt ? Resource::String::NumberedSelectionPrompt(2).get() + " " : std::string{})); +} + +TEST_CASE("ReporterReadLine_ConsoleCancellationAlreadyRecorded", "[PromptFlow]") +{ + BOOL readSucceeded = GENERATE(FALSE, TRUE); + std::istringstream input; + std::ostringstream output; + TestContext context{ output, input }; + context.Reporter.SetConsoleStreamsForTest(true); + context.Reporter.SetInputStreamFileTypeForTest(FILE_TYPE_CHAR); + TestHook::SetReadConsole_Override readConsoleOverride{ [&](wchar_t*, DWORD, DWORD* charactersRead) + { + context.Terminate(E_ABORT); + *charactersRead = 0; + SetLastError(ERROR_OPERATION_ABORTED); + return readSucceeded; + } }; + + REQUIRE_FALSE(context.Reporter.ReadLine([&]() { return context.IsTerminated(); })); + REQUIRE_TERMINATED_WITH(context, E_ABORT); + REQUIRE(output.str().empty()); +} + +TEST_CASE("PackageSelection_ConsoleStreams", "[PackageSelection][PromptFlow]") +{ + Execution::Reporter reporter; + DWORD mode = 0; + bool consoleStreams = GetConsoleMode(GetStdHandle(STD_INPUT_HANDLE), &mode) && + GetConsoleMode(GetStdHandle(STD_OUTPUT_HANDLE), &mode); + REQUIRE(reporter.CanPrompt() == consoleStreams); + + reporter.SetConsoleStreamsForTest(GENERATE(false, true)); + Execution::Reporter clone{ reporter, Execution::Reporter::clone_t{} }; + REQUIRE(clone.CanPrompt() == reporter.CanPrompt()); +} + +TEST_CASE("PackageSelection_ReporterUnavailable", "[PackageSelection][PromptFlow]") +{ + auto level = GENERATE(Execution::Reporter::Level::Info, Execution::Reporter::Level::Warning); + std::istringstream input{ "1\n" }; + std::ostringstream output; + Execution::Reporter reporter{ output, input }; + reporter.SetConsoleStreamsForTest(true); + + SECTION("Redirected streams") + { + reporter.SetConsoleStreamsForTest(false); + } + SECTION("Hidden output level") + { + reporter.SetLevelMask(level, false); + } + SECTION("Non-output channel") + { + reporter.SetChannel(GENERATE(Execution::Reporter::Channel::Completion, Execution::Reporter::Channel::Json, + Execution::Reporter::Channel::Disabled)); + } + + REQUIRE_FALSE(reporter.CanPrompt(level)); + REQUIRE_FALSE(reporter.PromptForIntegerResponse(Resource::String::NumberedSelectionPrompt(2), level)); + REQUIRE_FALSE(reporter.PromptForIntegerResponseWithinRange(Resource::String::NumberedSelectionPrompt(2), 0, 2, level)); + REQUIRE(input.peek() == '1'); + REQUIRE(output.str().empty()); +} + +TEST_CASE("PackageSelection_FeatureDisabled", "[PackageSelection][workflow]") +{ + TestUserSettings settings; + if (GENERATE(false, true)) + { + settings.Set(false); + } + std::istringstream input{ "0\n" }; + std::ostringstream output; + TestContext context{ output, input }; + auto previousThreadGlobals = context.SetForCurrentThread(); + context.Reporter.SetConsoleStreamsForTest(true); + context.Args.AddArg(Execution::Args::Type::Query, TSR::TestQuery_ReturnTwo.Query); + OverrideForOpenSource(context, CreateTestSource({ TSR::TestQuery_ReturnTwo })); + + SECTION("Install") + { + if (GENERATE(false, true)) + { + context.Args.AddArg(Execution::Args::Type::Silent); + } + context.Args.AddArg(Execution::Args::Type::Force); + InstallCommand({}).Execute(context); + } + SECTION("Show") + { + ShowCommand({}).Execute(context); + } + SECTION("Show versions") + { + context.Args.AddArg(Execution::Args::Type::ListVersions); + ShowCommand({}).Execute(context); + } + SECTION("Download") + { + DownloadCommand({}).Execute(context); + } + + INFO(output.str()); + REQUIRE_TERMINATED_WITH(context, APPINSTALLER_CLI_ERROR_MULTIPLE_APPLICATIONS_FOUND); + REQUIRE_FALSE(context.Contains(Execution::Data::Package)); + REQUIRE_FALSE(context.Contains(Execution::Data::Manifest)); + REQUIRE(input.peek() == '0'); + REQUIRE(output.str().find(Resource::String::NumberedSelectionPrompt(2).get()) == std::string::npos); + const std::string refinement = Resource::LocString{ Resource::String::PackageSelectionRefine }.get(); + REQUIRE_FALSE(refinement.empty()); + REQUIRE(output.str().find(refinement) != std::string::npos); +} + +TEST_CASE("PackageSelection_CommandCancel", "[PackageSelection][workflow]") +{ + TestUserSettings settings; + settings.Set(true); + std::istringstream input{ "0\n" }; + std::ostringstream output; + TestContext context{ output, input }; + auto previousThreadGlobals = context.SetForCurrentThread(); + context.Reporter.SetConsoleStreamsForTest(true); + context.Args.AddArg(Execution::Args::Type::Query, TSR::TestQuery_ReturnTwo.Query); + OverrideForOpenSource(context, CreateTestSource({ TSR::TestQuery_ReturnTwo })); + + SECTION("Install") + { + bool silent = GENERATE(false, true); + if (silent) + { + context.Args.AddArg(Execution::Args::Type::Silent); + } + context.Args.AddArg(Execution::Args::Type::Force); + InstallCommand({}).Execute(context); + } + SECTION("Show") + { + ShowCommand({}).Execute(context); + } + SECTION("Show versions") + { + context.Args.AddArg(Execution::Args::Type::ListVersions); + ShowCommand({}).Execute(context); + } + SECTION("Download") + { + DownloadCommand({}).Execute(context); + } + + INFO(output.str()); + REQUIRE_TERMINATED_WITH(context, E_ABORT); + REQUIRE_FALSE(context.Contains(Execution::Data::Package)); + REQUIRE_FALSE(context.Contains(Execution::Data::Manifest)); + REQUIRE(output.str().find(Resource::String::NumberedSelectionPrompt(2).get()) != std::string::npos); +} + +TEST_CASE("PackageSelection_CommandContinue", "[PackageSelection][workflow]") +{ + TestUserSettings settings; + settings.Set(true); + std::istringstream input{ "1\n" }; + std::ostringstream output; + TestContext context{ output, input }; + auto previousThreadGlobals = context.SetForCurrentThread(); + context.Reporter.SetConsoleStreamsForTest(true); + context.Args.AddArg(Execution::Args::Type::Query, TSR::TestQuery_ReturnTwo.Query); + context.Args.AddArg(Execution::Args::Type::Version, "1.0.0.0"sv); + OverrideForOpenSource(context, CreateTestSource({ TSR::TestQuery_ReturnTwo })); + + auto checkSelection = [](TestContext& selectedContext) + { + REQUIRE(selectedContext.Get()->GetProperty(AppInstaller::Repository::PackageProperty::Id) == + "AppInstallerCliTest.TestExeInstaller"); + REQUIRE(selectedContext.Get().Version == "1.0.0.0"); + REQUIRE(selectedContext.Args.GetArg(Execution::Args::Type::Version) == "1.0.0.0"); + selectedContext.Terminate(E_ABORT); + }; + + SECTION("Install") + { + bool silent = GENERATE(false, true); + if (silent) + { + context.Args.AddArg(Execution::Args::Type::Silent); + } + context.Args.AddArg(Execution::Args::Type::Force); + context.Override({ Workflow::InstallSinglePackage, checkSelection, 1 }); + InstallCommand({}).Execute(context); + REQUIRE(context.Args.Contains(Execution::Args::Type::Silent) == silent); + } + SECTION("Show") + { + context.Override({ Workflow::ShowManifestInfo, checkSelection, 1 }); + ShowCommand({}).Execute(context); + } + SECTION("Download") + { + context.Override({ Workflow::SetDownloadDirectory, checkSelection, 1 }); + DownloadCommand({}).Execute(context); + } + + INFO(output.str()); + REQUIRE_TERMINATED_WITH(context, E_ABORT); + REQUIRE(output.str().find(Resource::String::NumberedSelectionPrompt(2).get()) != std::string::npos); +} + +TEST_CASE("PackageSelection_MultipleQueries", "[PackageSelection][workflow][MultiQuery]") +{ + TestUserSettings settings; + settings.Set(true); + std::istringstream input{ "1\n" }; + std::ostringstream output; + TestContext context{ output, input }; + auto previousThreadGlobals = context.SetForCurrentThread(); + context.Reporter.SetConsoleStreamsForTest(true); + context.Args.AddArg(Execution::Args::Type::Force); + context.Args.AddArg(Execution::Args::Type::MultiQuery, TSR::TestQuery_ReturnTwo.Query); + context.Args.AddArg(Execution::Args::Type::MultiQuery, "MissingPackage"sv); + OverrideForOpenSource(context, CreateTestSource({ TSR::TestQuery_ReturnTwo })); + context.Override({ Workflow::GetSearchRequestForSingle, [](TestContext& subContext) + { + subContext.Reporter.SetConsoleStreamsForTest(true); + Workflow::GetSearchRequestForSingle(subContext); + } }); + + InstallCommand({}).Execute(context); + INFO(output.str()); + REQUIRE_TERMINATED_WITH(context, APPINSTALLER_CLI_ERROR_NOT_ALL_QUERIES_FOUND_SINGLE); + REQUIRE(input.peek() == '1'); + REQUIRE(output.str().find(Resource::String::NumberedSelectionPrompt(2).get()) == std::string::npos); +} + +TEST_CASE("PackageSelection_SearchResult", "[PackageSelection][SourcePriority][workflow]") +{ + TestUserSettings settings; + settings.Set(true); + auto width = GENERATE(size_t{20}, size_t{120}); + TestHook::SetConsoleWidth_Override widthOverride{ std::optional{width} }; + auto operation = GENERATE(OperationType::Install, OperationType::Show, OperationType::Download); + auto manifest = AppInstaller::Manifest::YamlParser::CreateFromPath(TestDataFile("InstallFlowTest_Exe.yaml")); + std::vector versions{ manifest }; + auto firstSource = std::make_shared(); + auto secondSource = std::make_shared(); + auto lowPriority = std::make_shared(); + firstSource->Details.Name = "FirstSource"; + secondSource->Details.Name = "SecondSource"; + SearchResult result; + result.Matches.emplace_back(TestCompositePackage::Make(versions, firstSource), + PackageMatchFilter{ PackageMatchField::Id, MatchType::Exact, manifest.Id }); + result.Matches.emplace_back(TestCompositePackage::Make(versions, secondSource), + PackageMatchFilter{ PackageMatchField::Id, MatchType::Exact, manifest.Id }); + auto expectedPackage = result.Matches[1].Package; + bool expectPrompt = true; + bool expectSecondSource = true; + + SECTION("Same identity across sources") + { + } + SECTION("Single source") + { + result.Matches[1].Package = TestCompositePackage::Make(versions, firstSource); + expectedPackage = result.Matches[1].Package; + expectSecondSource = false; + } + SECTION("Multiple available sources for a candidate") + { + auto package = TestCompositePackage::Make(versions, firstSource); + package->Available.emplace_back(TestPackage::Make(versions, secondSource)); + result.Matches[0].Package = package; + } + SECTION("Unique source priority") + { + secondSource->Details.Priority = 1; + expectPrompt = false; + } + SECTION("Priority tie") + { + firstSource->Details.Priority = 1; + secondSource->Details.Priority = 1; + auto excluded = TestCompositePackage::Make(versions, lowPriority); + result.Matches.insert(result.Matches.begin(), ResultMatch{ excluded, + PackageMatchFilter{ PackageMatchField::Id, MatchType::Exact, manifest.Id } }); + } + SECTION("Single match") + { + result.Matches.erase(result.Matches.begin()); + expectPrompt = false; + } + + std::istringstream input{ "2\n" }; + std::ostringstream output; + TestContext context{ output, input }; + auto previousThreadGlobals = context.SetForCurrentThread(); + context.Reporter.SetConsoleStreamsForTest(true); + context.Reporter.SetStyle(VisualStyle::NoVT); + context.Add(std::move(result)); + context << EnsureOneMatchFromSearchResult(operation, PackageSelectionBehavior::Prompt); + + INFO(output.str()); + REQUIRE_FALSE(context.IsTerminated()); + REQUIRE(context.Get() == expectedPackage); + const std::string title = Resource::LocString{ Resource::String::PackageSelectionTitle }.get(); + REQUIRE_FALSE(title.empty()); + REQUIRE((output.str().find(title + "\n\n") != std::string::npos) == expectPrompt); + REQUIRE((output.str().find(Resource::String::NumberedSelectionPrompt(2).get()) != std::string::npos) == expectPrompt); + if (expectPrompt) + { + auto tableStart = output.str().find("\n# "); + REQUIRE(tableStart != std::string::npos); + auto tableEnd = output.str().find("\n\n", tableStart + 1); + REQUIRE(tableEnd != std::string::npos); + auto tableText = output.str().substr(tableStart + 1, tableEnd - tableStart - 1); + REQUIRE(tableText.find("\n1 ") != std::string::npos); + REQUIRE(tableText.find("\n2 ") != std::string::npos); + std::istringstream tableStream{ tableText }; + std::string line; + size_t lineCount = 0; + while (std::getline(tableStream, line)) + { + REQUIRE(AppInstaller::Utility::UTF8ColumnWidth(line) < width); + ++lineCount; + } + REQUIRE(lineCount == size_t{4}); + if (width == 120) + { + REQUIRE(tableText.find(manifest.DefaultLocalization.Get()) != std::string::npos); + REQUIRE(tableText.find(manifest.Id) != std::string::npos); + REQUIRE(tableText.find(Resource::LocString{ Resource::String::SearchVersion }.get()) == std::string::npos); + REQUIRE(tableText.find(manifest.Version) == std::string::npos); + REQUIRE(tableText.find(Resource::LocString{ Resource::String::SearchSource }.get()) != std::string::npos); + REQUIRE(tableText.find("FirstSource") != std::string::npos); + REQUIRE((tableText.find("SecondSource") != std::string::npos) == expectSecondSource); + } + else + { + REQUIRE(tableText.find("\xE2\x80\xA6") != std::string::npos); + } + } + else + { + REQUIRE(input.peek() == '2'); + } + REQUIRE(firstSource->CountOfCallsRequiringManifestData == 0); + REQUIRE(secondSource->CountOfCallsRequiringManifestData == 0); +} + +TEST_CASE("PackageSelection_CandidateRowIdentity", "[PackageSelection][workflow]") +{ + struct PrimaryPackage : TestCompositePackage + { + using TestCompositePackage::TestCompositePackage; + + LocIndString GetProperty(PackageProperty property) const override + { + return Available.at(1)->GetProperty(property); + } + }; + + TestUserSettings settings; + TestHook::SetConsoleWidth_Override widthOverride{ std::optional{120} }; + auto operation = GENERATE(OperationType::Install, OperationType::Show, OperationType::Download); + settings.Set(true); + bool differentName = GENERATE(false, true); + bool differentId = GENERATE(false, true); + bool missingSourceName = GENERATE(false, true); + auto manifest = AppInstaller::Manifest::YamlParser::CreateFromPath(TestDataFile("InstallFlowTest_Exe.yaml")); + manifest.Id = "Public.App"; + manifest.Version = "1.0"; + manifest.DefaultLocalization.Add("PublicName"); + auto firstSource = std::make_shared(); + auto secondSource = std::make_shared(); + firstSource->Details.Name = "FirstSource"; + secondSource->Details.Name = "SecondSource"; + auto package = std::make_shared(std::vector{ manifest }, firstSource); + + std::string secondName = differentName ? "PrivateName" : "PublicName"; + std::string secondId = differentId ? "Private.App" : "Public.App"; + manifest.Id = secondId; + manifest.Version = "2.0"; + manifest.DefaultLocalization.Add(secondName); + package->Available.emplace_back(TestPackage::Make(std::vector{ manifest }, secondSource)); + if (missingSourceName) + { + firstSource->Details.Name.clear(); + } + + SearchResult result; + result.Matches.emplace_back(package, PackageMatchFilter{ PackageMatchField::Id, MatchType::Exact, secondId }); + manifest.Id = "Other.App"; + manifest.Version = "4.0"; + manifest.DefaultLocalization.Add("OtherName"); + result.Matches.emplace_back(TestCompositePackage::Make(std::vector{ manifest }, firstSource), + PackageMatchFilter{ PackageMatchField::Id, MatchType::Exact, manifest.Id }); + + std::istringstream input{ "1\n" }; + std::ostringstream output; + TestContext context{ output, input }; + auto previousThreadGlobals = context.SetForCurrentThread(); + context.Reporter.SetConsoleStreamsForTest(true); + context.Reporter.SetStyle(VisualStyle::NoVT); + context.Add(std::move(result)); + context << EnsureOneMatchFromSearchResult(operation, PackageSelectionBehavior::Prompt); + + INFO(output.str()); + REQUIRE_FALSE(context.IsTerminated()); + REQUIRE(context.Get() == package); + auto tableStart = output.str().find("\n# "); + REQUIRE(tableStart != std::string::npos); + auto tableEnd = output.str().find("\n\n", tableStart + 1); + REQUIRE(tableEnd != std::string::npos); + std::istringstream tableStream{ output.str().substr(tableStart + 1, tableEnd - tableStart - 1) }; + std::string line; + REQUIRE(static_cast(std::getline(tableStream, line))); + REQUIRE(static_cast(std::getline(tableStream, line))); + std::vector> expectedRows{ + { "1", secondName, secondId }, + { "2", "OtherName", "Other.App" } + }; + if (!missingSourceName) + { + expectedRows[0].emplace_back("FirstSource"); + expectedRows[1].emplace_back("FirstSource"); + } + for (const auto& expectedRow : expectedRows) + { + REQUIRE(static_cast(std::getline(tableStream, line))); + std::istringstream rowStream{ line }; + std::vector fields; + std::string field; + while (rowStream >> field) + { + fields.emplace_back(std::move(field)); + } + REQUIRE(fields == expectedRow); + } + REQUIRE_FALSE(std::getline(tableStream, line)); + REQUIRE(firstSource->CountOfCallsRequiringManifestData == 0); + REQUIRE(secondSource->CountOfCallsRequiringManifestData == 0); +} + +TEST_CASE("PackageSelection_SharedAmbiguityTables", "[PackageSelection][workflow]") +{ + TestUserSettings settings; + settings.Set(true); + TestHook::SetConsoleWidth_Override widthOverride{ std::optional{120} }; + bool withSource = GENERATE(false, true); + bool available = GENERATE(false, true); + bool prompt = GENERATE(false, true); + CAPTURE(withSource, available, prompt); + auto source = std::make_shared(); + auto manifest = AppInstaller::Manifest::YamlParser::CreateFromPath(TestDataFile("InstallFlowTest_Exe.yaml")); + std::istringstream input{ "2\n" }; + std::ostringstream output; + std::ostringstream expectedOutput; + TestContext context{ output, input }; + auto previousThreadGlobals = context.SetForCurrentThread(); + context.Reporter.SetConsoleStreamsForTest(true); + context.Reporter.SetStyle(VisualStyle::NoVT); + Execution::Reporter expectedReporter{ expectedOutput, input }; + expectedReporter.SetStyle(VisualStyle::NoVT); + std::vector header{ Resource::String::SearchName, Resource::String::SearchId }; + if (withSource) + { + header.emplace_back(Resource::String::SearchSource); + } + if (prompt) + { + header.insert(header.begin(), Resource::LocString{ AppInstaller::Utility::LocIndString{ "#"sv } }); + expectedReporter.Info() << Resource::String::PackageSelectionTitle << std::endl << std::endl; + } + else + { + expectedReporter.Warn() << (withSource ? Resource::String::MultiplePackagesFound : Resource::String::MultipleInstalledPackagesFound) << std::endl; + } + Execution::TableOutputBase expectedTable{ expectedReporter, std::move(header) }; + SearchResult result; + for (size_t i = 1; i <= 2; ++i) + { + auto name = "Package" + std::to_string(i); + manifest.Id = "Test." + name; + manifest.DefaultLocalization.Add(name); + auto package = available ? TestCompositePackage::Make(std::vector{ manifest }, source) : + TestCompositePackage::Make(manifest, TestPackage::MetadataMap{}, std::vector{}, source); + result.Matches.emplace_back(package, PackageMatchFilter{ PackageMatchField::Id, MatchType::Exact, manifest.Id }); + std::vector line{ name, manifest.Id }; + if (withSource) + { + line.emplace_back(available ? source->Details.Name : ""); + } + if (prompt) + { + line.insert(line.begin(), std::to_string(i)); + } + expectedTable.OutputLine(std::move(line)); + } + auto expectedPackage = result.Matches[1].Package; + context.Add(std::move(result)); + context << EnsureOneMatchFromSearchResult(withSource ? OperationType::Install : OperationType::Uninstall, + prompt ? PackageSelectionBehavior::Prompt : PackageSelectionBehavior::Disabled); + expectedTable.Complete(); + if (prompt) + { + expectedReporter.Info() << std::endl << Resource::String::NumberedSelectionPrompt(2) << ' ' << + Resource::String::PackageSelectionSelected(AppInstaller::Utility::LocIndView{ "Package2"sv }, + AppInstaller::Utility::LocIndView{ "Test.Package2"sv }) << std::endl; + REQUIRE_FALSE(context.IsTerminated()); + REQUIRE(context.Get() == size_t{1}); + REQUIRE(context.Get() == expectedPackage); + } + else + { + expectedReporter.Info() << Resource::String::PackageSelectionRefine << std::endl; + REQUIRE_TERMINATED_WITH(context, APPINSTALLER_CLI_ERROR_MULTIPLE_APPLICATIONS_FOUND); + REQUIRE_FALSE(context.Contains(Execution::Data::Package)); + REQUIRE_FALSE(context.Contains(Execution::Data::SelectedIndex)); + REQUIRE(input.peek() == '2'); + } + + REQUIRE(output.str() == expectedOutput.str()); + REQUIRE(source->CountOfCallsRequiringManifestData == 0); +} + +TEST_CASE("PackageSelection_AmbiguityRefinement", "[PackageSelection][workflow]") +{ + TestUserSettings settings; + bool enabled = GENERATE(false, true); + settings.Set(bool{ enabled }); + auto operation = GENERATE(OperationType::Install, OperationType::Uninstall, OperationType::Export); + auto selectionBehavior = GENERATE(PackageSelectionBehavior::Disabled, PackageSelectionBehavior::Prompt); + bool installed = operation != OperationType::Install; + bool truncated = GENERATE(false, true); + CAPTURE(enabled, operation, selectionBehavior, truncated); + TestHook::SetConsoleWidth_Override widthOverride{ std::optional{120} }; + auto source = CreateTestSource({ TSR::TestQuery_ReturnTwo }); + auto result = source->Search({}); + result.Truncated = truncated; + std::istringstream input{ "2\n" }; + std::ostringstream output; + std::ostringstream expectedOutput; + TestContext context{ output, input }; + auto previousThreadGlobals = context.SetForCurrentThread(); + context.Reporter.SetConsoleStreamsForTest(true); + context.Reporter.SetStyle(VisualStyle::NoVT); + context.Args.AddArg(Execution::Args::Type::DisableInteractivity); + context.Add(std::move(result)); + Execution::Reporter expectedReporter{ expectedOutput, input }; + expectedReporter.SetStyle(VisualStyle::NoVT); + expectedReporter.Warn() << (installed ? Resource::String::MultipleInstalledPackagesFound : Resource::String::MultiplePackagesFound) << std::endl; + std::vector header{ Resource::String::SearchName, Resource::String::SearchId }; + std::vector firstRow{ "AppInstaller Test Exe Installer", "AppInstallerCliTest.TestExeInstaller" }; + std::vector secondRow{ "MSIX SDK", "microsoft.msixsdk" }; + if (!installed) + { + header.emplace_back(Resource::String::SearchSource); + firstRow.emplace_back(source->Details.Name); + secondRow.emplace_back(source->Details.Name); + } + Execution::TableOutputBase table{ expectedReporter, std::move(header) }; + table.OutputLine(std::move(firstRow)); + table.OutputLine(std::move(secondRow)); + table.Complete(); + if (truncated) + { + expectedReporter.Info() << '<' << Resource::String::SearchTruncated << '>' << std::endl; + } + auto refinementId = operation == OperationType::Export ? + Resource::String::PackageSelectionRefineForExport : Resource::String::PackageSelectionRefine; + const std::string refinement = Resource::LocString{ refinementId }.get(); + REQUIRE_FALSE(refinement.empty()); + REQUIRE(refinement.find("--source") != std::string::npos); + if (operation == OperationType::Export) + { + REQUIRE(refinement.find("--package-id") != std::string::npos); + REQUIRE(refinement.find("--id") == std::string::npos); + REQUIRE(refinement.find("--exact") == std::string::npos); + } + else + { + REQUIRE(refinement.find("--id") != std::string::npos); + REQUIRE(refinement.find("--exact") != std::string::npos); + } + expectedReporter.Info() << refinementId << std::endl; + + context << EnsureOneMatchFromSearchResult(operation, selectionBehavior); + + REQUIRE_TERMINATED_WITH(context, APPINSTALLER_CLI_ERROR_MULTIPLE_APPLICATIONS_FOUND); + REQUIRE_FALSE(context.Contains(Execution::Data::Package)); + REQUIRE(input.peek() == '2'); + REQUIRE(output.str() == expectedOutput.str()); +} + +TEST_CASE("PackageSelection_PartialSearchFailureDoesNotPrompt", "[PackageSelection][workflow]") +{ + TestUserSettings settings; + settings.Set(true); + TestHook::SetConsoleWidth_Override widthOverride{ std::optional{120} }; + auto source = CreateTestSource({ TSR::TestQuery_ReturnTwo }); + auto result = source->Search({}); + bool truncated = GENERATE(false, true); + result.Truncated = truncated; + result.Failures.push_back({ "BrokenSource", std::make_exception_ptr(wil::ResultException(E_FAIL)) }); + std::istringstream input{ "2\n" }; + std::ostringstream output; + std::ostringstream expectedOutput; + TestContext context{ output, input }; + auto previousThreadGlobals = context.SetForCurrentThread(); + context.Reporter.SetConsoleStreamsForTest(true); + context.Reporter.SetStyle(VisualStyle::NoVT); + context.SetFlags(Execution::ContextFlag::ShowSearchResultsOnPartialFailure); + context.Add(std::move(result)); + Execution::Reporter expectedReporter{ expectedOutput, input }; + expectedReporter.SetStyle(VisualStyle::NoVT); + expectedReporter.Info() << std::endl << Resource::String::SearchFailureErrorListMatches << std::endl; + Execution::TableOutput<3> table{ expectedReporter, + { Resource::String::SearchName, Resource::String::SearchId, Resource::String::SearchSource } }; + table.OutputLine({ "AppInstaller Test Exe Installer", "AppInstallerCliTest.TestExeInstaller", source->Details.Name }); + table.OutputLine({ "MSIX SDK", "microsoft.msixsdk", source->Details.Name }); + table.Complete(); + if (truncated) + { + expectedReporter.Info() << '<' << Resource::String::SearchTruncated << '>' << std::endl; + } + + context << HandleSearchResultFailures; + + REQUIRE(context.GetTerminationHR() == E_FAIL); + REQUIRE_FALSE(context.Contains(Execution::Data::SelectedIndex)); + REQUIRE(input.peek() == '2'); + REQUIRE(output.str().find(Resource::String::NumberedSelectionPrompt(2).get()) == std::string::npos); + auto tableStart = output.str().find(expectedOutput.str()); + REQUIRE(tableStart != std::string::npos); + REQUIRE(output.str().substr(tableStart) == expectedOutput.str()); +} + +TEST_CASE("PackageSelection_Unavailable", "[PackageSelection][workflow]") +{ + TestUserSettings settings; + settings.Set(true); + std::istringstream input{ "2\n" }; + std::ostringstream output; + TestContext context{ output, input }; + auto previousThreadGlobals = context.SetForCurrentThread(); + context.Reporter.SetConsoleStreamsForTest(true); + auto source = CreateTestSource({ TSR::TestQuery_ReturnTwo }); + auto result = source->Search({}); + EnsureOneMatchFromSearchResult ensureOneMatch{ OperationType::Install, PackageSelectionBehavior::Prompt }; + HRESULT expectedError = APPINSTALLER_CLI_ERROR_MULTIPLE_APPLICATIONS_FOUND; + + SECTION("Default workflow") + { + auto operation = GENERATE(OperationType::Install, OperationType::Show, OperationType::Download, + OperationType::Upgrade, OperationType::Uninstall, OperationType::Repair, OperationType::Export, + OperationType::Pin, OperationType::Search, OperationType::List, OperationType::Completion); + ensureOneMatch = EnsureOneMatchFromSearchResult(operation); + } + SECTION("Context disabled") + { + context.SetFlags(Execution::ContextFlag::DisableInteractivity); + } + SECTION("Argument disabled") + { + context.Args.AddArg(Execution::Args::Type::DisableInteractivity); + } + SECTION("Setting disabled") + { + settings.Set(true); + } + SECTION("Silent with interactivity disabled") + { + context.Args.AddArg(Execution::Args::Type::Silent); + context.Args.AddArg(Execution::Args::Type::DisableInteractivity); + } + SECTION("Redirected streams") + { + context.Reporter.SetConsoleStreamsForTest(false); + } + SECTION("Hidden output") + { + context.Reporter.SetChannel(Execution::Reporter::Channel::Disabled); + } + SECTION("Truncated results") + { + result.Truncated = true; + } + SECTION("No matches") + { + result.Matches.clear(); + expectedError = APPINSTALLER_CLI_ERROR_NO_APPLICATIONS_FOUND; + } + + context.Add(std::move(result)); + context << ensureOneMatch; + INFO(output.str()); + REQUIRE_TERMINATED_WITH(context, expectedError); + REQUIRE_FALSE(context.Contains(Execution::Data::Package)); + REQUIRE(input.peek() == '2'); + REQUIRE(output.str().find(Resource::String::NumberedSelectionPrompt(2).get()) == std::string::npos); +} + TEST_CASE("PromptFlow_InteractivityDisabled", "[PromptFlow][workflow]") { TestCommon::TempFile installResultPath("TestExeInstalled.txt"); diff --git a/src/AppInstallerCLITests/TableOutput.cpp b/src/AppInstallerCLITests/TableOutput.cpp index 2d5480083f..cbe1b0b1e1 100644 --- a/src/AppInstallerCLITests/TableOutput.cpp +++ b/src/AppInstallerCLITests/TableOutput.cpp @@ -19,6 +19,126 @@ namespace } } +TEST_CASE("TableOutput_DynamicMatchesTyped", "[tableoutput]") +{ + std::ostringstream typedOutput; + std::ostringstream dynamicOutput; + std::istringstream input; + TestHook::SetConsoleWidth_Override widthOverride{ std::optional{GENERATE(size_t{20}, size_t{120})} }; + Reporter typedReporter(typedOutput, input); + Reporter dynamicReporter(dynamicOutput, input); + typedReporter.SetStyle(AppInstaller::Settings::VisualStyle::NoVT); + dynamicReporter.SetStyle(AppInstaller::Settings::VisualStyle::NoVT); + TableOutput<3> typed(typedReporter, { MakeHeader("Name"), MakeHeader("Empty"), MakeHeader("Id") }); + TableOutputBase dynamic(dynamicReporter, { MakeHeader("Name"), MakeHeader("Empty"), MakeHeader("Id") }); + typed.OutputLine({ "LongPackageName", "", "test.id" }); + dynamic.OutputLine({ "LongPackageName", "", "test.id" }); + typed.OutputLine({ "OtherPackageName", "", "other.id" }); + dynamic.OutputLine({ "OtherPackageName", "", "other.id" }); + bool showLineNumbers = GENERATE(false, true); + typed.Complete(showLineNumbers); + dynamic.Complete(showLineNumbers); + + REQUIRE_FALSE(dynamic.IsEmpty()); + REQUIRE(dynamic.GetRowCount() == size_t{2}); + REQUIRE(dynamicOutput.str() == typedOutput.str()); + dynamic.Complete(showLineNumbers); + REQUIRE(dynamicOutput.str() == typedOutput.str()); +} + +TEST_CASE("TableOutput_DynamicInvalidDimensions", "[tableoutput]") +{ + std::ostringstream output; + std::istringstream input; + Reporter reporter(output, input); + REQUIRE_THROWS_HR(TableOutputBase(reporter, {}), E_INVALIDARG); + TableOutputBase table(reporter, { MakeHeader("Name") }); + REQUIRE_THROWS_HR(table.OutputLine({ "Name", "Extra" }), E_INVALIDARG); + REQUIRE(table.IsEmpty()); + REQUIRE(output.str().empty()); +} + +TEST_CASE("TableOutput_RowCount", "[tableoutput]") +{ + std::ostringstream output; + std::istringstream input; + TestHook::SetConsoleWidth_Override widthOverride{ std::optional{120} }; + Reporter reporter(output, input); + TableOutput<2> table(reporter, { MakeHeader("Choice"), MakeHeader("Source") }); + REQUIRE(table.GetRowCount() == size_t{0}); + + table.OutputLine({ "1", "FirstSource" }); + table.OutputLine({ "", "SecondSource" }); + table.OutputLine({ "2", "ThirdSource" }); + table.OutputLine({ "", "" }); + REQUIRE(table.GetRowCount() == size_t{4}); + REQUIRE(output.str().empty()); + + table.Complete(GENERATE(false, true)); + REQUIRE(table.GetRowCount() == size_t{4}); +} + +TEST_CASE("TableOutput_LineNumbers", "[tableoutput]") +{ + std::ostringstream output; + std::ostringstream expectedOutput; + std::istringstream input; + TestHook::SetConsoleWidth_Override widthOverride{ std::optional{GENERATE(size_t{20}, size_t{120})} }; + Reporter reporter(output, input); + Reporter expectedReporter(expectedOutput, input); + reporter.SetStyle(AppInstaller::Settings::VisualStyle::NoVT); + expectedReporter.SetStyle(AppInstaller::Settings::VisualStyle::NoVT); + TableOutput<3> table(reporter, { MakeHeader("Name"), MakeHeader("Empty"), MakeHeader("Source") }); + TableOutput<4> expected(expectedReporter, { MakeHeader("#"), MakeHeader("Name"), MakeHeader("Empty"), MakeHeader("Source") }); + size_t count = GENERATE(size_t{1}, size_t{9}, size_t{10}, size_t{99}, size_t{100}); + for (size_t i = 1; i <= count; ++i) + { + auto number = std::to_string(i); + table.OutputLine({ "Package" + number, "", "Source" }); + expected.OutputLine({ number, "Package" + number, "", "Source" }); + } + + REQUIRE(table.GetRowCount() == count); + REQUIRE(output.str().empty()); + table.Complete(true); + expected.Complete(); + REQUIRE(output.str() == expectedOutput.str()); + REQUIRE(table.GetRowCount() == count); + table.Complete(true); + REQUIRE(output.str() == expectedOutput.str()); +} + +TEST_CASE("TableOutput_LineNumbersNotTruncated", "[tableoutput]") +{ + std::ostringstream output; + std::istringstream input; + TestHook::SetConsoleWidth_Override widthOverride{ std::optional{GENERATE(size_t{1}, size_t{4}, size_t{8})} }; + Reporter reporter(output, input); + reporter.SetStyle(AppInstaller::Settings::VisualStyle::NoVT); + TableOutput<2> table(reporter, { MakeHeader("Name"), MakeHeader("Source") }); + size_t count = GENERATE(size_t{10}, size_t{100}); + + for (size_t i = 0; i < count; ++i) + { + table.OutputLine({ "Package", "Source" }); + } + table.Complete(true); + + std::istringstream lines{ output.str() }; + std::string line; + REQUIRE(static_cast(std::getline(lines, line))); + REQUIRE(line.find('#') == 0); + REQUIRE(static_cast(std::getline(lines, line))); + for (size_t i = 1; i <= count; ++i) + { + REQUIRE(static_cast(std::getline(lines, line))); + auto number = std::to_string(i); + REQUIRE(line.find(number + std::string(std::to_string(count).size() - number.size() + 1, ' ')) == 0); + } + REQUIRE_FALSE(std::getline(lines, line)); + REQUIRE(table.GetRowCount() == count); +} + // Test that all rows are buffered and column widths account for values beyond the first 50 rows. // In the old sizing-buffer design, a row at position 55 with a longer value than any of the // first 50 rows would be truncated. The new design buffers every row so no value is clipped. @@ -107,7 +227,7 @@ TEST_CASE("TableOutput_Empty_ProducesNoOutput", "[tableoutput]") TableOutput<2> table(reporter, { MakeHeader("Name"), MakeHeader("Id") }); REQUIRE(table.IsEmpty()); - table.Complete(); + table.Complete(GENERATE(false, true)); REQUIRE(output.str().empty()); } @@ -199,7 +319,7 @@ TEST_CASE("TableOutput_ManyRowsBuffered", "[tableoutput]") }); } - table.Complete(); + table.Complete(GENERATE(false, true)); REQUIRE_FALSE(table.IsEmpty()); diff --git a/src/AppInstallerCLITests/TestHooks.h b/src/AppInstallerCLITests/TestHooks.h index 38dcb01c6e..396eafc9e1 100644 --- a/src/AppInstallerCLITests/TestHooks.h +++ b/src/AppInstallerCLITests/TestHooks.h @@ -79,6 +79,9 @@ namespace AppInstaller namespace CLI::Execution { void TestHook_SetConsoleWidth_Override(std::optional* value); + + using ReadConsoleFunction = std::function; + void TestHook_SetReadConsole_Override(ReadConsoleFunction* value); } namespace CLI::Workflow @@ -388,6 +391,22 @@ namespace TestHook std::optional m_width; }; + struct SetReadConsole_Override + { + SetReadConsole_Override(AppInstaller::CLI::Execution::ReadConsoleFunction function) : m_function(std::move(function)) + { + AppInstaller::CLI::Execution::TestHook_SetReadConsole_Override(&m_function); + } + + ~SetReadConsole_Override() + { + AppInstaller::CLI::Execution::TestHook_SetReadConsole_Override(nullptr); + } + + private: + AppInstaller::CLI::Execution::ReadConsoleFunction m_function; + }; + struct SetGetFontRegistryRoot_Override { SetGetFontRegistryRoot_Override(std::function function) diff --git a/src/AppInstallerCommonCore/ExperimentalFeature.cpp b/src/AppInstallerCommonCore/ExperimentalFeature.cpp index c814afb965..b59905f0b9 100644 --- a/src/AppInstallerCommonCore/ExperimentalFeature.cpp +++ b/src/AppInstallerCommonCore/ExperimentalFeature.cpp @@ -65,6 +65,8 @@ namespace AppInstaller::Settings return userSettings.Get(); case ExperimentalFeature::Feature::Font: return userSettings.Get(); + case ExperimentalFeature::Feature::InteractivePackageSelection: + return userSettings.Get(); default: THROW_HR(E_UNEXPECTED); } @@ -98,6 +100,8 @@ namespace AppInstaller::Settings return ExperimentalFeature{ "Resume", "resume", "https://aka.ms/winget-settings", Feature::Resume }; case Feature::Font: return ExperimentalFeature{ "Font", "fonts", "https://aka.ms/winget-settings", Feature::Font }; + case Feature::InteractivePackageSelection: + return ExperimentalFeature{ "Interactive Package Selection", "interactivePackageSelection", "https://aka.ms/winget-settings", Feature::InteractivePackageSelection }; default: THROW_HR(E_UNEXPECTED); } diff --git a/src/AppInstallerCommonCore/Public/winget/ExperimentalFeature.h b/src/AppInstallerCommonCore/Public/winget/ExperimentalFeature.h index 2dc097f548..436196cdc3 100644 --- a/src/AppInstallerCommonCore/Public/winget/ExperimentalFeature.h +++ b/src/AppInstallerCommonCore/Public/winget/ExperimentalFeature.h @@ -25,6 +25,7 @@ namespace AppInstaller::Settings DirectMSI = 0x1, Resume = 0x2, Font = 0x4, + InteractivePackageSelection = 0x8, Max, // This MUST always be after all experimental features // Features listed after Max will not be shown with the features command diff --git a/src/AppInstallerCommonCore/Public/winget/UserSettings.h b/src/AppInstallerCommonCore/Public/winget/UserSettings.h index f438d6ed8e..e000c6adda 100644 --- a/src/AppInstallerCommonCore/Public/winget/UserSettings.h +++ b/src/AppInstallerCommonCore/Public/winget/UserSettings.h @@ -102,6 +102,7 @@ namespace AppInstaller::Settings EFDirectMSI, EFResume, EFFonts, + EFInteractivePackageSelection, // Telemetry TelemetryDisable, // Install behavior @@ -194,6 +195,7 @@ namespace AppInstaller::Settings SETTINGMAPPING_SPECIALIZATION(Setting::EFDirectMSI, bool, bool, false, ".experimentalFeatures.directMSI"sv); SETTINGMAPPING_SPECIALIZATION(Setting::EFResume, bool, bool, false, ".experimentalFeatures.resume"sv); SETTINGMAPPING_SPECIALIZATION(Setting::EFFonts, bool, bool, false, ".experimentalFeatures.fonts"sv); + SETTINGMAPPING_SPECIALIZATION(Setting::EFInteractivePackageSelection, bool, bool, false, ".experimentalFeatures.interactivePackageSelection"sv); // Telemetry SETTINGMAPPING_SPECIALIZATION(Setting::TelemetryDisable, bool, bool, false, ".telemetry.disable"sv); // Install behavior diff --git a/src/AppInstallerCommonCore/UserSettings.cpp b/src/AppInstallerCommonCore/UserSettings.cpp index 3fe6bc75a2..5f3074f0e8 100644 --- a/src/AppInstallerCommonCore/UserSettings.cpp +++ b/src/AppInstallerCommonCore/UserSettings.cpp @@ -287,6 +287,7 @@ namespace AppInstaller::Settings WINGET_VALIDATE_PASS_THROUGH(EFDirectMSI) WINGET_VALIDATE_PASS_THROUGH(EFResume) WINGET_VALIDATE_PASS_THROUGH(EFFonts) + WINGET_VALIDATE_PASS_THROUGH(EFInteractivePackageSelection) WINGET_VALIDATE_PASS_THROUGH(AnonymizePathForDisplay) WINGET_VALIDATE_PASS_THROUGH(TelemetryDisable) WINGET_VALIDATE_PASS_THROUGH(InteractivityDisable)