File: DoctorCommandTests.cs
Web Access
Project: src\tests\Aspire.Cli.EndToEnd.Tests\Aspire.Cli.EndToEnd.Tests.csproj (Aspire.Cli.EndToEnd.Tests)
// 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);
    }
}