File: TypeScriptDotnetProjectBuildConfigurationTests.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 Aspire.Cli.EndToEnd.Tests.Helpers;
using Hex1b.Automation;
using Xunit;
 
namespace Aspire.Cli.EndToEnd.Tests;
 
/// <summary>
/// End-to-end coverage for the build configuration used by .NET projects launched from a TypeScript AppHost.
/// </summary>
public sealed class TypeScriptDotnetProjectBuildConfigurationTests(ITestOutputHelper output)
{
    // Regression coverage for https://github.com/microsoft/aspire/issues/16444: the prebuilt AppHost server ships
    // as Release, and its assembly configuration used to be forwarded as --configuration Release to user projects.
    [Fact]
    [CaptureWorkspaceOnFailure]
    public async Task DotnetProjectFromTypeScriptAppHostDoesNotUseAppHostServerBuildConfiguration()
    {
        var repoRoot = CliE2ETestHelpers.GetRepoRoot();
        var strategy = CliInstallStrategy.Detect(output.WriteLine);
 
        if (strategy.Mode == CliInstallMode.InstallScript && strategy.Quality is null && strategy.Version is null)
        {
            Assert.Skip("This test validates unreleased AppHost server behavior. Build a local Aspire CLI bundle or run in CI so the test uses current PR bits instead of the GA CLI.");
        }
 
        using var workspace = TemporaryWorkspace.Create(output);
        var localChannel = CliE2ETestHelpers.PrepareLocalChannel(
            repoRoot,
            strategy,
            [
                "Aspire.Hosting.CodeGeneration.TypeScript.",
                "Aspire.Hosting.Dotnet."
            ]);
 
        using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(
            repoRoot,
            strategy,
            output,
            variant: CliE2ETestHelpers.DockerfileVariant.DotNet,
            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);
        await auto.RunCommandAsync(
            "aspire init --language typescript --non-interactive --suppress-agent-init",
            counter,
            TimeSpan.FromMinutes(2));
 
        if (localChannel is not null)
        {
            CliE2ETestHelpers.WriteLocalChannelSettings(workspace.WorkspaceRoot.FullName, localChannel.SdkVersion);
        }
 
        await auto.TypeAsync("aspire add Aspire.Hosting.Dotnet --non-interactive");
        await auto.EnterAsync();
        await auto.WaitForAspireAddCompletionAsync(counter, TimeSpan.FromMinutes(2));
 
        await auto.RunCommandAsync(
            "dotnet new web --name Api --output Api",
            counter,
            TimeSpan.FromMinutes(2));
 
        var appHostPath = Path.Combine(workspace.WorkspaceRoot.FullName, "apphost.mts");
        await File.WriteAllTextAsync(appHostPath, """
            import { createBuilder } from './.aspire/modules/aspire.mjs';
 
            const builder = await createBuilder();
 
            await builder.addDotnetProject('api', './Api/Api.csproj');
 
            await builder.build().run();
            """);
 
        // The project must run with the SDK default (Debug) rather than the AppHost server's Release configuration.
        // The project.configuration property is parsed from the dotnet launch arguments, and the bin output shows
        // which configuration was actually built.
        var verificationScriptPath = Path.Combine(workspace.WorkspaceRoot.FullName, "verify-build-configuration.sh");
        var verificationScript = """
            #!/usr/bin/env bash
            set -euo pipefail
 
            cleanup() {
                aspire stop --non-interactive >/dev/null 2>&1 || true
            }
            trap cleanup EXIT
 
            ASPIRE_CLI_START_TIMEOUT=180 aspire start --non-interactive --format json > aspire-start.json
            aspire wait api --status up --timeout 240
            aspire describe api --format json > api.json
 
            configuration=$(jq -r '.resources[0].properties["project.configuration"] // empty' api.json)
            echo "project.configuration='$configuration'"
            if [ -n "$configuration" ] && [ "$configuration" != "Debug" ]; then
                echo "Expected the project to use the default Debug configuration but it used '$configuration'."
                exit 1
            fi
 
            if [ -d Api/bin/Release ]; then
                echo "Api/bin/Release exists: the project was built with the AppHost server's Release configuration."
                exit 1
            fi
 
            if [ ! -d Api/bin/Debug ]; then
                echo "Api/bin/Debug does not exist: the project was not built with the default Debug configuration."
                exit 1
            fi
 
            echo "BUILD_CONFIGURATION_OK"
            """;
        await File.WriteAllTextAsync(verificationScriptPath, verificationScript.ReplaceLineEndings("\n"));
 
        try
        {
            await auto.RunCommandAsync(
                "bash verify-build-configuration.sh",
                counter,
                TimeSpan.FromMinutes(10));
        }
        catch (InvalidOperationException)
        {
            // RunCommandAsync observes the error prompt before throwing but leaves the sequence counter
            // unchanged. Advance it so TerminalRun can capture diagnostics from the next prompt.
            counter.Increment();
            throw;
        }
    }
}