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
69 changes: 57 additions & 12 deletions src/Aspire.Cli/Commands/RunCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -476,13 +476,18 @@ await extensionInteractionService.StartDebugSessionAsync(
}
else
{
// We want to display resource information in remote environments.
// Resources update over time so we'll use a live display.
// It is used to show discovered endpoints as they come in over the backchannel.
// Remote environments surface endpoints discovered over the backchannel. Interactive
// terminals replace a cumulative snapshot in place, while redirected output appends
// each endpoint transition once without cursor manipulation.
var discoveredEndpoints = new List<(string Resource, string Endpoint)>();
var endpointsLocalizedString = RunCommandStrings.Endpoints;
var showCtrlC = !ExtensionHelper.IsExtensionHost(InteractionService, out _, out _);

Markup BuildEndpointMarkup(string resource, string endpoint)
{
return new Markup($"[bold]{resource.EscapeMarkup()}[/] [grey]has endpoint[/] {MarkupHelpers.SafeLink(InteractionService, endpoint)}");
}

IRenderable BuildLiveRenderable()
{
var rows = new List<IRenderable>();
Expand All @@ -502,7 +507,7 @@ IRenderable BuildLiveRenderable()
i == 0
? new Align(new Markup($"[bold green]{endpointsLocalizedString}[/]:"), HorizontalAlignment.Right)
: Text.Empty,
new Markup($"[bold]{resource.EscapeMarkup()}[/] [grey]has endpoint[/] {MarkupHelpers.SafeLink(InteractionService, endpoint)}")
BuildEndpointMarkup(resource, endpoint)
);
}

Expand All @@ -517,20 +522,60 @@ IRenderable BuildLiveRenderable()
return rows.Count > 0 ? new Rows(rows) : Text.Empty;
}

IRenderable BuildStaticEndpointRenderable(string resource, string endpoint, bool isFirstEndpoint)
{
var endpointsGrid = new Grid();
endpointsGrid.AddColumn();
endpointsGrid.AddColumn();
endpointsGrid.Columns[0].Width = longestLocalizedLengthWithColon;

if (isFirstEndpoint)
{
endpointsGrid.AddRow(Text.Empty, Text.Empty);
}

endpointsGrid.AddRow(
isFirstEndpoint
? new Align(new Markup($"[bold green]{endpointsLocalizedString}[/]:"), HorizontalAlignment.Right)
: Text.Empty,
BuildEndpointMarkup(resource, endpoint)
);

return new Padder(endpointsGrid, new Padding(3, 0));
}

async Task ProcessResourceStatesAsync(Action<string, string> endpointWriter)
{
var resourceStates = backchannel.GetResourceStatesAsync(cancellationToken);
await foreach (var resourceState in resourceStates.WithCancellation(cancellationToken))
{
ProcessResourceState(resourceState, endpointWriter);
}
}

try
{
await InteractionService.DisplayLiveAsync(BuildLiveRenderable(), async updateTarget =>
if (_hostEnvironment.SupportsInteractiveOutput)
{
var resourceStates = backchannel.GetResourceStatesAsync(cancellationToken);
await foreach (var resourceState in resourceStates.WithCancellation(cancellationToken))
{
ProcessResourceState(resourceState, (resource, endpoint) =>
await InteractionService.DisplayLiveAsync(
BuildLiveRenderable(),
updateTarget => ProcessResourceStatesAsync((resource, endpoint) =>
{
discoveredEndpoints.Add((resource, endpoint));
updateTarget(BuildLiveRenderable());
});
}
});
}));
}
else
{
AppendCtrlCMessage(longestLocalizedLengthWithColon);

var isFirstEndpoint = true;
await ProcessResourceStatesAsync((resource, endpoint) =>
{
InteractionService.DisplayRenderable(BuildStaticEndpointRenderable(resource, endpoint, isFirstEndpoint));
isFirstEndpoint = false;
});
}
}
catch (ConnectionLostException) when (cancellationToken.IsCancellationRequested)
{
Expand Down
7 changes: 7 additions & 0 deletions src/Aspire.Cli/Interaction/ConsoleInteractionService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -641,6 +641,13 @@ public void DisplayRenderable(IRenderable renderable)

public async Task DisplayLiveAsync(IRenderable initialRenderable, Func<Action<IRenderable>, Task> callback)
{
if (!_hostEnvironment.SupportsInteractiveOutput)
{
// The callback supplies replacement snapshots, so appending them would duplicate prior
// content. Callers must define incremental static output when a live region is unavailable.
throw new InvalidOperationException("Live rendering requires interactive output.");
}

await MessageConsole.Live(initialRenderable)
.AutoClear(false)
.StartAsync(async ctx =>
Expand Down
19 changes: 15 additions & 4 deletions src/Aspire.Cli/Utils/CliHostEnvironment.cs
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,11 @@ internal sealed class CliHostEnvironment : ICliHostEnvironment
public bool SupportsAnsi { get; }

public CliHostEnvironment(IConfiguration configuration, bool nonInteractive)
: this(configuration, nonInteractive, Console.IsOutputRedirected)
Comment thread
adamint marked this conversation as resolved.
{
}

internal CliHostEnvironment(IConfiguration configuration, bool nonInteractive, bool isOutputRedirected)
{
// If --non-interactive is explicitly set, disable interactive input and output.
// ANSI support is still determined from the host configuration so explicit
Expand All @@ -78,17 +83,18 @@ public CliHostEnvironment(IConfiguration configuration, bool nonInteractive)
SupportsInteractiveOutput = false;
SupportsAnsi = DetectAnsiSupport(configuration);
}
// Check if ASPIRE_PLAYGROUND is set to force interactive mode
// Playground mode can force interactive input and ANSI output, but a redirected stdout
// still cannot support the cursor manipulation required by Spectre live rendering.
else if (IsPlaygroundMode(configuration))
{
SupportsInteractiveInput = true;
SupportsInteractiveOutput = true;
SupportsInteractiveOutput = !isOutputRedirected;
SupportsAnsi = true;
}
else
{
SupportsInteractiveInput = DetectInteractiveInput(configuration);
SupportsInteractiveOutput = DetectInteractiveOutput(configuration);
SupportsInteractiveOutput = DetectInteractiveOutput(configuration, isOutputRedirected);
SupportsAnsi = DetectAnsiSupport(configuration);
}
}
Expand Down Expand Up @@ -134,7 +140,7 @@ private static bool DetectInteractiveInput(IConfiguration configuration)
return true;
}

private static bool DetectInteractiveOutput(IConfiguration configuration)
private static bool DetectInteractiveOutput(IConfiguration configuration, bool isOutputRedirected)
{
// Check if explicitly disabled via configuration
var nonInteractive = configuration["ASPIRE_NON_INTERACTIVE"];
Expand All @@ -151,6 +157,11 @@ private static bool DetectInteractiveOutput(IConfiguration configuration)
return false;
}

if (isOutputRedirected)
{
return false;
}

// Verify the console handles are valid. Returning false here is safe —
// all consumers gracefully degrade to plain text output (no spinners,
// no banner, no progress bars) so the command still works.
Expand Down
81 changes: 81 additions & 0 deletions tests/Aspire.Cli.EndToEnd.Tests/SmokeTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,87 @@ await auto.WaitUntilAsync(s =>
await auto.WaitForSuccessPromptAsync(counter);
}

[CaptureWorkspaceOnFailure]
[Fact]
public async Task RedirectedRemoteSshRunUsesStaticOutput()
{
var repoRoot = CliE2ETestHelpers.GetRepoRoot();
var strategy = CliInstallStrategy.Detect(output.WriteLine);

var workspace = TemporaryWorkspace.Create(output);

using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, mountDockerSocket: true, workspace: workspace);

var counter = new SequenceCounter();
var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500));
await using var terminalRun = CliE2ETestHelpers.StartRun(terminal, workspace, auto, counter, output, TestContext.Current.CancellationToken);

await auto.PrepareDockerEnvironmentAsync(counter, workspace);
await auto.InstallAspireCliAsync(strategy, counter);

const string projectName = "RemoteSshRedirectedApp";
await auto.AspireNewAsync(projectName, counter, useRedisCache: false);
await auto.RunCommandAsync($"cd {projectName}", counter);

// Playground mode normally forces interactive output and takes precedence over ambient CI
// markers. Keeping it enabled makes redirected stdout the condition that selects static
// rendering, so this test cannot silently pass because a new CI marker was inherited.
var runCommand =
"env " +
"-u ASPIRE_NON_INTERACTIVE -u ASPIRE_ANSI_PASS_THRU " +
"ASPIRE_PLAYGROUND=true " +
"TERM=dumb LINES=0 COLUMNS=80 " +
"VSCODE_IPC_HOOK_CLI=/tmp/vscode-ipc-remote-ssh " +
"SSH_CONNECTION='127.0.0.1 12345 127.0.0.1 22' " +
$"ASPIRE_CLI_START_TIMEOUT={CliE2EAutomatorHelpers.AspireRunStartupBudgetSeconds} " +
"aspire run > remote-ssh.stdout 2> remote-ssh.stderr & echo $! > remote-ssh.pid";
await auto.RunCommandAsync(runCommand, counter);

// Static resource updates are emitted once as lines such as:
// Endpoints: worker has endpoint http://localhost:5830
// Wait for multiple updates so a cumulative-snapshot fallback would be observable.
await auto.RunCommandAsync(
$"endpoint_count=0; cli_alive=1; " +
$"for attempt in $(seq 1 {CliE2EAutomatorHelpers.AspireRunReadyTimeout.TotalSeconds}); do " +
"endpoint_count=$(grep -Fc 'has endpoint' remote-ssh.stdout || true); " +
"if [ \"$endpoint_count\" -ge 2 ]; then break; fi; " +
"if ! kill -0 \"$(cat remote-ssh.pid)\" 2>/dev/null; then cli_alive=0; break; fi; " +
"sleep 1; " +
"done; " +
"if [ \"$endpoint_count\" -ge 2 ]; then true; " +
"else " +
"if [ \"$cli_alive\" -eq 0 ]; then echo 'aspire run exited before two endpoint updates' >&2; " +
"else echo 'timed out waiting for two endpoint updates' >&2; fi; " +
"echo '--- remote-ssh.stdout ---' >&2; cat remote-ssh.stdout >&2; " +
"echo '--- remote-ssh.stderr ---' >&2; cat remote-ssh.stderr >&2; false; " +
"fi",
counter,
CliE2EAutomatorHelpers.AspireRunReadyTimeout + TimeSpan.FromSeconds(30));

await auto.RunCommandAsync("kill -0 \"$(cat remote-ssh.pid)\"", counter);
await auto.RunCommandAsync("aspire ps --format json > remote-ssh-ps.json", counter);
await auto.RunCommandAsync(
$"grep -Fq '{projectName}' remote-ssh-ps.json && grep -Fq '\"status\": \"running\"' remote-ssh-ps.json",
counter);
await auto.RunCommandAsync(
"test -f remote-ssh.stdout && test -f remote-ssh.stderr && " +
"! grep -F -e 'LiveRenderable' -e 'System.ArgumentException' " +
"-e 'An unexpected error occurred' remote-ssh.stdout remote-ssh.stderr",
counter);
await auto.RunCommandAsync(
"test \"$(tr -cd '\\r' < remote-ssh.stdout | wc -c)\" -eq 0",
counter);
await auto.RunCommandAsync(
"test -z \"$(grep -F 'has endpoint' remote-ssh.stdout | sort | uniq -d)\" && " +
"test \"$(grep -Foc 'CTRL+C' remote-ssh.stdout)\" -eq 1",
counter);
await auto.RunCommandAsync(
"cli_pid=$(cat remote-ssh.pid); kill -INT \"$cli_pid\"; wait \"$cli_pid\"; " +
"cli_exit=$?; test \"$cli_exit\" -eq 0 && ! kill -0 \"$cli_pid\" 2>/dev/null",
counter,
TimeSpan.FromMinutes(1));
}

[CaptureWorkspaceOnFailure]
[Fact]
public async Task CreateAndRunPolyglotAppHostWithDevLocalhostUrls()
Expand Down
Loading
Loading