File: AddBlazorGatewayTests.cs
Web Access
Project: src\tests\Aspire.Hosting.Blazor.Tests\Aspire.Hosting.Blazor.Tests.csproj (Aspire.Hosting.Blazor.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.Net;
using System.Text.Json;
using Aspire.Hosting.ApplicationModel;
using Aspire.Hosting.Dcp;
using Aspire.Hosting.Dcp.Model;
using Aspire.Hosting.Publishing;
using Aspire.Hosting.Testing;
using Aspire.Hosting.Tests.Utils;
using Aspire.Hosting.Utils;
using Aspire.TestUtilities;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
 
#pragma warning disable ASPIREDOCKERFILEBUILDER001 // DockerfileBuilder is experimental
#pragma warning disable ASPIREPROJECTS001 // ProjectLaunchArgsOverrideAnnotation is experimental
#pragma warning disable ASPIRECONTAINERRUNTIME001 // Container image build and cleanup are experimental
#pragma warning disable ASPIREPIPELINES003 // Container image manager is experimental
 
namespace Aspire.Hosting.Blazor.Tests;
 
public class AddBlazorGatewayTests(ITestOutputHelper testOutputHelper)
{
    private const string GatewayPackageId = "Microsoft.AspNetCore.Components.Gateway.Cli";
    private const string GatewayPackageVersion = "11.0.0-rc.1.26425.128";
 
    [Fact]
    public void AddBlazorGateway_PreservesProjectResourceApiAndUsesToolForRunMode()
    {
        using var builder = TestDistributedApplicationBuilder.Create(testOutputHelper);
 
        IResourceBuilder<ProjectResource> gateway = builder.AddBlazorGateway("gateway");
 
        Assert.EndsWith(
            Path.Combine("Scripts", "Gateway.cs"),
            gateway.Resource.GetProjectMetadata().ProjectPath);
 
        var executable = Assert.Single(gateway.Resource.Annotations.OfType<ExecutableAnnotation>());
        Assert.Equal("dotnet", executable.Command);
        Assert.Equal(builder.AppHostDirectory, executable.WorkingDirectory);
        Assert.Single(gateway.Resource.Annotations.OfType<ProjectLaunchArgsOverrideAnnotation>());
 
        var initialSnapshot = Assert.Single(gateway.Resource.Annotations.OfType<ResourceSnapshotAnnotation>()).InitialSnapshot;
        var source = Assert.Single(
            initialSnapshot.Properties,
            property => property.Name == CustomResourceKnownProperties.Source);
        Assert.Equal(string.Empty, source.Value);
 
        Assert.Collection(
            gateway.Resource.Annotations.OfType<EndpointAnnotation>().OrderBy(endpoint => endpoint.Name),
            endpoint => Assert.Equal("http", endpoint.Name),
            endpoint => Assert.Equal("https", endpoint.Name));
    }
 
    [Fact]
    public async Task AddBlazorGateway_ConfiguresGatewayToolArguments()
    {
        using var builder = TestDistributedApplicationBuilder.Create(testOutputHelper);
        var gateway = builder.AddBlazorGateway("gateway");
        using var app = builder.Build();
 
        var args = await ArgumentEvaluator.GetArgumentListAsync(gateway.Resource);
 
        Assert.Collection(
            args,
            arg => Assert.Equal("tool", arg),
            arg => Assert.Equal("exec", arg),
            arg => Assert.Equal(GatewayPackageId, arg),
            arg => Assert.Equal("--version", arg),
            arg => Assert.Equal(GatewayPackageVersion, arg),
            arg => Assert.Equal("--yes", arg),
            arg => Assert.Equal("--", arg),
            arg => Assert.Equal("--environment", arg),
            arg => Assert.Equal(builder.Environment.EnvironmentName, arg),
            arg => Assert.Equal("--Logging:LogLevel:Microsoft=Warning", arg),
            arg => Assert.Equal("--Logging:LogLevel:Microsoft.Hosting.Lifetime=Information", arg),
            arg => Assert.Equal("--Logging:LogLevel:System.Net.Http.HttpClient.OtlpExporter=Warning", arg));
    }
 
    [Fact]
    public async Task AddBlazorGateway_RendersCompleteProcessLaunchPlan()
    {
        using var builder = TestDistributedApplicationBuilder.Create(testOutputHelper);
        var gateway = builder.AddBlazorGateway("gateway");
        using var app = builder.Build();
 
        await AssertGatewayProcessLaunchPlanAsync(gateway.Resource, builder, app.Services);
    }
 
    [Fact]
    public async Task AddBlazorGateway_InPublishMode_UsesFileBasedGateway()
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
        IResourceBuilder<ProjectResource> gateway = builder.AddBlazorGateway("gateway");
 
        var container = Assert.Single(builder.Resources.OfType<ContainerResource>());
        Assert.Equal("gateway", container.Name);
 
        var build = Assert.Single(container.Annotations.OfType<DockerfileBuildAnnotation>());
        Assert.NotNull(build.DockerfileFactory);
 
        var context = new DockerfileFactoryContext
        {
            Services = builder.Services.BuildServiceProvider(),
            Resource = container,
            CancellationToken = CancellationToken.None
        };
 
        var dockerfile = await build.DockerfileFactory(context);
 
        await Verify(dockerfile, extension: "Dockerfile");
 
        Assert.Empty(gateway.Resource.Annotations.OfType<ProjectLaunchArgsOverrideAnnotation>());
        Assert.Empty(gateway.Resource.Annotations.OfType<ExecutableAnnotation>());
    }
 
    [Fact]
    public async Task BlazorWasmPublishCompanion_UsesNet11Sdk()
    {
        var dockerfile = BlazorGatewayExtensions.BuildBlazorWasmPublishDockerfile(
            "Blazor/Blazor.csproj",
            ".aspire/scripts/PrefixEndpoints.cs",
            "app",
            "11.0.100-rc.1.26425.128",
            "net11.0");
 
        await Verify(dockerfile, extension: "Dockerfile")
            .AddScrubber(content => content
                .Replace("/tmp/", "{TempPath}")
                .Replace("WORKDIR /tmp", "WORKDIR {TempPath}"));
    }
 
    [Theory]
    [InlineData("8.0.408", "net8.0")]
    [InlineData("9.0.203", "net9.0")]
    [InlineData("10.0.201", "net10.0")]
    [InlineData("11.0.100-rc.1.26425.128", "net11.0")]
    public void BlazorWasmPublishCompanion_InstallsClientSdkAlongsideGatewaySdk(string sdkVersion, string framework)
    {
        var dockerfile = BlazorGatewayExtensions.BuildBlazorWasmPublishDockerfile(
            "Client/Client.csproj", ".aspire/scripts/PrefixEndpoints.cs", "app", sdkVersion, framework);
 
        Assert.Contains($"--version {sdkVersion} --install-dir /opt/client-dotnet --no-path", dockerfile);
        Assert.Contains($"/opt/client-dotnet/dotnet publish \"Client.csproj\" -f {framework}", dockerfile);
        Assert.Contains("WORKDIR /tmp", dockerfile);
        Assert.Contains("cp \"/src/.aspire/scripts/PrefixEndpoints.cs\" /tmp/PrefixEndpoints.cs", dockerfile);
        Assert.Contains("dotnet run /tmp/PrefixEndpoints.cs", dockerfile);
    }
 
    [Theory]
    [InlineData("")]
    [InlineData("7.0.410")]
    [InlineData("12.0.100")]
    [InlineData("10.0.201;echo")]
    public void BlazorWasmPublishCompanion_RejectsUnsupportedSdk(string sdkVersion)
    {
        Assert.Throws<NotSupportedException>(() => BlazorGatewayExtensions.BuildBlazorWasmPublishDockerfile(
            "Client/Client.csproj", ".aspire/scripts/PrefixEndpoints.cs", "app", sdkVersion, "net10.0"));
    }
 
    [Fact]
    [OuterloopTest("Builds the generated publish image and downloads the pinned client SDK")]
    [RequiresFeature(TestFeature.ContainerRuntime | TestFeature.ContainerImageBuild)]
    public async Task BlazorWasmPublishCompanion_BuildsClientPinnedToOlderSdk()
    {
        using var fileSystem = new TestFileSystemService();
        using var directory = fileSystem.TempDirectory.CreateTempSubdirectory();
        var clientDirectory = Path.Combine(directory.Path, "Client");
        await WriteClientProjectAsync(clientDirectory);
        await File.WriteAllTextAsync(Path.Combine(clientDirectory, "global.json"),
            """{"sdk":{"version":"10.0.201","rollForward":"latestFeature"}}""");
        File.Copy(Path.Combine(clientDirectory, "global.json"), Path.Combine(directory.Path, "global.json"));
        var script = await File.ReadAllTextAsync(
            Path.Combine(Path.GetDirectoryName(typeof(BlazorGatewayExtensions).Assembly.Location)!, "Scripts", "PrefixEndpoints.cs"));
        await File.WriteAllTextAsync(Path.Combine(directory.Path, "PrefixEndpoints.cs"),
            "#:property RestoreSources=https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-public/nuget/v3/index.json\n" + script);
 
        var dockerfile = BlazorGatewayExtensions.BuildBlazorWasmPublishDockerfile(
            "Client/Client.csproj", "PrefixEndpoints.cs", "app", "10.0.201", "net10.0");
        dockerfile += """
 
            RUN test -f /app/output/wwwroot/app/index.html && \
                test -s /app/output/app.endpoints.json && \
                cd /src/Client && /opt/client-dotnet/dotnet --version | grep -Fx 10.0.201 && \
                cd /tmp && dotnet --version | grep '^11\.'
            CMD ["sleep", "infinity"]
            """;
        await File.WriteAllTextAsync(Path.Combine(directory.Path, "Dockerfile"), dockerfile);
 
        using var builder = TestDistributedApplicationBuilder.Create(testOutputHelper);
        builder.AddDockerfile("client-publish", directory.Path);
        using var app = builder.Build();
        using var timeout = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken);
        timeout.CancelAfter(TimeSpan.FromMinutes(10));
        await app.StartAsync(timeout.Token);
        await app.ResourceNotifications.WaitForResourceAsync("client-publish", KnownResourceStates.Running, timeout.Token);
        await app.StopAsync(TestContext.Current.CancellationToken);
    }
 
    [Fact]
    [OuterloopTest("Builds and runs the published client companion and gateway images")]
    [RequiresFeature(TestFeature.ContainerRuntime | TestFeature.ContainerImageBuild)]
    public async Task PublishedBlazorGateway_ServesSpaConfigurationAndApi()
    {
        using var fileSystem = new TestFileSystemService();
        using var directory = fileSystem.TempDirectory.CreateTempSubdirectory();
        var clientDirectory = Path.Combine(directory.Path, "Client");
        await WriteClientProjectAsync(clientDirectory);
        await File.WriteAllTextAsync(Path.Combine(directory.Path, "Test.slnx"), "<Solution />");
        using var publishBuilder = TestDistributedApplicationBuilder.Create(
            options => options.ProjectDirectory = directory.Path, testOutputHelper, "AppHost:Operation=publish");
        var backendAlias = $"weatherapi-{Guid.NewGuid():N}";
        var backend = publishBuilder.AddProject<TestProjectMetadata>("weatherapi")
            .WithHttpEndpoint(targetPort: 80);
        var backendEndpoint = backend.Resource.Annotations.OfType<EndpointAnnotation>().Single();
        backendEndpoint.AllocatedEndpoint = new AllocatedEndpoint(backendEndpoint, backendAlias, 80);
        var client = publishBuilder.AddBlazorWasmApp("app", Path.Combine(clientDirectory, "Client.csproj"))
            .WithReference(backend);
        var gateway = publishBuilder.AddBlazorGateway("gateway")
            .WithBlazorClientApp(client, proxyTelemetry: false);
        gateway.Resource.Annotations.Remove(gateway.Resource.Annotations.OfType<EndpointAnnotation>().Single(endpoint => endpoint.Name == "https"));
 
        var gatewayContainer = publishBuilder.Resources.OfType<ContainerResource>().Single(resource => resource.Name == "gateway");
        var companion = Assert.Single(gateway.Resource.Annotations.OfType<ContainerFilesDestinationAnnotation>()).Source;
        var gatewayBuild = Assert.Single(gatewayContainer.Annotations.OfType<DockerfileBuildAnnotation>());
        var gatewayDirectory = Directory.CreateDirectory(Path.Combine(directory.Path, "Gateway")).FullName;
        const string restoreDirective = "#:property RestoreSources=https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-public/nuget/v3/index.json\n";
        await File.WriteAllTextAsync(Path.Combine(gatewayDirectory, "Gateway.cs"),
            restoreDirective + await File.ReadAllTextAsync(Path.Combine(gatewayBuild.ContextPath, "Gateway.cs")));
        gatewayContainer.Annotations.Remove(gatewayBuild);
        gatewayContainer.Annotations.Add(new DockerfileBuildAnnotation(gatewayDirectory, Path.Combine(gatewayDirectory, "Dockerfile"), stage: null)
        {
            DockerfileFactory = gatewayBuild.DockerfileFactory,
            ImageName = gatewayBuild.ImageName,
            ImageTag = gatewayBuild.ImageTag
        });
        var prefixScriptPath = Path.Combine(directory.Path, ".aspire", "scripts", "PrefixEndpoints.cs");
        await File.WriteAllTextAsync(prefixScriptPath, restoreDirective + await File.ReadAllTextAsync(prefixScriptPath));
 
        using var publishApp = publishBuilder.Build();
        var imageBuilder = publishApp.Services.GetRequiredService<IResourceContainerImageManager>();
        var runtime = await publishApp.Services.GetRequiredService<IContainerRuntimeResolver>().ResolveAsync(TestContext.Current.CancellationToken);
        Assert.True(companion.TryGetContainerImageName(out var clientImage));
        Assert.True(gatewayContainer.TryGetContainerImageName(out var gatewayImage));
        var builtImages = new List<string>();
        try
        {
            using var buildTimeout = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken);
            buildTimeout.CancelAfter(TimeSpan.FromMinutes(10));
            await imageBuilder.BuildImageAsync(companion, buildTimeout.Token);
            builtImages.Add(clientImage);
            await imageBuilder.BuildImageAsync(gatewayContainer, buildTimeout.Token);
            builtImages.Add(gatewayImage);
 
            using var builder = TestDistributedApplicationBuilder.Create(testOutputHelper);
            var api = builder.AddContainer("weatherapi", "nginx", "alpine")
                .WithContainerNetworkAlias(backendAlias)
                .WithHttpEndpoint(targetPort: 80)
                .WithContainerFiles("/usr/share/nginx/html", [
                    new ContainerFile { Name = "forecast.json", Contents = """{"temperatureC":21}""" }
                ])
                .WithHttpHealthCheck("/forecast.json");
            builder.AddContainer("gateway", gatewayImage)
                .WithHttpEndpoint(targetPort: 8080)
                .WithHttpHealthCheck("/app/")
                .WaitFor(api)
                .WithEnvironment(async context =>
                {
                    var endpoint = gateway.Resource.Annotations.OfType<EndpointAnnotation>().Single();
                    endpoint.AllocatedEndpoint = context.Resource.Annotations.OfType<EndpointAnnotation>().Single().AllocatedEndpoint;
                    var configuration = await ExecutionConfigurationBuilder.Create(gatewayContainer)
                        .WithEnvironmentVariablesConfig()
                        .BuildAsync(publishBuilder.ExecutionContext, cancellationToken: context.CancellationToken);
                    if (configuration.Exception is not null)
                    {
                        throw configuration.Exception;
                    }
 
                    foreach (var (key, value) in configuration.EnvironmentVariablesWithUnprocessed)
                    {
                        context.EnvironmentVariables[key] = value.Unprocessed;
                    }
                });
            using var app = builder.Build();
            try
            {
                using var startupTimeout = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken);
                startupTimeout.CancelAfter(TimeSpan.FromMinutes(2));
                await app.StartAsync(startupTimeout.Token);
                await app.ResourceNotifications.WaitForResourceHealthyAsync("gateway", startupTimeout.Token);
 
                using var httpClient = app.CreateHttpClient("gateway", "http");
                using var requestTimeout = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken);
                requestTimeout.CancelAfter(TimeSpan.FromSeconds(30));
                var index = await httpClient.GetStringAsync("/app/", requestTimeout.Token);
                Assert.Contains("blazor.webassembly.js", index);
                Assert.Equal(index, await httpClient.GetStringAsync("/app/weather/today", requestTimeout.Token));
                using var bootScript = await httpClient.GetAsync("/app/_framework/blazor.webassembly.js", requestTimeout.Token);
                bootScript.EnsureSuccessStatusCode();
                using var missingAsset = await httpClient.GetAsync("/app/missing.js", requestTimeout.Token);
                Assert.Equal(HttpStatusCode.NotFound, missingAsset.StatusCode);
                using var configurationResponse = await httpClient.GetAsync("/app/_blazor/_configuration", requestTimeout.Token);
                configurationResponse.EnsureSuccessStatusCode();
                Assert.Equal("application/json", configurationResponse.Content.Headers.ContentType?.MediaType);
                using var configuration = JsonDocument.Parse(await configurationResponse.Content.ReadAsStringAsync(requestTimeout.Token));
                Assert.Equal(
                    new Uri(httpClient.BaseAddress!, "/app/_api/weatherapi").AbsoluteUri,
                    configuration.RootElement.GetProperty("webAssembly").GetProperty("environment")
                        .GetProperty("services__weatherapi__http__0").GetString());
                using var apiResponse = await httpClient.GetAsync("/app/_api/weatherapi/forecast.json", requestTimeout.Token);
                apiResponse.EnsureSuccessStatusCode();
                using var forecast = JsonDocument.Parse(await apiResponse.Content.ReadAsStringAsync(requestTimeout.Token));
                Assert.Equal(21, forecast.RootElement.GetProperty("temperatureC").GetInt32());
                using var missingApi = await httpClient.GetAsync("/app/_api/weatherapi/missing.json", requestTimeout.Token);
                Assert.Equal(HttpStatusCode.NotFound, missingApi.StatusCode);
            }
            finally
            {
                await app.StopAsync(CancellationToken.None);
            }
        }
        finally
        {
            foreach (var image in builtImages.AsEnumerable().Reverse())
            {
                await runtime.RemoveImageAsync(image, CancellationToken.None);
            }
        }
    }
 
    private static async Task WriteClientProjectAsync(string clientDirectory)
    {
        Directory.CreateDirectory(Path.Combine(clientDirectory, "wwwroot"));
        await File.WriteAllTextAsync(Path.Combine(clientDirectory, "Client.csproj"),
            """
            <Project Sdk="Microsoft.NET.Sdk.BlazorWebAssembly">
              <PropertyGroup>
                <TargetFrameworks>net10.0</TargetFrameworks>
                <RestoreSources>https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-public/nuget/v3/index.json</RestoreSources>
              </PropertyGroup>
              <ItemGroup>
                <PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="10.0.0" />
              </ItemGroup>
            </Project>
            """);
        await File.WriteAllTextAsync(Path.Combine(clientDirectory, "Program.cs"),
            """
            using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
            await WebAssemblyHostBuilder.CreateDefault(args).Build().RunAsync();
            """);
        await File.WriteAllTextAsync(Path.Combine(clientDirectory, "wwwroot", "index.html"),
            """<!DOCTYPE html><html><head><base href="/" /></head><body><script src="_framework/blazor.webassembly.js"></script></body></html>""");
    }
 
    [Theory]
    [InlineData("net8.0")]
    [InlineData("net10.0")]
    [InlineData("net11.0")]
    public void ValidateBlazorWasmPublishTargetFramework_AcceptsSupportedFrameworks(string targetFramework)
    {
        BlazorGatewayExtensions.ValidateBlazorWasmPublishTargetFramework(targetFramework);
    }
 
    [Theory]
    [InlineData("net12.0")]
    [InlineData("net10.0;net11.0")]
    [InlineData("netstandard2.1")]
    [InlineData("invalid")]
    public void ValidateBlazorWasmPublishTargetFramework_RejectsUnsupportedFrameworks(string targetFramework)
    {
        Assert.Throws<NotSupportedException>(
            () => BlazorGatewayExtensions.ValidateBlazorWasmPublishTargetFramework(targetFramework));
    }
 
    [Fact]
    public async Task GetPublishPropertiesAsync_UsesReleaseConfiguration()
    {
        var directory = Directory.CreateTempSubdirectory();
        try
        {
            var projectPath = Path.Combine(directory.FullName, "Client.csproj");
            await File.WriteAllTextAsync(
                projectPath,
                """
                <Project Sdk="Microsoft.NET.Sdk">
                  <PropertyGroup>
                    <TargetFramework Condition="'$(Configuration)' == 'Debug'">net12.0</TargetFramework>
                    <TargetFramework Condition="'$(Configuration)' == 'Release'">net11.0</TargetFramework>
                  </PropertyGroup>
                </Project>
                """);
 
            var properties = await BlazorWasmAppBuilder.GetPublishPropertiesAsync(
                projectPath,
                NullLogger.Instance,
                CancellationToken.None);
 
            Assert.NotNull(properties);
            Assert.Equal("net11.0", properties.TargetFramework);
        }
        finally
        {
            directory.Delete(recursive: true);
        }
    }
 
    [Theory]
    [InlineData("net11.0", "net11.0")]
    [InlineData(" ; net11.0 ; ; ", "net11.0")]
    [InlineData("net10.0;net11.0", "net10.0;net11.0")]
    public async Task GetPublishPropertiesAsync_NormalizesSingleEntryTargetFrameworksAndResolvesSdk(string targetFrameworks, string expectedFramework)
    {
        using var fileSystem = new TestFileSystemService();
        using var directory = fileSystem.TempDirectory.CreateTempSubdirectory();
        var projectPath = Path.Combine(directory.Path, "Client.csproj");
        await File.WriteAllTextAsync(projectPath,
            $"""<Project Sdk="Microsoft.NET.Sdk"><PropertyGroup><TargetFrameworks>{targetFrameworks}</TargetFrameworks></PropertyGroup></Project>""");
 
        var properties = await BlazorWasmAppBuilder.GetPublishPropertiesAsync(
            projectPath, NullLogger.Instance, TestContext.Current.CancellationToken);
 
        Assert.NotNull(properties);
        Assert.Equal(expectedFramework, properties.TargetFramework);
        Assert.NotEmpty(properties.NETCoreSdkVersion);
    }
 
    [Fact]
    public void GetSolutionRoot_UsesNearestSolutionAncestor()
    {
        var solutionRoot = Directory.CreateTempSubdirectory();
        try
        {
            File.WriteAllText(Path.Combine(solutionRoot.FullName, "Test.slnx"), "<Solution />");
            var appHostDirectory = Directory.CreateDirectory(Path.Combine(solutionRoot.FullName, "src", "AppHost")).FullName;
 
            var projectDirectory = Directory.CreateDirectory(Path.Combine(solutionRoot.FullName, "src", "Client")).FullName;
 
            Assert.Equal(solutionRoot.FullName, BlazorGatewayExtensions.GetSolutionRoot(appHostDirectory, projectDirectory));
        }
        finally
        {
            solutionRoot.Delete(recursive: true);
        }
    }
 
    [Fact]
    public void GetSolutionRoot_WithoutSolution_Throws()
    {
        var directory = Directory.CreateTempSubdirectory();
        try
        {
            var appHostDirectory = Directory.CreateDirectory(Path.Combine(directory.FullName, "AppHost")).FullName;
 
            var projectDirectory = Directory.CreateDirectory(Path.Combine(directory.FullName, "Client")).FullName;
 
            var exception = Assert.Throws<InvalidOperationException>(
                () => BlazorGatewayExtensions.GetSolutionRoot(appHostDirectory, projectDirectory));
 
            Assert.Contains("requires a .sln or .slnx file", exception.Message);
        }
        finally
        {
            directory.Delete(recursive: true);
        }
    }
 
    [Fact]
    public void GetSolutionRoot_SkipsSolutionThatDoesNotContainProject()
    {
        var directory = Directory.CreateTempSubdirectory();
        try
        {
            var appHostDirectory = Directory.CreateDirectory(Path.Combine(directory.FullName, "AppHost")).FullName;
            File.WriteAllText(Path.Combine(appHostDirectory, "AppHost.slnx"), "<Solution />");
            var projectDirectory = Directory.CreateDirectory(Path.Combine(directory.FullName, "Client")).FullName;
 
            var exception = Assert.Throws<InvalidOperationException>(
                () => BlazorGatewayExtensions.GetSolutionRoot(appHostDirectory, projectDirectory));
 
            Assert.Contains("that also contains the client project", exception.Message);
        }
        finally
        {
            directory.Delete(recursive: true);
        }
    }
 
    [Fact]
    public void WithBlazorClientApp_RunModeGateway_ForwardsServiceReferences()
    {
        using var builder = TestDistributedApplicationBuilder.Create(testOutputHelper);
 
        var weatherApi = builder.AddProject<TestProjectMetadata>("weatherapi")
            .WithHttpEndpoint();
        var wasmApp = builder.AddBlazorWasmApp("store", "Store/Store.csproj")
            .WithReference(weatherApi);
 
        var gateway = builder.AddBlazorGateway("gateway")
            .WithBlazorClientApp(wasmApp);
 
        var endpointReference = Assert.Single(
            gateway.Resource.Annotations.OfType<EndpointReferenceAnnotation>(),
            annotation => annotation.Resource.Name == "weatherapi");
 
        Assert.True(endpointReference.UseAllEndpoints);
        Assert.Same(gateway.Resource, wasmApp.Resource.Parent);
    }
 
    private sealed class TestProjectMetadata : IProjectMetadata
    {
        public string ProjectPath => "TestProject/TestProject.csproj";
 
        public LaunchSettings LaunchSettings { get; } = new();
    }
 
    internal static async Task AssertGatewayProcessLaunchPlanAsync(
        IResource resource,
        IDistributedApplicationBuilder builder,
        IServiceProvider services)
    {
        var executionContext = new DistributedApplicationExecutionContext(
            new DistributedApplicationExecutionContextOptions(DistributedApplicationOperation.Run)
            {
                Services = services
            });
        var executionConfiguration = await ExecutionConfigurationBuilder.Create(resource)
            .WithArgumentsConfig()
            .BuildAsync(executionContext, NullLogger.Instance, CancellationToken.None);
 
        Assert.Null(executionConfiguration.Exception);
 
        var plan = await ExecutableCreator.ResolveLaunchPlanAsync(
            resource,
            executionConfiguration,
            builder.Configuration,
            new DistributedApplicationOptions(),
            new ExecutableLaunchPolicy(builder.Configuration),
            NullLogger.Instance,
            CancellationToken.None);
 
        Assert.Equal(ExecutableLaunchMechanism.Process, plan.Mechanism);
        Assert.Equal("dotnet", plan.Command);
        Assert.Equal(builder.AppHostDirectory, plan.WorkingDirectory);
        Assert.Equal(
            [
                "tool",
                "exec",
                GatewayPackageId,
                "--version",
                GatewayPackageVersion,
                "--yes",
                "--",
                "--environment",
                builder.Environment.EnvironmentName,
                "--Logging:LogLevel:Microsoft=Warning",
                "--Logging:LogLevel:Microsoft.Hosting.Lifetime=Information",
                "--Logging:LogLevel:System.Net.Http.HttpClient.OtlpExporter=Warning"
            ],
            plan.Arguments);
 
        var executable = Executable.Create("gateway-12345678", "stale");
        var renderedResource = new RenderedModelResource<Executable>(resource, executable);
        ExecutableCreator.Render(
            renderedResource,
            plan,
            pemCertificates: null,
            NullLogger<ExecutableCreator>.Instance);
 
        Assert.Equal(ExecutionType.Process, executable.Spec.ExecutionType);
        Assert.Equal("dotnet", executable.Spec.ExecutablePath);
        Assert.Equal(builder.AppHostDirectory, executable.Spec.WorkingDirectory);
        Assert.Equal(plan.Arguments, executable.Spec.Args);
    }
}