File: SingleFileAppHostInitDotnetRunTests.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.Json.Nodes;
using Aspire.Cli.EndToEnd.Tests.Helpers;
using Hex1b.Automation;
using Hex1b.Input;
using Xunit;
 
namespace Aspire.Cli.EndToEnd.Tests;
 
/// <summary>
/// Regression test for https://github.com/microsoft/aspire/issues/15986.
/// </summary>
/// <remarks>
/// <para>
/// Verifies that after interactive <c>aspire init</c>'s C# single-file path runs,
/// <c>dotnet run apphost.cs</c> can launch the AppHost successfully — i.e. the
/// launch-profile environment variables required by the dashboard / OTLP / resource
/// service are wired up correctly via the generated <c>apphost.run.json</c>.
/// </para>
/// <para>
/// Before the fix, <c>aspire init</c> wrote <c>apphost.cs</c>, <c>aspire.config.json</c>,
/// and <c>NuGet.config</c> but skipped <c>apphost.run.json</c>. The .NET 10 file-based
/// runner only honours <c>[file].run.json</c> for launch profiles (it ignores
/// <c>aspire.config.json</c>), so without it <c>dotnet run apphost.cs</c> crashed at
/// startup with <c>OptionsValidationException</c> complaining that
/// <c>ASPNETCORE_URLS</c> and <c>ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL</c> were not set.
/// </para>
/// </remarks>
public sealed class SingleFileAppHostInitDotnetRunTests(ITestOutputHelper output)
{
    [CaptureWorkspaceOnFailure]
    [Fact]
    public async Task AspireInitSingleFileAppHostRunsViaDotnetRunAppHost()
    {
        var repoRoot = CliE2ETestHelpers.GetRepoRoot();
        var strategy = CliInstallStrategy.Detect(output.WriteLine);
        var workspace = TemporaryWorkspace.Create(output);
 
        using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, mountDockerSocket: false, 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);
 
        // Run aspire init without --language so the interactive language prompt is shown,
        // then accept the default '> C#' selection.
        await auto.TypeAsync("aspire init");
        await auto.EnterAsync();
 
        await auto.WaitUntilAsync(
            s => new CellPatternSearcher().Find("> C#").Search(s).Count > 0,
            timeout: TimeSpan.FromSeconds(30),
            description: "language selection prompt with default '> C#'");
        await auto.EnterAsync();
 
        await auto.WaitUntilTextAsync("Created aspire.config.json", timeout: TimeSpan.FromMinutes(2));
        await auto.DeclineAgentInitPromptAsync(counter);
 
        // The workspace directory is bind-mounted into the container, so we can read
        // the files generated by `aspire init` from the host side without leaving the
        // shell session.
        var appHostCs = Path.Combine(workspace.WorkspaceRoot.FullName, "apphost.cs");
        var aspireConfigJson = Path.Combine(workspace.WorkspaceRoot.FullName, "aspire.config.json");
        var appHostRunJson = Path.Combine(workspace.WorkspaceRoot.FullName, "apphost.run.json");
 
        Assert.True(File.Exists(appHostCs), $"Expected apphost.cs to exist at: {appHostCs}");
        Assert.True(File.Exists(aspireConfigJson), $"Expected aspire.config.json to exist at: {aspireConfigJson}");
        Assert.Contains("#:property AspireUseCliBundle=true", File.ReadAllText(appHostCs));
        Assert.True(
            File.Exists(appHostRunJson),
            $"Expected apphost.run.json to exist at: {appHostRunJson}. "
            + "This is the regression tracked by https://github.com/microsoft/aspire/issues/15986: "
            + "without it, `dotnet run apphost.cs` crashes at startup because the dashboard "
            + "environment variables (ASPNETCORE_URLS, ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL) are unset.");
 
        // Sanity-check that the launch profile in apphost.run.json carries the dashboard
        // env vars that the file-based runner needs to inject. Full schema is covered by
        // InitCommand_SingleFileSkeleton_CreatesAppHostRunJsonWithDashboardEnvVars.
        var runJson = JsonNode.Parse(File.ReadAllText(appHostRunJson))?.AsObject();
        Assert.NotNull(runJson);
        var httpsProfile = runJson["profiles"]?["https"]?.AsObject();
        Assert.NotNull(httpsProfile);
        Assert.Equal("Project", httpsProfile["commandName"]?.GetValue<string>());
        Assert.False(string.IsNullOrWhiteSpace(httpsProfile["applicationUrl"]?.GetValue<string>()));
        var httpsEnv = httpsProfile["environmentVariables"]?.AsObject();
        Assert.NotNull(httpsEnv);
        Assert.False(string.IsNullOrWhiteSpace(httpsEnv["ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL"]?.GetValue<string>()));
        Assert.False(string.IsNullOrWhiteSpace(httpsEnv["ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL"]?.GetValue<string>()));
 
        // The generated AppHost opts into the CLI bundle, so `dotnet run` delegates to the
        // CLI and displays its startup message instead of the direct AppHost log message.
        await auto.TypeAsync("dotnet run apphost.cs");
        await auto.EnterAsync();
        await auto.WaitUntilTextAsync(
            "Press CTRL+C to stop the AppHost and exit.",
            timeout: TimeSpan.FromMinutes(1));
 
        // Stop the running AppHost with Ctrl+C and wait for the shell prompt.
        await auto.Ctrl().KeyAsync(Hex1bKey.C);
        await auto.WaitForAnyPromptAsync(counter, TimeSpan.FromMinutes(1));
    }
}