diff --git a/MSStore.CLI.UnitTests/InitCommandUnitTests.cs b/MSStore.CLI.UnitTests/InitCommandUnitTests.cs index 7098121..4ad742d 100644 --- a/MSStore.CLI.UnitTests/InitCommandUnitTests.cs +++ b/MSStore.CLI.UnitTests/InitCommandUnitTests.cs @@ -40,5 +40,145 @@ public async Task InitCommandShouldOpenBrowserIfNotRegistered() BrowserLauncher.Verify(x => x.OpenBrowserAsync("https://partner.microsoft.com/dashboard/registration", true, It.IsAny()), Times.Once); } + + [TestMethod] + public async Task InitCommandShouldFailIfAppIdIsNotFound() + { + AddDefaultFakeAccount(); + AddFakeApps(); + + FakeStorePackagedAPI + .Setup(x => x.GetApplicationAsync(It.IsAny(), It.IsAny())) + .ThrowsAsync(new InvalidOperationException("Not found")); + + var result = await ParseAndInvokeAsync( + [ + "init", + "https://www.microsoft.com/", + "--publish", + "--appId", + "9PN3ABCDEFGZ", + "--verbose" + ], -1); + + result.Error.Should().Contain("Could not retrieve your application. Please make sure you have the correct AppId."); + + FakeStorePackagedAPI.Verify(x => x.GetApplicationsAsync(It.IsAny()), Times.Never); + FakeConsole.Verify( + x => x.SelectionPromptAsync( + It.Is(s => s == "Which application should we use to configure your project?"), + It.IsAny>(), + It.IsAny(), + It.IsAny>(), + It.IsAny()), + Times.Never); + } + + [TestMethod] + public async Task InitCommandShouldFailOnCIIfNoAppIdIsProvided() + { + AddDefaultFakeAccount(); + AddFakeApps(); + + EnvironmentInformationService + .Setup(x => x.IsRunningOnCI) + .Returns(true); + + var result = await ParseAndInvokeAsync( + [ + "init", + "https://www.microsoft.com/", + "--publish", + "--verbose" + ], -1); + + result.Error.Should().Contain("Could not select an application because the current environment is not interactive."); + result.Error.Should().Contain("--appId"); + + FakeConsole.Verify( + x => x.SelectionPromptAsync( + It.Is(s => s == "Which application should we use to configure your project?"), + It.IsAny>(), + It.IsAny(), + It.IsAny>(), + It.IsAny()), + Times.Never); + } + + [TestMethod] + public async Task InitCommandShouldAutoSelectOnCIIfAccountHasASingleApp() + { + AddDefaultFakeAccount(); + AddFakeApps(); + + EnvironmentInformationService + .Setup(x => x.IsRunningOnCI) + .Returns(true); + + FakeStorePackagedAPI + .Setup(x => x.GetApplicationsAsync(It.IsAny())) + .ReturnsAsync([FakeApps[0]]); + + var result = await ParseAndInvokeAsync( + [ + "init", + "https://www.microsoft.com/", + "--output", + Path.GetTempPath(), + "--verbose" + ]); + + // Asserted piecewise because the app name and id are wrapped in markup, + // which becomes ANSI escape sequences when the console supports them. + result.Error.Should().Contain(FakeApps[0].PrimaryName!); + result.Error.Should().Contain(FakeApps[0].Id!); + result.Error.Should().Contain(", the only application registered in your account."); + result.Error.Should().NotContain("--appId"); + + FakeConsole.Verify( + x => x.SelectionPromptAsync( + It.Is(s => s == "Which application should we use to configure your project?"), + It.IsAny>(), + It.IsAny(), + It.IsAny>(), + It.IsAny()), + Times.Never); + } + + [TestMethod] + public async Task InitCommandShouldFailIfNotOnCIAndPromptIsNotSupported() + { + AddDefaultFakeAccount(); + AddFakeApps(); + + FakeConsole + .Setup(x => x.SelectionPromptAsync( + It.Is(s => s == "Which application should we use to configure your project?"), + It.IsAny>(), + It.IsAny(), + It.IsAny>(), + It.IsAny())) + .ThrowsAsync(new NotSupportedException("Cannot show selection prompt since the current terminal isn't interactive.")); + + var result = await ParseAndInvokeAsync( + [ + "init", + "https://www.microsoft.com/", + "--publish", + "--verbose" + ], -1); + + result.Error.Should().Contain("Could not select an application because the current environment is not interactive."); + result.Error.Should().Contain("--appId"); + + FakeConsole.Verify( + x => x.SelectionPromptAsync( + It.Is(s => s == "Which application should we use to configure your project?"), + It.IsAny>(), + It.IsAny(), + It.IsAny>(), + It.IsAny()), + Times.Once); + } } } \ No newline at end of file diff --git a/MSStore.CLI.UnitTests/ProjectConfiguratorTests.cs b/MSStore.CLI.UnitTests/ProjectConfiguratorTests.cs index 5d282dc..16ff66d 100644 --- a/MSStore.CLI.UnitTests/ProjectConfiguratorTests.cs +++ b/MSStore.CLI.UnitTests/ProjectConfiguratorTests.cs @@ -1,6 +1,8 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. +using System.Text.RegularExpressions; + using MSStore.CLI.Services.PWABuilder; namespace MSStore.CLI.UnitTests @@ -20,12 +22,13 @@ public void Init() private static (string Output, string Error) CleanResult((string Output, string Error) result) { + static string Clean(string value) => Regex.Replace(value, @"\x1B\[[0-9;]*[A-Za-z]", string.Empty) + .Replace(Environment.NewLine, " ") + .Replace(" ", " "); + return ( - Output: result.Output - .Replace(Environment.NewLine, " ") - .Replace(" ", " "), - Error: result.Error.Replace(Environment.NewLine, " ") - .Replace(" ", " ")); + Output: Clean(result.Output), + Error: Clean(result.Error)); } [TestMethod] @@ -660,6 +663,8 @@ public async Task ProjectConfiguratorParsesPWASuccessfullyIfOnCIIfPublish() "init", "https://microsoft.com", "--publish", + "--appId", + FakeApps[0].Id!, "--verbose" ]); @@ -724,5 +729,73 @@ public async Task ProjectConfiguratorParserPWAShouldNotCallPartnerCenterAPIIfPub result.Error.Should().Contain("You've provided a URL, so we'll use"); result.Error.Should().Contain("Submission commit success!"); } + + [TestMethod] + public async Task ProjectConfiguratorParsesPWASuccessfullyOnCIIfAppIdIsProvided() + { + SetupSuccessfullPWA(true); + + var result = await ParseAndInvokeAsync( + [ + "init", + "https://microsoft.com", + "--publisherDisplayName", + "FAKE_PUBLISHER_DISPLAY_NAME", + "--publish", + "-id", + FakeApps[1].Id!, + "--verbose" + ]); + + result = CleanResult(result); + + result.Error.Should().Contain("You've provided a URL, so we'll use"); + result.Error.Should().Contain($"AppId: {FakeApps[1].Id}"); + result.Error.Should().Contain("Submission commit success!"); + + FakeStorePackagedAPI.Verify(x => x.GetApplicationsAsync(It.IsAny()), Times.Never); + FakeConsole.Verify( + x => x.SelectionPromptAsync( + It.Is(s => s == "Which application should we use to configure your project?"), + It.IsAny>(), + It.IsAny(), + It.IsAny>(), + It.IsAny()), + Times.Never); + } + + [TestMethod] + public async Task ProjectConfiguratorParsesPWASuccessfullyOnCIIfAccountHasASingleApp() + { + SetupSuccessfullPWA(true); + + FakeStorePackagedAPI + .Setup(x => x.GetApplicationsAsync(It.IsAny())) + .ReturnsAsync([FakeApps[0]]); + + var result = await ParseAndInvokeAsync( + [ + "init", + "https://microsoft.com", + "--publisherDisplayName", + "FAKE_PUBLISHER_DISPLAY_NAME", + "--publish", + "--verbose" + ]); + + result = CleanResult(result); + + result.Error.Should().Contain($"Using {FakeApps[0].PrimaryName} ({FakeApps[0].Id}), the only application registered in your account."); + result.Error.Should().Contain("Submission commit success!"); + + FakeConsole.Verify( + x => x.SelectionPromptAsync( + It.Is(s => s == "Which application should we use to configure your project?"), + It.IsAny>(), + It.IsAny(), + It.IsAny>(), + It.IsAny()), + Times.Never); + } } } diff --git a/MSStore.CLI/Commands/InitCommand.cs b/MSStore.CLI/Commands/InitCommand.cs index 29747a5..9801f6e 100644 --- a/MSStore.CLI/Commands/InitCommand.cs +++ b/MSStore.CLI/Commands/InitCommand.cs @@ -28,6 +28,7 @@ internal class InitCommand : Command { internal static readonly Argument PathOrUrlArgument; private static readonly Option PublisherDisplayNameOption; + private static readonly Option AppIdOption; private static readonly Option PackageOption; private static readonly Option PublishOption; internal static readonly Option OutputOption; @@ -78,6 +79,11 @@ bool IsUri() Description = "The Publisher Display Name used to configure the application. If provided, avoids an extra APIs call." }; + AppIdOption = new Option("--appId", "-id") + { + Description = "Specifies the Application Id to configure the project with. If not provided, the application is selected interactively, which is not possible on CI/CD environments, unless the account has a single application." + }; + PackageOption = new Option("--package") { Description = "If supported by the app type, automatically packs the project." @@ -121,6 +127,7 @@ public InitCommand() { Arguments.Add(PathOrUrlArgument); Options.Add(PublisherDisplayNameOption); + Options.Add(AppIdOption); Options.Add(PackageOption); Options.Add(PublishOption); Options.Add(PublishCommand.FlightIdOption); @@ -141,6 +148,7 @@ public class Handler( IPartnerCenterManager partnerCenterManager, IImageConverter imageConverter, IConfigurationManager configurationManager, + IEnvironmentInformationService environmentInformationService, IAnsiConsole ansiConsole, TelemetryClient telemetryClient) : AsynchronousCommandLineAction { @@ -153,6 +161,7 @@ public class Handler( private readonly IPartnerCenterManager _partnerCenterManager = partnerCenterManager ?? throw new ArgumentNullException(nameof(partnerCenterManager)); private readonly IImageConverter _imageConverter = imageConverter ?? throw new ArgumentNullException(nameof(imageConverter)); private readonly IConfigurationManager _configurationManager = configurationManager ?? throw new ArgumentNullException(nameof(configurationManager)); + private readonly IEnvironmentInformationService _environmentInformationService = environmentInformationService ?? throw new ArgumentNullException(nameof(environmentInformationService)); private readonly IAnsiConsole _ansiConsole = ansiConsole ?? throw new ArgumentNullException(nameof(ansiConsole)); private readonly TelemetryClient _telemetryClient = telemetryClient ?? throw new ArgumentNullException(nameof(telemetryClient)); @@ -160,6 +169,7 @@ public override async Task InvokeAsync(ParseResult parseResult, Cancellatio { var pathOrUrl = parseResult.GetRequiredValue(PathOrUrlArgument); var publisherDisplayName = parseResult.GetValue(PublisherDisplayNameOption); + var appId = parseResult.GetValue(AppIdOption); var package = parseResult.GetValue(PackageOption); var publish = parseResult.GetValue(PublishOption); var flightId = parseResult.GetValue(PublishCommand.FlightIdOption); @@ -176,6 +186,9 @@ public override async Task InvokeAsync(ParseResult parseResult, Cancellatio { "withPDN", (publisherDisplayName != null).ToString() }, + { + "withAppId", (!string.IsNullOrEmpty(appId)).ToString() + }, { "Package", (package == true).ToString() }, @@ -273,7 +286,9 @@ public override async Task InvokeAsync(ParseResult parseResult, Cancellatio var storePackagedAPI = await _storeAPIFactory.CreatePackagedAsync(ct: ct); - var app = await SelectAppAsync(storePackagedAPI, ct); + var app = string.IsNullOrEmpty(appId) + ? await SelectAppAsync(storePackagedAPI, ct) + : await GetAppByIdAsync(storePackagedAPI, appId, ct); if (app == null || string.IsNullOrEmpty(app.Id)) { return await _telemetryClient.TrackCommandEventAsync(-1, props, ct); @@ -354,6 +369,30 @@ public override async Task InvokeAsync(ParseResult parseResult, Cancellatio return await _telemetryClient.TrackCommandEventAsync(result, props, ct); } + private async Task GetAppByIdAsync(IStorePackagedAPI storePackagedAPI, string appId, CancellationToken ct) + { + return await _ansiConsole.Status().StartAsync("Retrieving application...", async ctx => + { + try + { + var app = await storePackagedAPI.GetApplicationAsync(appId, ct); + + ctx.SuccessStatus(_ansiConsole, "Ok! Found the app!"); + return app; + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + throw; + } + catch (Exception err) + { + ctx.ErrorStatus(_ansiConsole, "Could not retrieve your application. Please make sure you have the correct AppId."); + _logger.LogError(err, "Could not find application with id '{AppId}'.", appId); + return null; + } + }); + } + private async Task SelectAppAsync(IStorePackagedAPI storePackagedAPI, CancellationToken ct) { var appList = await GetAppListAsync(storePackagedAPI, ct); @@ -370,6 +409,19 @@ public override async Task InvokeAsync(ParseResult parseResult, Cancellatio return await CreateNewAppAsync(ct); } + if (_environmentInformationService.IsRunningOnCI) + { + if (appList.Count == 1) + { + var singleApp = appList[0]; + _ansiConsole.MarkupLine($"Using [green bold]{singleApp.PrimaryName?.EscapeMarkup()}[/] ([green bold]{singleApp.Id}[/]), the only application registered in your account."); + return singleApp; + } + + WriteNonInteractiveError(); + return null; + } + var newAppOption = "Create a new app..."; var appNames = appList.Select(app => app.PrimaryName!).ToList(); @@ -378,16 +430,34 @@ public override async Task InvokeAsync(ParseResult parseResult, Cancellatio appNames.Add(newAppOption); */ - var selectedApp = await _consoleReader.SelectionPromptAsync( - "Which application should we use to configure your project?", - appNames, - ct: ct); + string selectedApp; + try + { + selectedApp = await _consoleReader.SelectionPromptAsync( + "Which application should we use to configure your project?", + appNames, + ct: ct); + } + catch (NotSupportedException err) + { + _logger.LogError(err, "Could not show the application selection prompt."); + WriteNonInteractiveError(); + return null; + } return selectedApp == newAppOption ? await CreateNewAppAsync(ct) : appList.FirstOrDefault(app => app.PrimaryName == selectedApp); } + private void WriteNonInteractiveError() + { + _ansiConsole.MarkupLine(":collision: [bold red]Could not select an application because the current environment is not interactive.[/]"); + _ansiConsole.MarkupLine("Use the '[bold]--appId[/]' option to specify which application should be used, for example: '[bold]msstore init --appId 9PXXXXXXXXXX[/]'."); + _ansiConsole.MarkupLine("You can list the applications registered in your account with '[bold]msstore apps list[/]'."); + _logger.LogError("Could not select an application because the current environment is not interactive. Use the '--appId' option to specify which application should be used."); + } + private Task CreateNewAppAsync(CancellationToken ct) { throw new NotImplementedException("App name reservation is not implemented yet.");