File: TypeScriptEmptyAppHostTemplateTests.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 Hex1b.Automation;
using Xunit;
 
namespace Aspire.Cli.EndToEnd.Tests;
 
/// <summary>
/// End-to-end tests for the TypeScript Empty AppHost template (aspire-ts-empty).
/// Validates that aspire new creates a working TypeScript AppHost project
/// and that aspire start runs it successfully.
/// </summary>
public sealed class TypeScriptEmptyAppHostTemplateTests(ITestOutputHelper output)
{
    [Fact]
    [CaptureWorkspaceOnFailure]
    public async Task CreateAndRunTypeScriptEmptyAppHostProjectWithDevelopmentCertificate()
    {
        var repoRoot = CliE2ETestHelpers.GetRepoRoot();
        var strategy = CliInstallStrategy.Detect(output.WriteLine);
        var workspace = TemporaryWorkspace.Create(output);
 
        using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, mountDockerSocket: true, 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.AspireNewAsync("TsEmptyApp", counter, template: AspireTemplate.TypeScriptEmptyAppHost);
 
        var appDirectory = Path.Combine(workspace.WorkspaceRoot.FullName, "TsEmptyApp");
        GitIgnoreAssertions.AssertContainsEntry(appDirectory, ".aspire/");
 
        var appHostPath = Path.Combine(appDirectory, "apphost.mts");
        var appHostContents = await File.ReadAllTextAsync(appHostPath, TestContext.Current.CancellationToken);
        appHostContents = appHostContents
            .Replace(
                "import { createBuilder } from './.aspire/modules/aspire.mjs';",
                """
                import { writeFileSync } from 'node:fs';
                import { createBuilder } from './.aspire/modules/aspire.mjs';
                """,
                StringComparison.Ordinal)
            .Replace(
                "const builder = await createBuilder();",
                """
                writeFileSync('node-extra-ca-certs.txt', process.env.NODE_EXTRA_CA_CERTS ?? '');
 
                const builder = await createBuilder();
                """,
                StringComparison.Ordinal);
        await File.WriteAllTextAsync(appHostPath, appHostContents, TestContext.Current.CancellationToken);
 
        // Start the empty TypeScript AppHost to verify the scaffolded project works
        await auto.TypeAsync("cd TsEmptyApp");
        await auto.EnterAsync();
        await auto.WaitForSuccessPromptAsync(counter);
 
        await auto.RunCommandAsync("npm run build", counter, TimeSpan.FromMinutes(2));
 
        await auto.AspireStartAsync(counter);
        await auto.RunCommandAsync(
            "CERT_PATH=$(cat node-extra-ca-certs.txt) && test -s \"$CERT_PATH\" && grep -q -- '-----BEGIN CERTIFICATE-----' \"$CERT_PATH\"",
            counter);
        await auto.AspireStopAsync(counter);
    }
 
    [Fact]
    [CaptureWorkspaceOnFailure]
    public async Task TypeScriptAppHostRunDoesNotDeadlockWhenLazyOptionsInvokeAsyncCallback()
    {
        var repoRoot = CliE2ETestHelpers.GetRepoRoot();
        var strategy = CliInstallStrategy.Detect(output.WriteLine);
        var workspace = TemporaryWorkspace.Create(output);
 
        using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, mountDockerSocket: true, 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, enableDcpDiagnostics: true);
        await auto.InstallAspireCliAsync(strategy, counter);
 
        await auto.AspireNewTypeScriptEmptyAppHostAsync("TsDeadlockRepro", counter);
 
        var appDirectory = Path.Combine(workspace.WorkspaceRoot.FullName, "TsDeadlockRepro");
        WriteDeadlockReproFiles(appDirectory);
 
        await auto.RunCommandAsync("cd TsDeadlockRepro", counter);
        await auto.RunCommandAsync("aspire restore --non-interactive", counter, TimeSpan.FromMinutes(3));
        await auto.RunCommandAsync("npm run build", counter, TimeSpan.FromMinutes(2));
 
        await auto.AspireStartAsync(counter, startTimeout: TimeSpan.FromMinutes(2));
        await auto.AspireStopAsync(counter);
    }
 
    private static void WriteDeadlockReproFiles(string appDirectory)
    {
        var sdkVersion = GetSdkVersion(appDirectory);
        var extensionDirectory = Directory.CreateDirectory(Path.Combine(appDirectory, "DeadlockExtension"));
 
        File.WriteAllText(Path.Combine(extensionDirectory.FullName, "DeadlockExtension.csproj"), $$"""
            <Project Sdk="Microsoft.NET.Sdk">
              <PropertyGroup>
                <TargetFramework>net10.0</TargetFramework>
                <ImplicitUsings>enable</ImplicitUsings>
                <Nullable>enable</Nullable>
                <NoWarn>$(NoWarn);ASPIREATS001</NoWarn>
              </PropertyGroup>
 
              <ItemGroup>
                <PackageReference Include="Aspire.Hosting" Version="{{sdkVersion}}" />
              </ItemGroup>
            </Project>
            """);
 
        File.WriteAllText(Path.Combine(extensionDirectory.FullName, "DeadlockExtensions.cs"), """
            using Aspire.Hosting;
            using Aspire.Hosting.ApplicationModel;
            using Microsoft.Extensions.DependencyInjection;
            using Microsoft.Extensions.Options;
 
            namespace DeadlockExtension;
 
            public static class DeadlockExtensions
            {
                [AspireExport(RunSyncOnBackgroundThread = true)]
                public static IDistributedApplicationBuilder AddLazyOptionsDeadlockRepro(
                    this IDistributedApplicationBuilder builder,
                    Action<DeadlockOptions>? configure = null)
                {
                    builder.Services.AddOptions<DeadlockOptions>()
                        .Configure(options => configure?.Invoke(options));
 
                    builder.Eventing.Subscribe<BeforeStartEvent>((@event, _) =>
                    {
                        var options = @event.Services.GetRequiredService<IOptions<DeadlockOptions>>().Value;
                        if (options.SomeProperty is not "value-from-typescript")
                        {
                            throw new InvalidOperationException($"Expected TypeScript callback to set SomeProperty, but got '{options.SomeProperty ?? "<null>"}'.");
                        }
 
                        return Task.CompletedTask;
                    });
 
                    return builder;
                }
            }
 
            [AspireExport(ExposeProperties = true)]
            public sealed class DeadlockOptions
            {
                public string? SomeProperty { get; set; }
            }
            """);
 
        File.WriteAllText(Path.Combine(appDirectory, "apphost.mts"), """
            import { createBuilder } from './.aspire/modules/aspire.mjs';
 
            const builder = await createBuilder();
 
            await builder.addLazyOptionsDeadlockRepro({
                configure: async (options) => {
                    await options.someProperty.set("value-from-typescript");
                }
            });
 
            await builder.build().run();
            """);
 
        var configPath = Path.Combine(appDirectory, "aspire.config.json");
        var config = JsonNode.Parse(File.ReadAllText(configPath))?.AsObject()
            ?? throw new InvalidOperationException($"Unable to read {configPath}.");
        config["packages"] = new JsonObject
        {
            ["DeadlockExtension"] = "DeadlockExtension/DeadlockExtension.csproj"
        };
        File.WriteAllText(configPath, config.ToJsonString(new JsonSerializerOptions { WriteIndented = true }));
    }
 
    private static string GetSdkVersion(string appDirectory)
    {
        var configPath = Path.Combine(appDirectory, "aspire.config.json");
        using var doc = JsonDocument.Parse(File.ReadAllText(configPath));
        return doc.RootElement.GetProperty("sdk").GetProperty("version").GetString()
            ?? throw new InvalidOperationException("Expected aspire.config.json to contain sdk.version.");
    }
}