File: AppHostSyntaxErrorOutputTests.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.Globalization;
using System.Runtime.CompilerServices;
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 AppHost failures reported before startup.
/// </summary>
public sealed class AppHostSyntaxErrorOutputTests(ITestOutputHelper output)
{
    [Fact]
    [CaptureWorkspaceOnFailure]
    public Task RunReportsSyntaxErrorsForDotNetAppHost()
    {
        return RunSyntaxErrorScenarioAsync(
            projectName: "BrokenDotNetApp",
            template: AspireTemplate.EmptyAppHost,
            configureProject: WriteBrokenDotNetAppHost,
            command: "aspire run --apphost BrokenDotNetApp.csproj",
            expectedExitCode: 6,
            outputExpectation: s_dotNetRunOutputExpectation,
            timeout: TimeSpan.FromMinutes(2));
    }
 
    [Fact]
    [CaptureWorkspaceOnFailure]
    public Task RunReportsMissingSdkAsBuildFailureForDotNetAppHost()
    {
        return RunSyntaxErrorScenarioAsync(
            projectName: "MissingSdkAppHost",
            template: AspireTemplate.EmptyAppHost,
            configureProject: WriteDotNetAppHostWithMissingSdk,
            command: "aspire run --apphost MissingSdkAppHost.csproj",
            expectedExitCode: 6,
            outputExpectation: s_dotNetMissingSdkRunOutputExpectation,
            timeout: TimeSpan.FromMinutes(2));
    }
 
    [Fact]
    [CaptureWorkspaceOnFailure]
    public Task StartReportsSyntaxErrorsForDotNetAppHost()
    {
        return RunSyntaxErrorScenarioAsync(
            projectName: "BrokenDotNetApp",
            template: AspireTemplate.EmptyAppHost,
            configureProject: WriteBrokenDotNetAppHost,
            command: "aspire start --apphost BrokenDotNetApp.csproj",
            expectedExitCode: 2,
            outputExpectation: s_dotNetStartOutputExpectation,
            timeout: TimeSpan.FromMinutes(2));
    }
 
    [Fact]
    [CaptureWorkspaceOnFailure]
    public Task RunReportsSyntaxErrorsForTypeScriptAppHost()
    {
        return RunSyntaxErrorScenarioAsync(
            projectName: "BrokenTypeScriptApp",
            template: AspireTemplate.TypeScriptEmptyAppHost,
            configureProject: WriteBrokenTypeScriptAppHost,
            command: "aspire run",
            expectedExitCode: 2,
            outputExpectation: s_typeScriptRunOutputExpectation,
            timeout: TimeSpan.FromMinutes(3));
    }
 
    [Fact]
    [CaptureWorkspaceOnFailure]
    public Task StartReportsSyntaxErrorsForTypeScriptAppHost()
    {
        return RunSyntaxErrorScenarioAsync(
            projectName: "BrokenTypeScriptApp",
            template: AspireTemplate.TypeScriptEmptyAppHost,
            configureProject: WriteBrokenTypeScriptAppHost,
            command: "aspire start",
            expectedExitCode: 2,
            outputExpectation: s_typeScriptStartOutputExpectation,
            timeout: TimeSpan.FromMinutes(3));
    }
 
    private async Task RunSyntaxErrorScenarioAsync(
        string projectName,
        AspireTemplate template,
        Action<string> configureProject,
        string command,
        int expectedExitCode,
        CommandOutputExpectation outputExpectation,
        TimeSpan timeout,
        [CallerMemberName] string testName = "")
    {
        var repoRoot = CliE2ETestHelpers.GetRepoRoot();
        var strategy = CliInstallStrategy.Detect(output.WriteLine);
        var workspace = TemporaryWorkspace.Create(output);
        var recordingPath = CliE2ETestHelpers.GetTestResultsRecordingPath(testName);
 
        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.AspireNewAsync(projectName, counter, template: template);
        configureProject(Path.Combine(workspace.WorkspaceRoot.FullName, projectName));
 
        await AssertAspireCommandOutputAsync(
            auto,
            counter,
            projectName,
            command,
            expectedExitCode,
            outputExpectation,
            recordingPath,
            timeout);
    }
 
    private static async Task AssertAspireCommandOutputAsync(
        Hex1bTerminalAutomator auto,
        SequenceCounter counter,
        string workingDirectory,
        string command,
        int expectedExitCode,
        CommandOutputExpectation outputExpectation,
        string recordingPath,
        TimeSpan timeout)
    {
        var quotedWorkingDirectory = AspireCliShellCommandHelpers.QuoteBashArg(workingDirectory);
        await auto.RunCommandAsync($"cd \"$ASPIRE_E2E_WORKSPACE\"/{quotedWorkingDirectory}", counter, TimeSpan.FromSeconds(10));
        await auto.ClearScreenAsync(counter);
 
        var recordingOffset = File.Exists(recordingPath) ? new FileInfo(recordingPath).Length : 0;
        await auto.TypeAsync(command);
        await auto.EnterAsync();
 
        var expectedCounter = counter.Value;
        var errorPromptSearcher = new CellPatternSearcher()
            .FindPattern(expectedCounter.ToString(CultureInfo.InvariantCulture))
            .RightText($" ERR:{expectedExitCode}] $ ");
 
        await auto.WaitUntilAsync(snapshot =>
        {
            if (errorPromptSearcher.Search(snapshot).Count == 0)
            {
                return false;
            }
 
            return true;
        }, timeout, description: $"waiting for '{command}' to fail with exit code {expectedExitCode}");
        counter.Increment();
 
        AssertTerminalRecording(ReadRecordingText(recordingPath, recordingOffset), outputExpectation);
    }
 
    private static string ReadRecordingText(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);
        return reader.ReadToEnd();
    }
 
    private static void AssertTerminalRecording(string terminalRecording, CommandOutputExpectation outputExpectation)
    {
        foreach (var text in outputExpectation.RequiredText)
        {
            Assert.Contains(text, terminalRecording);
        }
 
        foreach (var text in outputExpectation.ForbiddenText)
        {
            Assert.DoesNotContain(text, terminalRecording);
        }
    }
 
    private static readonly CommandOutputExpectation s_dotNetRunOutputExpectation = new(
        RequiredText:
        [
            "error CS1002: ; expected",
            "Build FAILED.",
            "The project could not be built."
        ],
        ForbiddenText:
        [
            RunCommandStrings.RecentAppHostStartupOutput
        ]);
 
    private static readonly CommandOutputExpectation s_dotNetMissingSdkRunOutputExpectation = new(
        RequiredText:
        [
            // https://github.com/microsoft/aspire/issues/19035: the whole point of the fix is that the
            // MSBuild SDK-resolution failure reaches the user. Match on the bare error code because the
            // full sentence ("The SDK 'Missing.AppHost.Sdk' specified could not be found.") is long
            // enough to be wrapped across lines in the terminal recording.
            "MSB4236",
            "The project could not be built."
        ],
        ForbiddenText:
        [
            InteractionServiceStrings.ProjectOptionDoesntExist,
            InteractionServiceStrings.UnbuildableAppHostsDetected
        ]);
 
    private static readonly CommandOutputExpectation s_dotNetStartOutputExpectation = new(
        RequiredText:
        [
            RunCommandStrings.FailedToStartAppHost,
            RunCommandStrings.RecentAppHostStartupOutput,
            "error CS1002: ; expected",
            "Build FAILED.",
            RunCommandStrings.AppHostFailedToBuild
        ]);
 
    private static readonly CommandOutputExpectation s_typeScriptRunOutputExpectation = new(
        RequiredText:
        [
            "apphost.mts(1,15): error TS1109: Expression expected.",
            "The TypeScript (Node.js) apphost failed."
        ],
        ForbiddenText:
        [
            RunCommandStrings.RecentAppHostStartupOutput,
            "Executing:"
        ]);
 
    private static readonly CommandOutputExpectation s_typeScriptStartOutputExpectation = new(
        RequiredText:
        [
            RunCommandStrings.FailedToStartAppHost,
            RunCommandStrings.RecentAppHostStartupOutput,
            "apphost.mts(1,15): error TS1109: Expression expected.",
            "AppHost process exited with code 2."
        ],
        ForbiddenText:
        [
            "Executing:",
            "audited",
            "funding"
        ]);
 
    private static void WriteBrokenDotNetAppHost(string projectDirectory)
    {
        var appHostPath = Path.Combine(projectDirectory, "apphost.cs");
        var aspireSdkVersion = GetAspireSdkVersion(appHostPath);
 
        File.WriteAllText(Path.Combine(projectDirectory, "BrokenDotNetApp.csproj"), $$"""
            <Project Sdk="Aspire.AppHost.Sdk/{{aspireSdkVersion}}">
              <PropertyGroup>
                <OutputType>Exe</OutputType>
                <TargetFramework>net10.0</TargetFramework>
                <ImplicitUsings>enable</ImplicitUsings>
                <Nullable>enable</Nullable>
              </PropertyGroup>
            </Project>
            """);
 
        File.WriteAllText(appHostPath, """
            var builder = DistributedApplication.CreateBuilder(args);
 
            builder.AddParameter("example", "value")
 
            var app = builder.Build();
            await app.RunAsync();
            """);
    }
 
    private static void WriteDotNetAppHostWithMissingSdk(string projectDirectory)
    {
        File.WriteAllText(Path.Combine(projectDirectory, "MissingSdkAppHost.csproj"), """
            <Project Sdk="Missing.AppHost.Sdk">
              <PropertyGroup>
                <OutputType>Exe</OutputType>
                <TargetFramework>net10.0</TargetFramework>
              </PropertyGroup>
            </Project>
            """);
    }
 
    private static string GetAspireSdkVersion(string appHostPath)
    {
        var firstLine = File.ReadLines(appHostPath).First();
        const string versionMarker = "Aspire.AppHost.Sdk@";
        var markerIndex = firstLine.IndexOf(versionMarker, StringComparison.Ordinal);
        Assert.True(markerIndex >= 0, $"Expected {appHostPath} to start with an Aspire.AppHost.Sdk directive.");
 
        return firstLine[(markerIndex + versionMarker.Length)..].Trim();
    }
 
    private static void WriteBrokenTypeScriptAppHost(string projectDirectory)
    {
        File.WriteAllText(Path.Combine(projectDirectory, "apphost.mts"), "const value = ;");
    }
 
    private sealed record CommandOutputExpectation(string[] RequiredText, string[] ForbiddenText)
    {
        public CommandOutputExpectation(string[] RequiredText)
            : this(RequiredText, [])
        {
        }
    }
}