diff --git a/src/Aspire.Cli/Commands/DashboardRunCommand.cs b/src/Aspire.Cli/Commands/DashboardRunCommand.cs index 2fb506ee86f..1b46ac6f199 100644 --- a/src/Aspire.Cli/Commands/DashboardRunCommand.cs +++ b/src/Aspire.Cli/Commands/DashboardRunCommand.cs @@ -21,6 +21,8 @@ namespace Aspire.Cli.Commands; /// internal sealed class DashboardRunCommand : BaseCommand { + private static readonly TimeSpan s_bundleStatusDelay = TimeSpan.FromMilliseconds(200); + internal override HelpGroup HelpGroup => HelpGroup.Monitoring; protected override bool UpdateNotificationsEnabled => true; @@ -83,7 +85,7 @@ public DashboardRunCommand( protected override async Task ExecuteAsync(ParseResult parseResult, CancellationToken cancellationToken) { - using var layoutLease = await _bundleService.EnsureExtractedAndAcquireLayoutAsync("cli", "dashboard", cancellationToken).ConfigureAwait(false); + using var layoutLease = await EnsureDashboardBundleAsync(cancellationToken).ConfigureAwait(false); var layout = layoutLease?.Layout; if (layout is null) { @@ -140,6 +142,21 @@ protected override async Task ExecuteAsync(ParseResult parseResul return await ExecuteForegroundAsync(managedPath, dashboardArgs, dashboardInfo, environmentVariables, cancellationToken).ConfigureAwait(false); } + private async Task EnsureDashboardBundleAsync(CancellationToken cancellationToken) + { + var layoutTask = _bundleService.EnsureExtractedAndAcquireLayoutAsync("cli", "dashboard", cancellationToken); + + // Cached bundle acquisition normally completes quickly, so wait briefly before showing a status to avoid flicker during typical usage. + if (await Task.WhenAny(layoutTask, Task.Delay(s_bundleStatusDelay, CancellationToken.None)).ConfigureAwait(false) == layoutTask) + { + return await layoutTask.ConfigureAwait(false); + } + + return await InteractionService.ShowStatusAsync( + DashboardCommandStrings.EnsuringDashboardBundle, + () => layoutTask).ConfigureAwait(false); + } + private static void AddOptionArgs(ParseResult parseResult, List args, IReadOnlyList unmatchedTokens, IEnvironment environment) { AddStringOptionArg(parseResult, args, unmatchedTokens, environment, s_frontendUrlOption, KnownAspNetCoreConfigNames.Urls, defaultValue: "http://localhost:18888"); diff --git a/src/Aspire.Cli/Resources/DashboardCommandStrings.Designer.cs b/src/Aspire.Cli/Resources/DashboardCommandStrings.Designer.cs index 2c3d133d1df..7eec55423c2 100644 --- a/src/Aspire.Cli/Resources/DashboardCommandStrings.Designer.cs +++ b/src/Aspire.Cli/Resources/DashboardCommandStrings.Designer.cs @@ -93,6 +93,12 @@ public static string StartingDashboard { } } + public static string EnsuringDashboardBundle { + get { + return ResourceManager.GetString("EnsuringDashboardBundle", resourceCulture); + } + } + public static string DashboardStartTimedOut { get { return ResourceManager.GetString("DashboardStartTimedOut", resourceCulture); diff --git a/src/Aspire.Cli/Resources/DashboardCommandStrings.resx b/src/Aspire.Cli/Resources/DashboardCommandStrings.resx index 8980e3db482..ca8a6a5bd07 100644 --- a/src/Aspire.Cli/Resources/DashboardCommandStrings.resx +++ b/src/Aspire.Cli/Resources/DashboardCommandStrings.resx @@ -141,6 +141,9 @@ Starting dashboard... + + Preparing dashboard bits... + Dashboard did not become ready within the expected time. diff --git a/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.cs.xlf b/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.cs.xlf index a0bf40721eb..622b1e2e1c4 100644 --- a/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.cs.xlf +++ b/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.cs.xlf @@ -67,6 +67,11 @@ Manage the Aspire dashboard (Preview) + + Preparing dashboard bits... + Preparing dashboard bits... + + One or more HTTP endpoints through which the dashboard frontend is served One or more HTTP endpoints through which the dashboard frontend is served diff --git a/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.de.xlf b/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.de.xlf index be5c1dfe154..1c1bae874e0 100644 --- a/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.de.xlf +++ b/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.de.xlf @@ -67,6 +67,11 @@ Manage the Aspire dashboard (Preview) + + Preparing dashboard bits... + Preparing dashboard bits... + + One or more HTTP endpoints through which the dashboard frontend is served One or more HTTP endpoints through which the dashboard frontend is served diff --git a/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.es.xlf b/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.es.xlf index 29f803eaede..d6053101689 100644 --- a/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.es.xlf +++ b/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.es.xlf @@ -67,6 +67,11 @@ Manage the Aspire dashboard (Preview) + + Preparing dashboard bits... + Preparing dashboard bits... + + One or more HTTP endpoints through which the dashboard frontend is served One or more HTTP endpoints through which the dashboard frontend is served diff --git a/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.fr.xlf b/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.fr.xlf index 7f284cdd6ae..b7bfd5860d0 100644 --- a/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.fr.xlf +++ b/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.fr.xlf @@ -67,6 +67,11 @@ Manage the Aspire dashboard (Preview) + + Preparing dashboard bits... + Preparing dashboard bits... + + One or more HTTP endpoints through which the dashboard frontend is served One or more HTTP endpoints through which the dashboard frontend is served diff --git a/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.it.xlf b/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.it.xlf index c347a854d30..5ce223ccea0 100644 --- a/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.it.xlf +++ b/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.it.xlf @@ -67,6 +67,11 @@ Manage the Aspire dashboard (Preview) + + Preparing dashboard bits... + Preparing dashboard bits... + + One or more HTTP endpoints through which the dashboard frontend is served One or more HTTP endpoints through which the dashboard frontend is served diff --git a/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.ja.xlf b/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.ja.xlf index cbefc65051b..e60d3b7b420 100644 --- a/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.ja.xlf +++ b/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.ja.xlf @@ -67,6 +67,11 @@ Manage the Aspire dashboard (Preview) + + Preparing dashboard bits... + Preparing dashboard bits... + + One or more HTTP endpoints through which the dashboard frontend is served One or more HTTP endpoints through which the dashboard frontend is served diff --git a/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.ko.xlf b/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.ko.xlf index 8d94679f91d..cac5f89e8c4 100644 --- a/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.ko.xlf +++ b/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.ko.xlf @@ -67,6 +67,11 @@ Manage the Aspire dashboard (Preview) + + Preparing dashboard bits... + Preparing dashboard bits... + + One or more HTTP endpoints through which the dashboard frontend is served One or more HTTP endpoints through which the dashboard frontend is served diff --git a/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.pl.xlf b/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.pl.xlf index 89adb78c19a..1dcdfcde143 100644 --- a/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.pl.xlf +++ b/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.pl.xlf @@ -67,6 +67,11 @@ Manage the Aspire dashboard (Preview) + + Preparing dashboard bits... + Preparing dashboard bits... + + One or more HTTP endpoints through which the dashboard frontend is served One or more HTTP endpoints through which the dashboard frontend is served diff --git a/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.pt-BR.xlf b/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.pt-BR.xlf index 3f0c68537df..59b7e57ab01 100644 --- a/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.pt-BR.xlf +++ b/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.pt-BR.xlf @@ -67,6 +67,11 @@ Manage the Aspire dashboard (Preview) + + Preparing dashboard bits... + Preparing dashboard bits... + + One or more HTTP endpoints through which the dashboard frontend is served One or more HTTP endpoints through which the dashboard frontend is served diff --git a/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.ru.xlf b/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.ru.xlf index bd962f3aae3..88f7f7162ac 100644 --- a/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.ru.xlf +++ b/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.ru.xlf @@ -67,6 +67,11 @@ Manage the Aspire dashboard (Preview) + + Preparing dashboard bits... + Preparing dashboard bits... + + One or more HTTP endpoints through which the dashboard frontend is served One or more HTTP endpoints through which the dashboard frontend is served diff --git a/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.tr.xlf b/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.tr.xlf index 9ba06488e3a..c70d48db2f1 100644 --- a/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.tr.xlf +++ b/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.tr.xlf @@ -67,6 +67,11 @@ Manage the Aspire dashboard (Preview) + + Preparing dashboard bits... + Preparing dashboard bits... + + One or more HTTP endpoints through which the dashboard frontend is served One or more HTTP endpoints through which the dashboard frontend is served diff --git a/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.zh-Hans.xlf b/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.zh-Hans.xlf index a90ec95d60f..6f5f77b0dd3 100644 --- a/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.zh-Hans.xlf +++ b/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.zh-Hans.xlf @@ -67,6 +67,11 @@ Manage the Aspire dashboard (Preview) + + Preparing dashboard bits... + Preparing dashboard bits... + + One or more HTTP endpoints through which the dashboard frontend is served One or more HTTP endpoints through which the dashboard frontend is served diff --git a/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.zh-Hant.xlf b/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.zh-Hant.xlf index abad5d743de..976480d845a 100644 --- a/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.zh-Hant.xlf +++ b/src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.zh-Hant.xlf @@ -67,6 +67,11 @@ Manage the Aspire dashboard (Preview) + + Preparing dashboard bits... + Preparing dashboard bits... + + One or more HTTP endpoints through which the dashboard frontend is served One or more HTTP endpoints through which the dashboard frontend is served diff --git a/tests/Aspire.Cli.Tests/Commands/DashboardRunCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/DashboardRunCommandTests.cs index 47ee1662287..cae8f2a1d40 100644 --- a/tests/Aspire.Cli.Tests/Commands/DashboardRunCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/DashboardRunCommandTests.cs @@ -147,6 +147,62 @@ public async Task DashboardRunCommand_DefaultOptions_DoesNotEmitAllowAnonymous() Assert.DoesNotContain(capturedArgs, arg => arg.Contains("ASPIRE_DASHBOARD_UNSECURED_ALLOW_ANONYMOUS")); } + [Fact] + public async Task DashboardRunCommand_BundleAvailableWithinDelay_DoesNotDisplayBundleStatus() + { + using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); + + var testInteractionService = new TestInteractionService(); + var (services, _, _) = CreateServicesWithLayout(workspace, interactionService: testInteractionService); + + using var provider = services.BuildServiceProvider(); + var command = provider.GetRequiredService(); + var result = command.Parse("dashboard run"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(CliExitCodes.Success, exitCode); + Assert.Equal([DashboardCommandStrings.StartingDashboard], testInteractionService.ShownStatuses); + } + + [Fact] + public async Task DashboardRunCommand_BundleUnavailableAfterDelay_DisplaysBundleStatus() + { + using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); + + var releaseBundle = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var bundleStatusDisplayed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var testInteractionService = new TestInteractionService + { + ShowStatusCallback = status => + { + if (status == DashboardCommandStrings.EnsuringDashboardBundle) + { + bundleStatusDisplayed.TrySetResult(); + } + } + }; + var bundleService = new TestBundleService(isBundle: true) + { + EnsureExtractedAndAcquireLayoutAsyncCallback = cancellationToken => releaseBundle.Task.WaitAsync(cancellationToken) + }; + var (services, _, _) = CreateServicesWithLayout(workspace, testInteractionService, bundleService); + + using var provider = services.BuildServiceProvider(); + var command = provider.GetRequiredService(); + var result = command.Parse("dashboard run"); + + var pendingRun = result.InvokeAsync(); + await bundleStatusDisplayed.Task.DefaultTimeout(); + releaseBundle.TrySetResult(); + var exitCode = await pendingRun.DefaultTimeout(); + + Assert.Equal(CliExitCodes.Success, exitCode); + Assert.Equal( + [DashboardCommandStrings.EnsuringDashboardBundle, DashboardCommandStrings.StartingDashboard], + testInteractionService.ShownStatuses); + } + [Fact] public async Task DashboardRunCommand_DefaultOptions_PassesDefaultArgsToProcess() { @@ -540,7 +596,8 @@ public void RenderDashboardSummary_RendersLogsPathAsClickableFileLink() private (IServiceCollection Services, string ManagedPath, TestProcessExecutionFactory ExecutionFactory) CreateServicesWithLayout( TemporaryWorkspace workspace, - TestInteractionService? interactionService = null) + TestInteractionService? interactionService = null, + TestBundleService? bundleService = null) { var layoutDir = Path.Combine(workspace.WorkspaceRoot.FullName, "layout"); var managedDir = Path.Combine(layoutDir, "managed"); @@ -553,6 +610,8 @@ public void RenderDashboardSummary_RendersLogsPathAsClickableFileLink() LayoutPath = layoutDir, Components = new LayoutComponents { Managed = "managed" } }; + bundleService ??= new TestBundleService(isBundle: true); + bundleService.Layout = layout; var executionFactory = new TestProcessExecutionFactory { @@ -562,7 +621,7 @@ public void RenderDashboardSummary_RendersLogsPathAsClickableFileLink() var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => { options.LayoutDiscoveryFactory = _ => new FakeLayoutDiscovery(layout); - options.BundleServiceFactory = _ => new TestBundleService(true) { Layout = layout }; + options.BundleServiceFactory = _ => bundleService; options.DotNetCliExecutionFactoryFactory = _ => executionFactory; if (interactionService is not null) { diff --git a/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs b/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs index 1f2deaa386a..fa9cbe391f5 100644 --- a/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs +++ b/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs @@ -798,16 +798,28 @@ internal sealed class TestBundleService(bool isBundle) : IBundleService public Func? EnsureExtractedAsyncCallback { get; set; } + public Func? EnsureExtractedAndAcquireLayoutAsyncCallback { get; set; } + public Task EnsureExtractedAsync(CancellationToken cancellationToken = default) => EnsureExtractedAsyncCallback?.Invoke(cancellationToken) ?? Task.CompletedTask; public Task ExtractAsync(string destinationPath, bool force = false, CancellationToken cancellationToken = default) => Task.FromResult(isBundle ? BundleExtractResult.AlreadyUpToDate : BundleExtractResult.NoPayload); - public Task EnsureExtractedAndAcquireLayoutAsync(string holderKind, string? commandName = null, CancellationToken cancellationToken = default) - => EnsureExtractedException is not null - ? Task.FromException(EnsureExtractedException) - : Task.FromResult(Layout is null ? null : new BundleLayoutLease(Layout, lease: null)); + public async Task EnsureExtractedAndAcquireLayoutAsync(string holderKind, string? commandName = null, CancellationToken cancellationToken = default) + { + if (EnsureExtractedException is not null) + { + throw EnsureExtractedException; + } + + if (EnsureExtractedAndAcquireLayoutAsyncCallback is not null) + { + await EnsureExtractedAndAcquireLayoutAsyncCallback(cancellationToken); + } + + return Layout is null ? null : new BundleLayoutLease(Layout, lease: null); + } public string? GetDefaultExtractDir(string processPath) => null; }