File: ProjectReferenceTests.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 System.Text.Json.Nodes;
using Aspire.Cli.EndToEnd.Tests.Helpers;
using Aspire.Cli.Resources;
using Aspire.Cli.Tests.Utils;
using Hex1b.Automation;
using Xunit;
 
namespace Aspire.Cli.EndToEnd.Tests;
 
/// <summary>
/// End-to-end test for polyglot project reference support.
/// Creates a .NET hosting integration project and a TypeScript AppHost that references it
/// via <c>aspire.config.json</c>, then verifies the integration is discovered, code-generated, and functional.
/// </summary>
public sealed class ProjectReferenceTests(ITestOutputHelper output)
{
    [Fact]
    public async Task TypeScriptAppHostWithProjectReferenceIntegration()
    {
        var repoRoot = CliE2ETestHelpers.GetRepoRoot();
        var installMode = CliE2ETestHelpers.DetectDockerInstallMode(repoRoot);
        var workspace = TemporaryWorkspace.Create(output);
 
        using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, installMode, output, mountDockerSocket: true, workspace: workspace);
 
        var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken);
 
        var counter = new SequenceCounter();
        var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500));
 
        await auto.PrepareDockerEnvironmentAsync(counter, workspace);
 
        await auto.InstallAspireCliInDockerAsync(installMode, counter);
 
        // Step 1: Create a TypeScript AppHost (so we get the SDK version in aspire.config.json)
        await auto.TypeAsync("aspire init --language typescript --non-interactive");
        await auto.EnterAsync();
        await auto.WaitUntilTextAsync("Created apphost.ts", timeout: TimeSpan.FromMinutes(2));
        await auto.WaitForSuccessPromptAsync(counter);
 
        // Step 2: Create the integration project, update aspire.config.json, and modify apphost.ts
        {
            var workDir = workspace.WorkspaceRoot.FullName;
 
            // Read the SDK version from the aspire.config.json that aspire init created.
            var configPath = Path.Combine(workDir, "aspire.config.json");
            var configJson = File.ReadAllText(configPath);
            using var doc = JsonDocument.Parse(configJson);
            var sdkVersion = doc.RootElement.GetProperty("sdk").GetProperty("version").GetString()!;
 
            // Create the .NET hosting integration project
            var integrationDir = Path.Combine(workDir, "MyIntegration");
            Directory.CreateDirectory(integrationDir);
 
            File.WriteAllText(Path.Combine(integrationDir, "MyIntegration.csproj"), $$"""
                <Project Sdk="Microsoft.NET.Sdk">
                  <PropertyGroup>
                    <TargetFramework>net10.0</TargetFramework>
                    <NoWarn>$(NoWarn);ASPIREATS001</NoWarn>
                  </PropertyGroup>
                  <ItemGroup>
                    <PackageReference Include="Aspire.Hosting" Version="{{sdkVersion}}" />
                  </ItemGroup>
                </Project>
                """);
 
            // Write a nuget.config in the workspace root so MyIntegration.csproj can resolve
            // Aspire.Hosting from the configured channel's package source (hive or feed).
            // Without this, NuGet walks up from MyIntegration/ and finds no config pointing
            // to the hive, falling back to nuget.org which doesn't have prerelease versions.
            var aspireHome = Path.Combine(
                Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".aspire");
            var hivesDir = Path.Combine(aspireHome, "hives");
            if (Directory.Exists(hivesDir))
            {
                var hiveDirs = Directory.GetDirectories(hivesDir);
                var sourceLines = new List<string> { """<add key="nuget.org" value="https://api.nuget.org/v3/index.json" />""" };
                foreach (var hiveDir in hiveDirs)
                {
                    var packagesDir = Path.Combine(hiveDir, "packages");
                    if (Directory.Exists(packagesDir))
                    {
                        var hiveName = Path.GetFileName(hiveDir);
                        sourceLines.Insert(0, $"""<add key="hive-{hiveName}" value="{packagesDir}" />""");
                    }
                }
                var nugetConfig = $"""
                    <?xml version="1.0" encoding="utf-8"?>
                    <configuration>
                      <packageSources>
                        <clear />
                        {string.Join("\n        ", sourceLines)}
                      </packageSources>
                    </configuration>
                    """;
                File.WriteAllText(Path.Combine(workDir, "nuget.config"), nugetConfig);
            }
 
            File.WriteAllText(Path.Combine(integrationDir, "MyIntegrationExtensions.cs"), """
                using Aspire.Hosting;
                using Aspire.Hosting.ApplicationModel;
 
                namespace Aspire.Hosting;
 
                public static class MyIntegrationExtensions
                {
                    [AspireExport("addMyService")]
                    public static IResourceBuilder<ContainerResource> AddMyService(
                        this IDistributedApplicationBuilder builder, string name)
                        => builder.AddContainer(name, "redis", "latest");
                }
                """);
 
            // Update aspire.config.json to add the project reference.
            var config = JsonNode.Parse(configJson)?.AsObject()
                ?? throw new InvalidOperationException("Expected aspire.config.json to contain a JSON object.");
            var packages = config["packages"] as JsonObject ?? new JsonObject();
            packages["MyIntegration"] = "./MyIntegration/MyIntegration.csproj";
            config["packages"] = packages;
 
            var updatedJson = config.ToJsonString(new JsonSerializerOptions { WriteIndented = true });
            File.WriteAllText(configPath, updatedJson);
 
            // Delete the generated .modules folder to force re-codegen with the new integration
            var modulesDir = Path.Combine(workDir, ".modules");
            if (Directory.Exists(modulesDir))
            {
                Directory.Delete(modulesDir, recursive: true);
            }
 
            // Update apphost.ts to use the custom integration
            File.WriteAllText(Path.Combine(workDir, "apphost.ts"), """
                import { createBuilder } from './.modules/aspire.js';
 
                const builder = await createBuilder();
                await builder.addMyService("my-svc");
                await builder.build().run();
                """);
        }
 
        // Step 3: Start the AppHost (triggers project ref build + codegen)
        await auto.TypeAsync("aspire start --non-interactive 2>&1 | tee /tmp/aspire-start-output.txt");
        await auto.EnterAsync();
        await auto.WaitUntilAsync(s =>
        {
            if (s.ContainsText(RunCommandStrings.AppHostFailedToBuild))
            {
                // Dump child logs before failing
                return true;
            }
            return s.ContainsText(RunCommandStrings.AppHostStartedSuccessfully);
        }, timeout: TimeSpan.FromMinutes(2), description: "waiting for apphost start success or failure");
        await auto.WaitForSuccessPromptAsync(counter);
 
        // If start failed, dump the child log for debugging before the test fails
        await auto.TypeAsync("CHILD_LOG=$(ls -t ~/.aspire/logs/cli_*detach*.log 2>/dev/null | head -1) && if [ -n \"$CHILD_LOG\" ]; then echo '=== CHILD LOG ==='; cat \"$CHILD_LOG\"; echo '=== END CHILD LOG ==='; fi");
        await auto.EnterAsync();
        await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(10));
 
        // Step 4: Verify the custom integration was code-generated
        await auto.TypeAsync("grep addMyService .modules/aspire.ts");
        await auto.EnterAsync();
        await auto.WaitUntilTextAsync("addMyService", timeout: TimeSpan.FromSeconds(5));
        await auto.WaitForSuccessPromptAsync(counter);
 
        // Step 5: Wait for the custom resource to be up
        await auto.TypeAsync("aspire wait my-svc --timeout 60");
        await auto.EnterAsync();
        await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(90));
 
        // Step 6: Verify the resource appears in describe
        await auto.TypeAsync("aspire describe my-svc --format json > /tmp/my-svc-describe.json");
        await auto.EnterAsync();
        await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(15));
        await auto.TypeAsync("cat /tmp/my-svc-describe.json");
        await auto.EnterAsync();
        await auto.WaitUntilTextAsync("my-svc", timeout: TimeSpan.FromSeconds(5));
        await auto.WaitForSuccessPromptAsync(counter);
 
        // Step 7: Clean up
        await auto.TypeAsync("aspire stop");
        await auto.EnterAsync();
        await auto.WaitForSuccessPromptAsync(counter);
        await auto.TypeAsync("exit");
        await auto.EnterAsync();
 
        await pendingRun;
    }
}