File: WithBlazorAppTests.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.
 
#pragma warning disable ASPIREEXTENSION001
 
using Aspire.Hosting.ApplicationModel;
using Aspire.Hosting.JavaScript;
using Aspire.Hosting.Utils;
using Microsoft.Extensions.DependencyInjection;
 
namespace Aspire.Hosting.Blazor.Tests;
 
public class WithBlazorAppTests(ITestOutputHelper testOutputHelper)
{
    [Fact]
    public void WithBlazorApp_AddsGatewayAppsAnnotation()
    {
        using var builder = TestDistributedApplicationBuilder.Create(testOutputHelper);
 
        var gateway = builder.AddProject<TestProjectMetadata>("gateway")
            .WithHttpEndpoint()
            .WithHttpsEndpoint();
 
        var wasmApp = builder.AddBlazorWasmApp("store", "Store/Store.csproj");
 
        gateway.WithBlazorApp(wasmApp, "store", []);
 
        var annotation = gateway.Resource.Annotations.OfType<GatewayAppsAnnotation>().SingleOrDefault();
        Assert.NotNull(annotation);
        Assert.Single(annotation.Apps);
        Assert.Equal("store", annotation.Apps[0].PathPrefix);
    }
 
    [Fact]
    public void WithBlazorApp_MultipleApps_AllRegistered()
    {
        using var builder = TestDistributedApplicationBuilder.Create(testOutputHelper);
 
        var gateway = builder.AddProject<TestProjectMetadata>("gateway")
            .WithHttpEndpoint()
            .WithHttpsEndpoint();
 
        var storeApp = builder.AddBlazorWasmApp("store", "Store/Store.csproj");
        var adminApp = builder.AddBlazorWasmApp("admin", "Admin/Admin.csproj");
 
        gateway
            .WithBlazorApp(storeApp, "store", [])
            .WithBlazorApp(adminApp, "admin", []);
 
        var annotation = gateway.Resource.Annotations.OfType<GatewayAppsAnnotation>().SingleOrDefault();
        Assert.NotNull(annotation);
        Assert.Equal(2, annotation.Apps.Count);
        Assert.Equal("store", annotation.Apps[0].PathPrefix);
        Assert.Equal("admin", annotation.Apps[1].PathPrefix);
    }
 
    [Fact]
    public void WithBlazorApp_InitializesAnnotation_OnlyOnce()
    {
        using var builder = TestDistributedApplicationBuilder.Create(testOutputHelper);
 
        var gateway = builder.AddProject<TestProjectMetadata>("gateway")
            .WithHttpEndpoint()
            .WithHttpsEndpoint();
 
        var storeApp = builder.AddBlazorWasmApp("store", "Store/Store.csproj");
        var adminApp = builder.AddBlazorWasmApp("admin", "Admin/Admin.csproj");
 
        gateway
            .WithBlazorApp(storeApp, "store", [])
            .WithBlazorApp(adminApp, "admin", []);
 
        // Should only have one GatewayAppsAnnotation
        var annotations = gateway.Resource.Annotations.OfType<GatewayAppsAnnotation>().ToList();
        Assert.Single(annotations);
        Assert.True(annotations[0].IsInitialized);
    }
 
    [Fact]
    public void WithClient_ForwardsServiceReferences_ToGateway()
    {
        using var builder = TestDistributedApplicationBuilder.Create(testOutputHelper);
 
        var weatherApi = builder.AddProject<TestProjectMetadata>("weatherapi")
            .WithHttpEndpoint();
 
        var gateway = builder.AddProject<TestProjectMetadata>("gateway")
            .WithHttpEndpoint()
            .WithHttpsEndpoint();
 
        var wasmApp = builder.AddBlazorWasmApp("store", "Store/Store.csproj")
            .WithReference(weatherApi);
 
        gateway.WithBlazorClientApp(wasmApp);
 
        // The gateway should now have a reference to weatherapi
        var gatewayRefs = gateway.Resource.Annotations
            .OfType<ResourceRelationshipAnnotation>()
            .Select(r => r.Resource.Name)
            .ToList();
 
        Assert.Contains("weatherapi", gatewayRefs);
    }
 
    [Fact]
    public void WithClient_DoesNotDuplicateExistingReferences()
    {
        using var builder = TestDistributedApplicationBuilder.Create(testOutputHelper);
 
        var weatherApi = builder.AddProject<TestProjectMetadata>("weatherapi")
            .WithHttpEndpoint();
 
        var gateway = builder.AddProject<TestProjectMetadata>("gateway")
            .WithHttpEndpoint()
            .WithHttpsEndpoint()
            .WithReference(weatherApi); // Already referencing weatherapi
 
        var wasmApp = builder.AddBlazorWasmApp("store", "Store/Store.csproj")
            .WithReference(weatherApi);
 
        gateway.WithBlazorClientApp(wasmApp);
 
        // Count references to weatherapi on the gateway
        var weatherRefs = gateway.Resource.Annotations
            .OfType<ResourceRelationshipAnnotation>()
            .Count(r => r.Resource.Name == "weatherapi");
 
        // Should have exactly 1 (not duplicated)
        Assert.Equal(1, weatherRefs);
    }
 
    [Fact]
    public void WithClient_WaitForDoesNotPreventReference()
    {
        using var builder = TestDistributedApplicationBuilder.Create(testOutputHelper);
 
        var weatherApi = builder.AddProject<TestProjectMetadata>("weatherapi")
            .WithHttpEndpoint();
 
        // Gateway already has WaitFor(weatherApi) but not WithReference(weatherApi).
        // WithBlazorClientApp should still forward the reference so YARP gets service discovery env vars.
        var gateway = builder.AddProject<TestProjectMetadata>("gateway")
            .WithHttpEndpoint()
            .WithHttpsEndpoint()
            .WaitFor(weatherApi);
 
        var wasmApp = builder.AddBlazorWasmApp("store", "Store/Store.csproj")
            .WithReference(weatherApi);
 
        gateway.WithBlazorClientApp(wasmApp);
 
        var refs = gateway.Resource.Annotations
            .OfType<ResourceRelationshipAnnotation>()
            .Where(r => r.Resource.Name == "weatherapi" && r.Type == "Reference")
            .ToList();
 
        Assert.Single(refs);
    }
 
    [Fact]
    public void WithClient_UsesResourceName_AsPathPrefix()
    {
        using var builder = TestDistributedApplicationBuilder.Create(testOutputHelper);
 
        var gateway = builder.AddProject<TestProjectMetadata>("gateway")
            .WithHttpEndpoint()
            .WithHttpsEndpoint();
 
        var wasmApp = builder.AddBlazorWasmApp("storefront", "Store/Store.csproj");
 
        gateway.WithBlazorClientApp(wasmApp);
 
        var annotation = gateway.Resource.Annotations.OfType<GatewayAppsAnnotation>().SingleOrDefault();
        Assert.NotNull(annotation);
        Assert.Equal("storefront", annotation.Apps[0].PathPrefix);
    }
 
    [Fact]
    public void WithClient_AddsDebuggerResourceAndCommands()
    {
        using var builder = TestDistributedApplicationBuilder.Create(testOutputHelper);
 
        var gateway = builder.AddProject<TestProjectMetadata>("gateway")
            .WithHttpEndpoint()
            .WithHttpsEndpoint();
 
        var wasmApp = builder.AddBlazorWasmApp("store", "Store/Store.csproj")
            .WithBlazorDebuggerBrowser("chrome");
 
        gateway.WithBlazorClientApp(wasmApp);
 
        var debuggerResource = Assert.Single(builder.Resources.OfType<BrowserDebuggerResource>());
        Assert.Equal("gateway-store-debugger", debuggerResource.Name);
        Assert.Equal("chrome", debuggerResource.Command);
        Assert.True(debuggerResource.TryGetLastAnnotation<ExplicitStartupAnnotation>(out _));
        Assert.True(debuggerResource.TryGetLastAnnotation<ManifestPublishingCallbackAnnotation>(out var manifestAnnotation));
        Assert.Same(ManifestPublishingCallbackAnnotation.Ignore, manifestAnnotation);
 
        var parentRelationship = Assert.Single(
            debuggerResource.Annotations.OfType<ResourceRelationshipAnnotation>(),
            annotation => annotation.Type == "Parent");
        Assert.Same(gateway.Resource, parentRelationship.Resource);
 
        Assert.True(debuggerResource.TryGetLastAnnotation<ResourceSnapshotAnnotation>(out var snapshotAnnotation));
        Assert.Equal("BrowserDebugger", snapshotAnnotation.InitialSnapshot.ResourceType);
        Assert.True(snapshotAnnotation.InitialSnapshot.IsHidden);
 
        var commandNames = wasmApp.Resource.Annotations
            .OfType<ResourceCommandAnnotation>()
            .Select(annotation => annotation.Name)
            .ToList();
        Assert.Contains("debug-in-browser", commandNames);
        Assert.Contains("stop-browser-debug", commandNames);
        Assert.DoesNotContain(gateway.Resource.Annotations, annotation => annotation is ResourceCommandAnnotation);
    }
 
    [Fact]
    public void WithClient_InPublishMode_DoesNotAddDebuggerResourceOrCommands()
    {
        using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
 
        var gateway = builder.AddProject<TestProjectMetadata>("gateway")
            .WithHttpEndpoint()
            .WithHttpsEndpoint();
 
        var wasmApp = builder.AddBlazorWasmApp("store", "Store/Store.csproj");
 
        gateway.WithBlazorClientApp(wasmApp);
 
        Assert.Empty(builder.Resources.OfType<BrowserDebuggerResource>());
        Assert.DoesNotContain(wasmApp.Resource.Annotations, annotation => annotation is ResourceCommandAnnotation);
        Assert.Single(gateway.Resource.Annotations.OfType<GatewayAppsAnnotation>());
    }
 
    [Theory]
    [InlineData("Running", ResourceCommandState.Enabled)]
    [InlineData("Starting", ResourceCommandState.Disabled)]
    [InlineData("Exited", ResourceCommandState.Disabled)]
    public void WithClient_DebuggerCommandsHaveExpectedInitialState(string resourceState, ResourceCommandState expectedDebugState)
    {
        using var builder = TestDistributedApplicationBuilder.Create(testOutputHelper);
        ConfigureBrowserDebugging(builder, browserCapabilitySupported: true);
 
        var gateway = builder.AddProject<TestProjectMetadata>("gateway")
            .WithHttpsEndpoint();
        var wasmApp = builder.AddBlazorWasmApp("store", "Store/Store.csproj");
        gateway.WithBlazorClientApp(wasmApp);
 
        var commands = wasmApp.Resource.Annotations.OfType<ResourceCommandAnnotation>().ToDictionary(annotation => annotation.Name);
        var context = new UpdateCommandStateContext
        {
            ResourceSnapshot = new CustomResourceSnapshot
            {
                ResourceType = "BlazorWasmApp",
                Properties = [],
                State = resourceState
            },
            Services = new ServiceCollection().BuildServiceProvider()
        };
 
        Assert.Equal(expectedDebugState, commands["debug-in-browser"].UpdateState(context));
        Assert.Equal(ResourceCommandState.Hidden, commands["stop-browser-debug"].UpdateState(context));
    }
 
    [Theory]
    [InlineData(false, null, ResourceCommandState.Disabled)]
    [InlineData(true, null, ResourceCommandState.Disabled)]
    [InlineData(true, false, ResourceCommandState.Disabled)]
    [InlineData(true, true, ResourceCommandState.Enabled)]
    public void WithClient_DebugInBrowserCommandRequiresBrowserDebuggingSupport(
        bool debugSessionActive,
        bool? browserCapabilitySupported,
        ResourceCommandState expectedState)
    {
        using var builder = TestDistributedApplicationBuilder.Create(testOutputHelper);
        builder.Configuration["DEBUG_SESSION_PORT"] = debugSessionActive ? "localhost:1234" : null;
 
        if (browserCapabilitySupported is not null)
        {
            ConfigureBrowserDebugging(builder, browserCapabilitySupported.Value);
        }
 
        var gateway = builder.AddProject<TestProjectMetadata>("gateway")
            .WithHttpsEndpoint();
        var wasmApp = builder.AddBlazorWasmApp("store", "Store/Store.csproj");
        gateway.WithBlazorClientApp(wasmApp);
 
        var command = Assert.Single(
            wasmApp.Resource.Annotations.OfType<ResourceCommandAnnotation>(),
            annotation => annotation.Name == "debug-in-browser");
        var context = new UpdateCommandStateContext
        {
            ResourceSnapshot = new CustomResourceSnapshot
            {
                ResourceType = "BlazorWasmApp",
                Properties = [],
                State = KnownResourceStates.Running
            },
            Services = new ServiceCollection().BuildServiceProvider()
        };
 
        Assert.Equal(expectedState, command.UpdateState(context));
    }
 
    [Fact]
    public async Task WithClient_DebugInBrowserCommandFailsWithoutBrowserDebuggingSupport()
    {
        using var builder = TestDistributedApplicationBuilder.Create(testOutputHelper);
 
        var gateway = builder.AddProject<TestProjectMetadata>("gateway")
            .WithHttpsEndpoint();
        var wasmApp = builder.AddBlazorWasmApp("store", "Store/Store.csproj");
        gateway.WithBlazorClientApp(wasmApp);
 
        var command = Assert.Single(
            wasmApp.Resource.Annotations.OfType<ResourceCommandAnnotation>(),
            annotation => annotation.Name == "debug-in-browser");
        using var services = new ServiceCollection().BuildServiceProvider();
 
        var result = await command.ExecuteCommand(new ExecuteCommandContext
        {
            Services = services,
            ResourceName = wasmApp.Resource.Name,
            CancellationToken = CancellationToken.None,
            Logger = Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance,
            Arguments = new InteractionInputCollection([])
        });
 
        Assert.False(result.Success);
        Assert.Equal(
            "Browser debugging requires an active IDE debug session that supports the 'browser' launch configuration.",
            result.Message);
    }
 
    [Fact]
    public async Task WithClient_CreatesBrowserLaunchConfiguration()
    {
        using var builder = TestDistributedApplicationBuilder.Create(testOutputHelper);
 
        var gateway = builder.AddProject<TestProjectMetadata>("gateway")
            .WithHttpEndpoint()
            .WithHttpsEndpoint()
            .WithEndpoint("https", endpoint => endpoint.AllocatedEndpoint = new(endpoint, "localhost", 7443), createIfNotExists: false);
 
        var wasmApp = builder.AddBlazorWasmApp("store", "Store/Store.csproj")
            .WithBlazorDebuggerBrowser("chrome");
 
        gateway.WithBlazorClientApp(wasmApp);
 
        var debuggerResource = Assert.Single(builder.Resources.OfType<BrowserDebuggerResource>());
        var launchConfiguration = await CreateBrowserLaunchConfigurationAsync(debuggerResource);
 
        Assert.Equal(ExecutableLaunchMode.Debug, launchConfiguration.Mode);
        Assert.Equal("https://localhost:7443/store/", launchConfiguration.Url);
        Assert.Equal(wasmApp.Resource.ProjectPath, launchConfiguration.WebRoot);
        Assert.Equal("chrome", launchConfiguration.Browser);
    }
 
    [Fact]
    public void WithClient_CanDisableTelemetryProxy()
    {
        using var builder = TestDistributedApplicationBuilder.Create(testOutputHelper);
 
        var gateway = builder.AddProject<TestProjectMetadata>("gateway")
            .WithHttpEndpoint()
            .WithHttpsEndpoint();
 
        var wasmApp = builder.AddBlazorWasmApp("store", "Store/Store.csproj");
 
        gateway.WithBlazorClientApp(wasmApp, proxyTelemetry: false);
 
        var annotation = gateway.Resource.Annotations.OfType<GatewayAppsAnnotation>().SingleOrDefault();
        Assert.NotNull(annotation);
        Assert.False(annotation.Apps[0].ProxyBlazorTelemetry);
    }
 
    [Fact]
    public void WithClient_ForwardsAllEndpoints_WhenUseAllEndpoints()
    {
        using var builder = TestDistributedApplicationBuilder.Create(testOutputHelper);
 
        var weatherApi = builder.AddProject<TestProjectMetadata>("weatherapi")
            .WithHttpEndpoint()
            .WithHttpsEndpoint();
 
        var gateway = builder.AddProject<TestProjectMetadata>("gateway")
            .WithHttpEndpoint()
            .WithHttpsEndpoint();
 
        // WithReference(weatherApi) sets UseAllEndpoints = true on the WASM app's annotation.
        var wasmApp = builder.AddBlazorWasmApp("store", "Store/Store.csproj")
            .WithReference(weatherApi);
 
        gateway.WithBlazorClientApp(wasmApp);
 
        // The gateway should have an EndpointReferenceAnnotation for weatherapi with UseAllEndpoints = true.
        var endpointRef = gateway.Resource.Annotations
            .OfType<EndpointReferenceAnnotation>()
            .SingleOrDefault(a => a.Resource.Name == "weatherapi");
 
        Assert.NotNull(endpointRef);
        Assert.True(endpointRef.UseAllEndpoints);
    }
 
    [Fact]
    public void WithClient_ForwardsSpecificEndpoints_WhenNamedEndpointReferenced()
    {
        using var builder = TestDistributedApplicationBuilder.Create(testOutputHelper);
 
        var weatherApi = builder.AddProject<TestProjectMetadata>("weatherapi")
            .WithHttpEndpoint(name: "public")
            .WithHttpsEndpoint(name: "internal");
 
        var gateway = builder.AddProject<TestProjectMetadata>("gateway")
            .WithHttpEndpoint()
            .WithHttpsEndpoint();
 
        // Reference only the "public" endpoint on the WASM app.
        var wasmApp = builder.AddBlazorWasmApp("store", "Store/Store.csproj")
            .WithReference(weatherApi.GetEndpoint("public"));
 
        gateway.WithBlazorClientApp(wasmApp);
 
        // The gateway should have only the "public" endpoint forwarded (not UseAllEndpoints)
        // because a single named endpoint uses service discovery's named endpoint format.
        var endpointRef = gateway.Resource.Annotations
            .OfType<EndpointReferenceAnnotation>()
            .SingleOrDefault(a => a.Resource.Name == "weatherapi");
 
        Assert.NotNull(endpointRef);
        Assert.False(endpointRef.UseAllEndpoints);
        Assert.Collection(endpointRef.EndpointNames,
            name => Assert.Equal("public", name));
 
        // The registration should map weatherapi → "public" endpoint name so the YARP
        // destination uses https+http://_public.weatherapi instead of scheme-based resolution.
        var annotation = gateway.Resource.Annotations.OfType<GatewayAppsAnnotation>().Single();
        var registration = Assert.Single(annotation.Apps);
        var service = Assert.Single(registration.Services);
        Assert.Equal("weatherapi", service.Name);
        Assert.Collection(service.EndpointNames,
            name => Assert.Equal("public", name));
    }
 
    [Fact]
    public void WithClient_ForwardsMultipleNamedEndpoints_Correctly()
    {
        using var builder = TestDistributedApplicationBuilder.Create(testOutputHelper);
 
        var weatherApi = builder.AddProject<TestProjectMetadata>("weatherapi")
            .WithHttpEndpoint(name: "public")
            .WithHttpsEndpoint(name: "admin")
            .WithHttpEndpoint(name: "health");
 
        var gateway = builder.AddProject<TestProjectMetadata>("gateway")
            .WithHttpEndpoint()
            .WithHttpsEndpoint();
 
        // Reference two specific endpoints on the WASM app.
        var wasmApp = builder.AddBlazorWasmApp("store", "Store/Store.csproj")
            .WithReference(weatherApi.GetEndpoint("public"))
            .WithReference(weatherApi.GetEndpoint("admin"));
 
        gateway.WithBlazorClientApp(wasmApp);
 
        // The gateway should have only the specific named endpoints forwarded.
        var endpointRef = gateway.Resource.Annotations
            .OfType<EndpointReferenceAnnotation>()
            .SingleOrDefault(a => a.Resource.Name == "weatherapi");
 
        Assert.NotNull(endpointRef);
        Assert.False(endpointRef.UseAllEndpoints);
        Assert.Collection(endpointRef.EndpointNames.Order(),
            name => Assert.Equal("admin", name),
            name => Assert.Equal("public", name));
 
        // The YARP destination uses the first endpoint name for named resolution.
        var annotation = gateway.Resource.Annotations.OfType<GatewayAppsAnnotation>().Single();
        var registration = Assert.Single(annotation.Apps);
        var service = Assert.Single(registration.Services);
        Assert.Equal("weatherapi", service.Name);
        Assert.Collection(service.EndpointNames.Order(),
            name => Assert.Equal("admin", name),
            name => Assert.Equal("public", name));
    }
 
    private static async Task<BrowserLaunchConfiguration> CreateBrowserLaunchConfigurationAsync(IResource resource)
    {
        var launchConfiguration = await resource.CreateLaunchConfigurationAsync(ExecutableLaunchMode.Debug);
        return Assert.IsType<BrowserLaunchConfiguration>(launchConfiguration);
    }
 
    private static void ConfigureBrowserDebugging(IDistributedApplicationBuilder builder, bool browserCapabilitySupported)
    {
        builder.Configuration["DEBUG_SESSION_PORT"] = "localhost:1234";
 
        // DEBUG_SESSION_INFO is emitted by the IDE in this shape:
        // {"protocols_supported":["2024-03-03"],"supported_launch_configurations":["browser"]}
        builder.Configuration["DEBUG_SESSION_INFO"] = browserCapabilitySupported
            ? """{"protocols_supported":["2024-03-03"],"supported_launch_configurations":["browser"]}"""
            : """{"protocols_supported":["2024-03-03"],"supported_launch_configurations":["project"]}""";
    }
 
    private sealed class TestProjectMetadata : IProjectMetadata
    {
        public string ProjectPath => "TestProject/TestProject.csproj";
 
        public LaunchSettings LaunchSettings { get; } = new();
    }
}