File: RadiusPublishTests.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;
using Aspire.Cli.EndToEnd.Tests.Helpers;
using Hex1b.Automation;
using Xunit;
 
namespace Aspire.Cli.EndToEnd.Tests;
 
/// <summary>
/// Lightweight end-to-end coverage for publishing an AppHost that targets a
/// Radius compute environment (see <c>Aspire.Hosting.Radius</c>). Unlike the
/// in-proc unit/snapshot tests, this exercises the full CLI → AppHost build →
/// publish-pipeline path, but stays cheap and deterministic: <c>aspire publish</c>
/// stops at generating <c>app.bicep</c> + <c>bicepconfig.json</c>, so no <c>rad</c>
/// CLI and no Kubernetes cluster are required.
///
/// Modeled on <see cref="KubernetesPublishRequiresExternalEndpointTests"/> (the
/// lightest "run aspire publish, assert output artifacts" precedent) rather than
/// <c>KubernetesPublishTests</c>, which additionally spins up KinD + Helm + Docker.
/// Each test class runs as a separate CI job for parallelization.
/// </summary>
public sealed class RadiusPublishTests(ITestOutputHelper output)
{
    private const string ProjectName = "AspireRadiusPublishTest";
 
    [Fact]
    [CaptureWorkspaceOnFailure]
    public async Task PublishWithRadiusEnvironment_EmitsExpectedArtifacts()
    {
        var repoRoot = CliE2ETestHelpers.GetRepoRoot();
        var strategy = CliInstallStrategy.Detect(output.WriteLine);
        using var workspace = TemporaryWorkspace.Create(output);
 
        // No docker socket mount: publish only generates Bicep, it does not need a
        // container daemon inside the workload (unlike KubernetesPublishTests).
        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);
 
        // Use the Empty AppHost template, not the Starter. The Radius publisher
        // fails on ProjectResources that have no attached container image, and the
        // Starter ships projects. The empty template scaffolds a single-file
        // file-based apphost.cs whose only body is `builder.Build().Run();`, so we
        // add exactly the resources we want. AspireNewCSharpEmptyAppHostAsync is used
        // (rather than the generic AspireNewAsync) because it robustly handles the
        // language + agent-init prompts instead of racing on a short language-prompt
        // wait.
        await auto.AspireNewCSharpEmptyAppHostAsync(ProjectName, counter);
 
        // cd into the project so `aspire add` and `aspire publish` resolve the
        // AppHost via repo-root discovery.
        await auto.TypeAsync($"cd {ProjectName}");
        await auto.EnterAsync();
        await auto.WaitForSuccessPromptAsync(counter);
 
        // Add the Radius hosting package. `aspire add` resolves the version against
        // the same feed configuration the rest of the CLI uses (including PR builds),
        // matching how KubernetesPublishRequiresExternalEndpointTests adds Kubernetes.
        await auto.TypeAsync("aspire add Aspire.Hosting.Radius");
        await auto.EnterAsync();
        await auto.WaitForAspireAddCompletionAsync(counter, TimeSpan.FromSeconds(180));
 
        // Patch the AppHost in-place. AspireNewCSharpEmptyAppHostAsync scaffolds a
        // single-file, file-based AppHost at `<ProjectName>/apphost.cs` (not a
        // `<ProjectName>.AppHost/AppHost.cs` project — that is the Starter layout).
        // Its body is just `builder.Build().Run();`, so we insert the Radius wiring
        // immediately before it. AddRadiusEnvironment and AddContainer are NOT
        // [Experimental], so the AppHost compiles without any ASPIRERADIUS*
        // suppression (cloud providers and WithContainerImage are experimental and
        // intentionally out of scope). A registry-less image ("nginx") only produces
        // a publisher WARN, not a failure. Failing to find the marker surfaces as a
        // clear test failure rather than a silently no-op publish.
        var appHostFilePath = Path.Combine(
            workspace.WorkspaceRoot.FullName,
            ProjectName,
            "apphost.cs");
        var content = File.ReadAllText(appHostFilePath);
        const string buildRunPattern = "builder.Build().Run();";
        Assert.Contains(buildRunPattern, content);
        const string radiusWiring = """
            builder.AddRadiusEnvironment("radius");
            builder.AddContainer("web", "nginx");
            """;
        content = content.Replace(buildRunPattern, radiusWiring + Environment.NewLine + Environment.NewLine + buildRunPattern);
        File.WriteAllText(appHostFilePath, content);
 
        // ASPIRE_PLAYGROUND=true takes precedence over --non-interactive and makes
        // Spectre.Console attempt concurrent dynamic displays. See
        // KubernetesPublishTests for full context.
        await auto.TypeAsync("unset ASPIRE_PLAYGROUND");
        await auto.EnterAsync();
        await auto.WaitForSuccessPromptAsync(counter);
 
        // Drive aspire publish. On success the pipeline exits 0 and
        // WaitForSuccessPromptAsync matches the ` OK] $ ` prompt (it throws on an
        // ` ERR:` prompt), so this both waits for completion and asserts exit 0.
        await auto.TypeAsync("aspire publish -o radius-output --non-interactive");
        await auto.EnterAsync();
        await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromMinutes(5));
 
        // Assert the emitted artifact shape (smoke-level, not golden-file). With a
        // single compute environment the publisher writes flat root output, so we
        // assert exact paths rather than searching recursively — a nested or
        // mislocated layout should fail the test.
        var outputDir = Path.Combine(workspace.WorkspaceRoot.FullName, ProjectName, "radius-output");
 
        var appBicepPath = Path.Combine(outputDir, "app.bicep");
        Assert.True(File.Exists(appBicepPath), $"Expected generated Bicep at '{appBicepPath}'.");
        var appBicep = File.ReadAllText(appBicepPath);
        // The Radius Bicep extension must be declared first, e.g. `extension radius`.
        Assert.StartsWith("extension radius", appBicep.TrimStart());
        // The environment publisher ran.
        Assert.Contains("Radius.Core/environments", appBicep);
        // The container translation ran — without this the test would still pass if
        // AddContainer were silently omitted.
        Assert.Contains("Radius.Compute/containers", appBicep);
        Assert.Contains("nginx", appBicep);
 
        var bicepConfigPath = Path.Combine(outputDir, "bicepconfig.json");
        Assert.True(File.Exists(bicepConfigPath), $"Expected generated bicepconfig.json at '{bicepConfigPath}'.");
        // Parse the config and confirm it pins the Radius Bicep extension, e.g.:
        //   { "extensions": { "radius": "br:biceptypes.azurecr.io/radius:0.59" } }
        // Parsing (rather than a raw substring check) guards against malformed JSON
        // or a missing version tag. The exact version is intentionally not pinned
        // here to avoid churn when the Radius extension version bumps.
        using var bicepConfig = JsonDocument.Parse(File.ReadAllText(bicepConfigPath));
        var radiusExtension = bicepConfig.RootElement
            .GetProperty("extensions")
            .GetProperty("radius")
            .GetString();
        const string radiusExtensionPrefix = "br:biceptypes.azurecr.io/radius:";
        Assert.StartsWith(radiusExtensionPrefix, radiusExtension);
        // Ensure a version tag actually follows the prefix (e.g. `...:0.59`); a bare
        // prefix with no version would otherwise satisfy StartsWith. The exact
        // version is intentionally not pinned to avoid churn when the pin bumps.
        Assert.NotEmpty(radiusExtension![radiusExtensionPrefix.Length..]);
    }
}