// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
using Aspire.Cli.EndToEnd.Tests.Helpers;
using Aspire.Cli.Resources;
using Hex1b.Automation;
using Xunit;
namespace Aspire.Cli.EndToEnd.Tests;
/// <summary>
/// End-to-end tests for Aspire CLI doctor prerequisite checks and reporting on Linux.
/// </summary>
public sealed class DoctorCommandTests(ITestOutputHelper output)
{
private const string SpinnerCharacters = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏";
public static TheoryData<string> AlternativeToolchains => new()
{
"bun",
"yarn",
"pnpm",
"deno"
};
[Theory]
[InlineData(null, null, true, "linux")]
[InlineData(null, null, false, "linux")]
[InlineData(null, null, true, "windows")]
[InlineData("ASPIRE_CONTAINER_RUNTIME", "docker", true, "linux")]
[InlineData("ASPIRE_CONTAINER_RUNTIME", "podman", true, "linux")]
[InlineData("DOTNET_ASPIRE_CONTAINER_RUNTIME", "docker", true, "linux")]
[InlineData("DOTNET_ASPIRE_CONTAINER_RUNTIME", "podman", true, "linux")]
[InlineData("ASPIRE_CONTAINER_RUNTIME", "docker", true, "windows")]
[InlineData("ASPIRE_CONTAINER_RUNTIME", "podman", true, "windows")]
[CaptureWorkspaceOnFailure]
public async Task DoctorCommand_RuntimeSelectionIndicators(string? variableName, string? configuredRuntime, bool dockerRunning, string dockerOs)
{
var repoRoot = CliE2ETestHelpers.GetRepoRoot();
var strategy = CliInstallStrategy.Detect(output.WriteLine);
var workspace = TemporaryWorkspace.Create(output);
using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, 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);
// Controlled CLI responses set runtime availability without depending on a second daemon.
// The packaged Aspire executable still performs discovery, configuration, and rendering.
var runtimeDirectory = workspace.CreateDirectory("runtime-probes");
foreach (var runtime in new[] { "docker", "podman" })
{
var version = runtime == "docker" ? "29.0.0" : "5.0.0";
var healthExitCode = runtime == "docker" && !dockerRunning ? 1 : 0;
var serverOs = runtime == "docker" ? dockerOs : "linux";
var script = $$$"""
#!/bin/sh
case "$*" in
"container ls -n 1") exit {{{healthExitCode}}} ;;
"version -f json") printf '%s\n' '{"Client":{"Version":"{{{version}}}"},"Server":{"Version":"{{{version}}}","Os":"{{{serverOs}}}"}}' ;;
"--version") printf '%s\n' '{{{runtime}}} version {{{version}}}' ;;
*) exit 1 ;;
esac
""";
await File.WriteAllTextAsync(
Path.Combine(runtimeDirectory.FullName, runtime),
script.ReplaceLineEndings("\n") + "\n",
TestContext.Current.CancellationToken);
}
await auto.RunCommandAsync(
"chmod +x runtime-probes/* && export PATH=\"$PWD/runtime-probes:$PATH\" && " +
"unset ASPIRE_CONTAINER_RUNTIME DOTNET_ASPIRE_CONTAINER_RUNTIME",
counter);
if (variableName is not null)
{
await auto.RunCommandAsync($"export {variableName}={configuredRuntime}", counter);
}
// Other prerequisite checks may fail independently of runtime selection. Accept doctor's
// documented 0/1 exit codes, then verify the complete runtime section below.
// The harness forces ANSI passthrough even for redirected output. Disable it for this
// plain-text capture so message assertions do not include terminal color/reset sequences.
await auto.RunCommandAsync(
"env -u ASPIRE_ANSI_PASS_THRU NO_COLOR=1 aspire doctor --non-interactive --nologo > doctor-runtime.txt; result=$?; printf '%s' \"$result\" > doctor-text.exit; cat doctor-runtime.txt; test \"$result\" -le 1",
counter,
TimeSpan.FromMinutes(2));
await auto.RunCommandAsync(
"aspire doctor --format json --non-interactive --nologo > doctor-runtime.json; result=$?; printf '%s' \"$result\" > doctor-json.exit; test \"$result\" -le 1",
counter,
TimeSpan.FromMinutes(2));
string[] expectedMessages = configuredRuntime switch
{
"docker" when dockerOs == "windows" => ["Docker is running in Windows container mode \u2190 explicitly selected"],
"docker" => ["Docker v29.0.0: running \u2190 explicitly selected"],
"podman" => ["Podman v5.0.0: running \u2190 explicitly selected"],
_ when dockerOs == "windows" => ["Docker is running in Windows container mode \u2190 active", "Podman v5.0.0: running"],
_ when !dockerRunning => ["Docker: installed but not running", "Podman v5.0.0: running \u2190 active"],
_ => ["Docker v29.0.0: running \u2190 active", "Podman v5.0.0: running"]
};
var text = await File.ReadAllTextAsync(
Path.Combine(workspace.WorkspaceRoot.FullName, "doctor-runtime.txt"),
TestContext.Current.CancellationToken);
// Match runtime rows such as "Docker v29.0.0: running" and
// "Docker: installed but not running", including any selection suffix.
var renderedRuntimeMessages = Regex.Matches(text, @"(?:(?:Docker|Podman)(?: v\d+\.\d+\.\d+)?: (?:running|installed but not running)|Docker is running in Windows container mode)[^\r\n]*")
.Select(match => match.Value.Trim())
.ToArray();
Assert.Equal(expectedMessages, renderedRuntimeMessages);
using var document = JsonDocument.Parse(await File.ReadAllTextAsync(
Path.Combine(workspace.WorkspaceRoot.FullName, "doctor-runtime.json"),
TestContext.Current.CancellationToken));
var runtimeChecks = document.RootElement.GetProperty("checks").EnumerateArray()
.Where(check => check.GetProperty("category").GetString() == "container")
.ToArray();
Assert.Equal(
configuredRuntime == "podman" ? ["podman"]
: dockerOs == "windows" ? configuredRuntime is null ? ["container-runtime", "podman"] : ["container-runtime"]
: configuredRuntime is null ? ["docker", "podman"] : new[] { configuredRuntime },
runtimeChecks.Select(check => check.GetProperty("name").GetString()));
Assert.All(runtimeChecks, check => Assert.Equal(
check.GetProperty("name").GetString() == "container-runtime" ? "fail"
: !dockerRunning && check.GetProperty("name").GetString() == "docker" ? "warning" : "pass",
check.GetProperty("status").GetString()));
Assert.Equal(expectedMessages, runtimeChecks.Select(check => check.GetProperty("message").GetString()));
if (dockerOs == "windows" && configuredRuntime != "podman")
{
Assert.Equal("1", await File.ReadAllTextAsync(Path.Combine(workspace.WorkspaceRoot.FullName, "doctor-text.exit")));
Assert.Equal("1", await File.ReadAllTextAsync(Path.Combine(workspace.WorkspaceRoot.FullName, "doctor-json.exit")));
}
}
[Fact]
public async Task DoctorCommand_WithoutSslCertDir_ShowsPartiallyTrusted()
{
var repoRoot = CliE2ETestHelpers.GetRepoRoot();
var strategy = CliInstallStrategy.Detect(output.WriteLine);
var workspace = TemporaryWorkspace.Create(output);
using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, 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);
// Generate and trust dev certs inside the container (Docker images don't have them by default)
await auto.TypeAsync("dotnet dev-certs https --trust 2>/dev/null || dotnet dev-certs https");
await auto.EnterAsync();
await auto.WaitForSuccessPromptAsync(counter);
// Unset SSL_CERT_DIR to trigger partial trust detection on Linux
await auto.TypeAsync("unset SSL_CERT_DIR");
await auto.EnterAsync();
await auto.WaitForSuccessPromptAsync(counter);
await auto.TypeAsync("aspire doctor");
await auto.EnterAsync();
await auto.WaitUntilAsync(
s => s.ContainsText("dev-certs") && s.ContainsText("partially trusted"),
timeout: TimeSpan.FromSeconds(60), description: "doctor to complete with partial trust warning");
await auto.WaitForSuccessPromptAsync(counter);
}
[Fact]
public async Task DoctorCommand_WithSslCertDir_ShowsTrustedAndDcpConnectionHealthy()
{
var repoRoot = CliE2ETestHelpers.GetRepoRoot();
var strategy = CliInstallStrategy.Detect(output.WriteLine);
var workspace = TemporaryWorkspace.Create(output);
using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, 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);
// Generate and trust dev certs inside the container (Docker images don't have them by default)
await auto.TypeAsync("dotnet dev-certs https --trust 2>/dev/null || dotnet dev-certs https");
await auto.EnterAsync();
await auto.WaitForSuccessPromptAsync(counter);
// Set SSL_CERT_DIR to include dev-certs trust path for full trust
await auto.TypeAsync("export SSL_CERT_DIR=\"/etc/ssl/certs:$HOME/.aspnet/dev-certs/trust\"");
await auto.EnterAsync();
await auto.WaitForSuccessPromptAsync(counter);
await auto.TypeAsync("aspire doctor");
await auto.EnterAsync();
await auto.WaitUntilAsync(s =>
{
// Fail if we see partial trust when SSL_CERT_DIR is configured
if (s.ContainsText("partially trusted"))
{
throw new InvalidOperationException(
"Unexpected 'partially trusted' message when SSL_CERT_DIR is configured!");
}
return s.ContainsText("certificate is trusted") &&
s.ContainsText("Developer Control Plane (DCP) connection health checks succeeded");
}, timeout: TimeSpan.FromSeconds(60), description: "doctor to complete with trusted certificate");
await auto.WaitForSuccessPromptAsync(counter);
}
[Fact]
public async Task DoctorCommand_WithDebugLogging_DoesNotRenderSpinner()
{
var repoRoot = CliE2ETestHelpers.GetRepoRoot();
var strategy = CliInstallStrategy.Detect(output.WriteLine);
var workspace = TemporaryWorkspace.Create(output);
var testName = nameof(DoctorCommand_WithDebugLogging_DoesNotRenderSpinner);
var recordingPath = CliE2ETestHelpers.GetTestResultsRecordingPath(testName);
// The recording path is stable across retries, so remove stale output before the recorder starts.
File.Delete(recordingPath);
using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, workspace: workspace, testName: testName);
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);
await auto.ClearScreenAsync(counter);
var recordingOffset = File.Exists(recordingPath) ? new FileInfo(recordingPath).Length : 0;
await auto.TypeAsync("aspire doctor -l debug");
await auto.EnterAsync();
await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(2));
var commandOutput = ReadRecordingOutput(recordingPath, recordingOffset);
Assert.Contains("[dbug]", commandOutput, StringComparison.Ordinal);
Assert.Contains(DoctorCommandStrings.EnvironmentCheckHeader, commandOutput, StringComparison.Ordinal);
Assert.Contains(DoctorCommandStrings.CheckingPrerequisites, commandOutput, StringComparison.Ordinal);
Assert.DoesNotContain(commandOutput, SpinnerCharacters.Contains);
}
private static string ReadRecordingOutput(string recordingPath, long recordingOffset)
{
using var stream = new FileStream(recordingPath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
stream.Position = recordingOffset;
using var reader = new StreamReader(stream);
var outputBuilder = new StringBuilder();
while (reader.ReadLine() is { } eventLine)
{
using var eventDocument = JsonDocument.Parse(eventLine);
var recordingEvent = eventDocument.RootElement;
if (recordingEvent.GetArrayLength() >= 3 && recordingEvent[1].GetString() == "o")
{
outputBuilder.Append(recordingEvent[2].GetString());
}
}
return outputBuilder.ToString();
}
[Theory]
[MemberData(nameof(AlternativeToolchains))]
[CaptureWorkspaceOnFailure]
public async Task DoctorCommand_TypeScriptAppHostReportsMissingConfiguredToolchain(string toolchain)
{
var repoRoot = CliE2ETestHelpers.GetRepoRoot();
var strategy = CliInstallStrategy.Detect(output.WriteLine);
var workspace = TemporaryWorkspace.Create(output);
using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, 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);
output.WriteLine($"Testing aspire doctor missing-tool detection for: {toolchain}");
await auto.TypeAsync("aspire init --language typescript --non-interactive");
await auto.EnterAsync();
await auto.WaitUntilTextAsync("Created apphost.mts", timeout: TimeSpan.FromMinutes(2));
await auto.WaitForSuccessPromptAsync(counter);
TypeScriptAppHostToolchainTestHelpers.SetPackageManager(workspace.WorkspaceRoot.FullName, toolchain, cleanInstallState: true);
if (TypeScriptAppHostToolchainTestHelpers.UsesCorepack(toolchain))
{
await auto.RunCommandAsync(
$"COREPACK_ENABLE_DOWNLOAD_PROMPT=0 corepack prepare {TypeScriptAppHostToolchainTestHelpers.GetPackageManager(toolchain)} --activate",
counter,
TimeSpan.FromMinutes(2));
}
// Verify the configured toolchain can start and stop the generated AppHost
// before doctor is asked to report that the toolchain is missing from PATH.
await auto.AspireStartAsync(counter);
await auto.AspireStopAsync(counter);
if (toolchain == "deno")
{
await auto.TypeAsync("aspire doctor");
await auto.EnterAsync();
await auto.WaitUntilTextAsync(
"TypeScript AppHost tooling found (deno).",
timeout: TimeSpan.FromSeconds(60));
await auto.WaitForAnyPromptAsync(counter);
}
await auto.TypeAsync("""mkdir -p ./doctor-path && ln -sf "$(command -v aspire)" ./doctor-path/aspire && ln -sf "$(command -v dotnet)" ./doctor-path/dotnet && if command -v docker >/dev/null 2>&1; then ln -sf "$(command -v docker)" ./doctor-path/docker; fi && export PATH="$PWD/doctor-path" """);
await auto.EnterAsync();
await auto.WaitForSuccessPromptAsync(counter);
await auto.TypeAsync("aspire doctor");
await auto.EnterAsync();
await auto.WaitUntilAsync(
s => s.ContainsText($"TypeScript AppHost requires '{toolchain}'.") &&
s.ContainsText($"Install {TypeScriptAppHostToolchainTestHelpers.GetDisplayName(toolchain)} tooling and rerun 'aspire doctor'.") &&
s.ContainsText(TypeScriptAppHostToolchainTestHelpers.GetInstallationLink(toolchain)),
timeout: TimeSpan.FromSeconds(60),
description: $"doctor to report missing {toolchain} tooling");
await auto.WaitForAnyPromptAsync(counter);
}
}