diff --git a/src/Aspire.Cli/Commands/RunCommand.cs b/src/Aspire.Cli/Commands/RunCommand.cs index 1498db7e84e..1ff3a5e67c2 100644 --- a/src/Aspire.Cli/Commands/RunCommand.cs +++ b/src/Aspire.Cli/Commands/RunCommand.cs @@ -165,22 +165,46 @@ protected override async Task ExecuteAsync(ParseResult parseResult, Cancell _interactionService.DisplayDashboardUrls(dashboardUrls); var table = new Table().Border(TableBorder.Rounded); + + // Add columns + table.AddColumn("Resource"); + table.AddColumn("Type"); + table.AddColumn("State"); + table.AddColumn("Health"); + table.AddColumn("Endpoint(s)"); + + // We add a default row here to say that + // there are no resources in the app host. + // This will be replaced once the first + // resource is streamed back from the + // app host which should be almost immediate + // if no resources are present. + + // Create placeholders based on number of columns defined. + var placeholders = new Markup[table.Columns.Count]; + for (int i = 0; i < table.Columns.Count; i++) + { + placeholders[i] = new Markup("--"); + } + table.Rows.Add(placeholders); + var message = new Markup("Press [bold]Ctrl+C[/] to stop the app host and exit."); - var rows = new Rows(new List { + var renderables = new List { table, message - }); + }; + var rows = new Rows(renderables); await _ansiConsole.Live(rows).StartAsync(async context => { - var knownResources = new SortedDictionary(); + // If we are running an apphost that has no + // resources in it then we want to display + // the message that there are no resources. + // That is why we immediately do a refresh. + context.Refresh(); - table.AddColumn("Resource"); - table.AddColumn("Type"); - table.AddColumn("State"); - table.AddColumn("Health"); - table.AddColumn("Endpoint(s)"); + var knownResources = new SortedDictionary(); var resourceStates = backchannel.GetResourceStatesAsync(cancellationToken); diff --git a/tests/Aspire.Cli.Tests/Commands/RunCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/RunCommandTests.cs index c11cd3e04b7..35b08b6eb28 100644 --- a/tests/Aspire.Cli.Tests/Commands/RunCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/RunCommandTests.cs @@ -203,4 +203,60 @@ public async Task RunCommand_CompletesSuccessfully() var exitCode = await pendingRun.WaitAsync(CliTestConstants.DefaultTimeout); Assert.Equal(ExitCodeConstants.Success, exitCode); } + + [Fact] + public async Task RunCommand_WithNoResources_CompletesSuccessfully() + { + var getResourceStatesAsyncCalled = new TaskCompletionSource(); + var backchannelFactory = (IServiceProvider sp) => { + var backchannel = new TestAppHostBackchannel(); + backchannel.GetResourceStatesAsyncCalled = getResourceStatesAsyncCalled; + + // Return empty resources using an empty enumerable + backchannel.GetResourceStatesAsyncCallback = _ => EmptyAsyncEnumerable.Instance; + + return backchannel; + }; + + var runnerFactory = (IServiceProvider sp) => { + var runner = new TestDotNetCliRunner(); + runner.CheckHttpCertificateAsyncCallback = (options, ct) => 0; + runner.BuildAsyncCallback = (projectFile, options, ct) => 0; + runner.GetAppHostInformationAsyncCallback = (projectFile, options, ct) => (0, true, VersionHelper.GetDefaultTemplateVersion()); + + runner.RunAsyncCallback = async (projectFile, watch, noBuild, args, env, backchannelCompletionSource, options, ct) => + { + var backchannel = sp.GetRequiredService(); + backchannelCompletionSource!.SetResult(backchannel); + await Task.Delay(Timeout.InfiniteTimeSpan, ct); + return 0; + }; + + return runner; + }; + + var projectLocatorFactory = (IServiceProvider sp) => new TestProjectLocator(); + + var services = CliTestHelper.CreateServiceCollection(outputHelper, options => + { + options.ProjectLocatorFactory = projectLocatorFactory; + options.AppHostBackchannelFactory = backchannelFactory; + options.DotNetCliRunnerFactory = runnerFactory; + }); + + var provider = services.BuildServiceProvider(); + var command = provider.GetRequiredService(); + var result = command.Parse("run"); + + using var cts = new CancellationTokenSource(); + var pendingRun = result.InvokeAsync(cts.Token); + + await getResourceStatesAsyncCalled.Task.WaitAsync(CliTestConstants.DefaultTimeout); + + // Simulate CTRL-C. + cts.Cancel(); + + var exitCode = await pendingRun.WaitAsync(CliTestConstants.DefaultTimeout); + Assert.Equal(ExitCodeConstants.Success, exitCode); + } } diff --git a/tests/Aspire.Cli.Tests/Utils/EmptyAsyncEnumerable.cs b/tests/Aspire.Cli.Tests/Utils/EmptyAsyncEnumerable.cs new file mode 100644 index 00000000000..f221eba7a63 --- /dev/null +++ b/tests/Aspire.Cli.Tests/Utils/EmptyAsyncEnumerable.cs @@ -0,0 +1,33 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Aspire.Cli.Tests.Utils; + +/// +/// A utility class that provides an empty IAsyncEnumerable<T>. +/// +/// The type of the elements in the empty enumerable. +internal sealed class EmptyAsyncEnumerable +{ + /// + /// Gets a singleton instance of an empty . + /// + public static readonly IAsyncEnumerable Instance = new EmptyAsyncEnumerableImpl(); + + private sealed class EmptyAsyncEnumerableImpl : IAsyncEnumerable + { + public IAsyncEnumerator GetAsyncEnumerator(CancellationToken cancellationToken = default) + { + return new EmptyAsyncEnumerator(); + } + + private sealed class EmptyAsyncEnumerator : IAsyncEnumerator + { + public T Current => default!; + + public ValueTask DisposeAsync() => ValueTask.CompletedTask; + + public ValueTask MoveNextAsync() => new ValueTask(Task.FromResult(false)); + } + } +} \ No newline at end of file