Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 18 additions & 1 deletion src/Aspire.Cli/Commands/DashboardRunCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ namespace Aspire.Cli.Commands;
/// </summary>
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;
Expand Down Expand Up @@ -83,7 +85,7 @@ public DashboardRunCommand(

protected override async Task<CommandResult> 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)
{
Expand Down Expand Up @@ -140,6 +142,21 @@ protected override async Task<CommandResult> ExecuteAsync(ParseResult parseResul
return await ExecuteForegroundAsync(managedPath, dashboardArgs, dashboardInfo, environmentVariables, cancellationToken).ConfigureAwait(false);
}

private async Task<BundleLayoutLease?> 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<string> args, IReadOnlyList<string> unmatchedTokens, IEnvironment environment)
{
AddStringOptionArg(parseResult, args, unmatchedTokens, environment, s_frontendUrlOption, KnownAspNetCoreConfigNames.Urls, defaultValue: "http://localhost:18888");
Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions src/Aspire.Cli/Resources/DashboardCommandStrings.resx
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,9 @@
<data name="StartingDashboard" xml:space="preserve">
<value>Starting dashboard...</value>
</data>
<data name="EnsuringDashboardBundle" xml:space="preserve">
<value>Preparing dashboard bits...</value>
</data>
<data name="DashboardStartTimedOut" xml:space="preserve">
<value>Dashboard did not become ready within the expected time.</value>
</data>
Expand Down
5 changes: 5 additions & 0 deletions src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.cs.xlf

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.de.xlf

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.es.xlf

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.fr.xlf

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.it.xlf

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.ja.xlf

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.ko.xlf

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.pl.xlf

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.ru.xlf

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions src/Aspire.Cli/Resources/xlf/DashboardCommandStrings.tr.xlf

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

63 changes: 61 additions & 2 deletions tests/Aspire.Cli.Tests/Commands/DashboardRunCommandTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<RootCommand>();
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<RootCommand>();
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()
{
Expand Down Expand Up @@ -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");
Expand All @@ -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
{
Expand All @@ -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)
{
Expand Down
20 changes: 16 additions & 4 deletions tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -798,16 +798,28 @@ internal sealed class TestBundleService(bool isBundle) : IBundleService

public Func<CancellationToken, Task>? EnsureExtractedAsyncCallback { get; set; }

public Func<CancellationToken, Task>? EnsureExtractedAndAcquireLayoutAsyncCallback { get; set; }

public Task EnsureExtractedAsync(CancellationToken cancellationToken = default)
=> EnsureExtractedAsyncCallback?.Invoke(cancellationToken) ?? Task.CompletedTask;

public Task<BundleExtractResult> ExtractAsync(string destinationPath, bool force = false, CancellationToken cancellationToken = default)
=> Task.FromResult(isBundle ? BundleExtractResult.AlreadyUpToDate : BundleExtractResult.NoPayload);

public Task<BundleLayoutLease?> EnsureExtractedAndAcquireLayoutAsync(string holderKind, string? commandName = null, CancellationToken cancellationToken = default)
=> EnsureExtractedException is not null
? Task.FromException<BundleLayoutLease?>(EnsureExtractedException)
: Task.FromResult(Layout is null ? null : new BundleLayoutLease(Layout, lease: null));
public async Task<BundleLayoutLease?> 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;
}
Expand Down
Loading