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
40 changes: 32 additions & 8 deletions src/Aspire.Cli/Commands/RunCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -165,22 +165,46 @@ protected override async Task<int> 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<IRenderable> {
var renderables = new List<IRenderable> {
table,
message
});
};
var rows = new Rows(renderables);

await _ansiConsole.Live(rows).StartAsync(async context =>
{
var knownResources = new SortedDictionary<string, RpcResourceState>();
// 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<string, RpcResourceState>();

var resourceStates = backchannel.GetResourceStatesAsync(cancellationToken);

Expand Down
56 changes: 56 additions & 0 deletions tests/Aspire.Cli.Tests/Commands/RunCommandTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<RpcResourceState>.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<IAppHostBackchannel>();
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<RootCommand>();
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);
}
}
33 changes: 33 additions & 0 deletions tests/Aspire.Cli.Tests/Utils/EmptyAsyncEnumerable.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// A utility class that provides an empty IAsyncEnumerable&lt;T&gt;.
/// </summary>
/// <typeparam name="T">The type of the elements in the empty enumerable.</typeparam>
internal sealed class EmptyAsyncEnumerable<T>
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot You don't need this class, there's a built in AsyncEnumerable.Empty<T>()

{
/// <summary>
/// Gets a singleton instance of an empty <see cref="IAsyncEnumerable{T}"/>.
/// </summary>
public static readonly IAsyncEnumerable<T> Instance = new EmptyAsyncEnumerableImpl();

private sealed class EmptyAsyncEnumerableImpl : IAsyncEnumerable<T>
{
public IAsyncEnumerator<T> GetAsyncEnumerator(CancellationToken cancellationToken = default)
{
return new EmptyAsyncEnumerator();
}

private sealed class EmptyAsyncEnumerator : IAsyncEnumerator<T>
{
public T Current => default!;

public ValueTask DisposeAsync() => ValueTask.CompletedTask;

public ValueTask<bool> MoveNextAsync() => new ValueTask<bool>(Task.FromResult(false));
}
}
}