// 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 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 command, specifically testing
/// certificate trust level detection on Linux.
/// </summary>
public sealed class DoctorCommandTests(ITestOutputHelper output)
{
private const string SpinnerCharacters = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏";
public static TheoryData<string> AlternativeToolchains => new()
{
"bun",
"yarn",
"pnpm",
"deno"
};
[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);
}
}