diff --git a/.github/actions/spelling/allow.txt b/.github/actions/spelling/allow.txt index 24088f8881..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 @@ -80,6 +81,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 11ee297b11..151f337e5b 100644 --- a/doc/ReleaseNotes.md +++ b/doc/ReleaseNotes.md @@ -2,6 +2,16 @@ ## 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. + ### `--output-locale` argument Added a new `--output-locale` argument that overrides the language used for WinGet's own output for a single invocation. @@ -36,6 +46,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/doc/Settings.md b/doc/Settings.md index 50db6cc830..269a3d029e 100644 --- a/doc/Settings.md +++ b/doc/Settings.md @@ -454,14 +454,14 @@ This feature enables support for fonts via `winget settings`. The `winget font l }, ``` -### sourcePriority +### interactivePackageSelection -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). +Enables numbered choices for ambiguous single-package `install`, `show`, and `download` commands, including `show --versions`. Disabled by default. ```json - "experimentalFeatures": { - "sourcePriority": true - }, + "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/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..e162aaac12 100644 --- a/schemas/JSON/settings/settings.schema.0.2.json +++ b/schemas/JSON/settings/settings.schema.0.2.json @@ -339,13 +339,13 @@ "type": "boolean", "default": false }, - "resume": { - "description": "Enable support for some commands to resume", + "interactivePackageSelection": { + "description": "Enable interactive selection for ambiguous package matches", "type": "boolean", "default": false }, - "sourcePriority": { - "description": "Enable source priority feature", + "resume": { + "description": "Enable support for some commands to resume", "type": "boolean", "default": false } 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/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/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/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/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/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/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 4641d99359..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(); @@ -1358,8 +1353,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; @@ -1397,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/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/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/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/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/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/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/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 1bea3bbc92..d6f4084c26 100644 --- a/src/AppInstallerCLITests/SourceFlow.cpp +++ b/src/AppInstallerCLITests/SourceFlow.cpp @@ -4,6 +4,10 @@ #include "WorkflowCommon.h" #include "TestHooks.h" #include "TestSettings.h" +#include "TestRestRequestHandler.h" +#include +#include +#include #include #include #include @@ -14,6 +18,297 @@ 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("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; + 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/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/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/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/ExperimentalFeature.cpp b/src/AppInstallerCommonCore/ExperimentalFeature.cpp index dd659448a8..b59905f0b9 100644 --- a/src/AppInstallerCommonCore/ExperimentalFeature.cpp +++ b/src/AppInstallerCommonCore/ExperimentalFeature.cpp @@ -65,8 +65,8 @@ namespace AppInstaller::Settings return userSettings.Get(); case ExperimentalFeature::Feature::Font: return userSettings.Get(); - case ExperimentalFeature::Feature::SourcePriority: - return userSettings.Get(); + case ExperimentalFeature::Feature::InteractivePackageSelection: + return userSettings.Get(); default: THROW_HR(E_UNEXPECTED); } @@ -100,8 +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::SourcePriority: - return ExperimentalFeature{ "Source Priority", "sourcePriority", "https://aka.ms/winget-settings", Feature::SourcePriority }; + 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/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/ExperimentalFeature.h b/src/AppInstallerCommonCore/Public/winget/ExperimentalFeature.h index 42559317ef..436196cdc3 100644 --- a/src/AppInstallerCommonCore/Public/winget/ExperimentalFeature.h +++ b/src/AppInstallerCommonCore/Public/winget/ExperimentalFeature.h @@ -25,7 +25,7 @@ namespace AppInstaller::Settings DirectMSI = 0x1, Resume = 0x2, Font = 0x4, - SourcePriority = 0x8, + 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/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/AppInstallerCommonCore/Public/winget/UserSettings.h b/src/AppInstallerCommonCore/Public/winget/UserSettings.h index 52fcf2c46e..e000c6adda 100644 --- a/src/AppInstallerCommonCore/Public/winget/UserSettings.h +++ b/src/AppInstallerCommonCore/Public/winget/UserSettings.h @@ -102,7 +102,7 @@ namespace AppInstaller::Settings EFDirectMSI, EFResume, EFFonts, - EFSourcePriority, + EFInteractivePackageSelection, // Telemetry TelemetryDisable, // Install behavior @@ -195,7 +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::EFSourcePriority, bool, bool, false, ".experimentalFeatures.sourcePriority"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 04a293c78b..5f3074f0e8 100644 --- a/src/AppInstallerCommonCore/UserSettings.cpp +++ b/src/AppInstallerCommonCore/UserSettings.cpp @@ -287,7 +287,7 @@ 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(EFInteractivePackageSelection) WINGET_VALIDATE_PASS_THROUGH(AnonymizePathForDisplay) WINGET_VALIDATE_PASS_THROUGH(TelemetryDisable) WINGET_VALIDATE_PASS_THROUGH(InteractivityDisable) 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/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/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) 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'