File: TypeScriptBlazorGatewayTests.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 Aspire.Cli.EndToEnd.Tests.Helpers;
using Hex1b.Automation;
using Xunit;
 
namespace Aspire.Cli.EndToEnd.Tests;
 
/// <summary>
/// End-to-end coverage for Blazor gateway APIs invoked from a TypeScript AppHost.
/// </summary>
public sealed class TypeScriptBlazorGatewayTests(ITestOutputHelper output)
{
    [Fact]
    [CaptureWorkspaceOnFailure]
    public async Task DotnetProjectBlazorGatewayRunsFromTypeScriptAppHost()
    {
        var repoRoot = CliE2ETestHelpers.GetRepoRoot();
        var strategy = CliInstallStrategy.Detect(output.WriteLine);
 
        if (strategy.Mode == CliInstallMode.InstallScript && strategy.Quality is null && strategy.Version is null)
        {
            Assert.Skip("This test exercises unreleased Blazor polyglot APIs. Build a local Aspire CLI bundle or run in CI so the test uses current PR bits instead of the GA CLI.");
        }
 
        using var workspace = TemporaryWorkspace.Create(output);
        var localChannel = CliE2ETestHelpers.PrepareLocalChannel(
            repoRoot,
            strategy,
            [
                "Aspire.Hosting.Blazor.",
                "Aspire.Hosting.CodeGeneration.TypeScript.",
                "Aspire.Hosting.Dotnet."
            ]);
 
        // The gateway is a file-based .NET app, so this scenario needs both Node.js for the
        // TypeScript AppHost and the .NET SDK for the gateway and Blazor client builds.
        using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(
            repoRoot,
            strategy,
            output,
            variant: CliE2ETestHelpers.DockerfileVariant.DotNet,
            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.RunCommandAsync(
            "aspire init --language typescript --non-interactive --suppress-agent-init",
            counter,
            TimeSpan.FromMinutes(2));
 
        if (localChannel is not null)
        {
            CliE2ETestHelpers.WriteLocalChannelSettings(workspace.WorkspaceRoot.FullName, localChannel.SdkVersion);
        }
 
        await auto.TypeAsync("aspire add Aspire.Hosting.Blazor --non-interactive");
        await auto.EnterAsync();
        await auto.WaitForAspireAddCompletionAsync(counter, TimeSpan.FromMinutes(2));
 
        await auto.RunCommandAsync(
            "dotnet new blazorwasm --name Client --output Client --no-restore",
            counter,
            TimeSpan.FromMinutes(2));
 
        var appHostPath = Path.Combine(workspace.WorkspaceRoot.FullName, "apphost.mts");
        await File.WriteAllTextAsync(appHostPath, """
            import { createBuilder } from './.aspire/modules/aspire.mjs';
 
            const builder = await createBuilder();
 
            const client = await builder.addBlazorWasmProject('client', './Client/Client.csproj');
            const gateway = await builder.addDotnetProjectBlazorGateway('gateway');
            await gateway.withBlazorClientApp(client);
 
            await builder.build().run();
            """);
 
        var verificationScriptPath = Path.Combine(workspace.WorkspaceRoot.FullName, "verify-blazor-gateway.sh");
        var verificationScript = """
            #!/usr/bin/env bash
            set -euo pipefail
 
            cleanup() {
                aspire stop --non-interactive >/dev/null 2>&1 || true
            }
            trap cleanup EXIT
 
            ASPIRE_CLI_START_TIMEOUT=180 aspire start --non-interactive --format json > aspire-start.json
            aspire wait gateway --status up --timeout 240
 
            found=false
            for i in $(seq 1 20); do
                if ! aspire describe gateway --format json > gateway.json; then
                    sleep 2
                    continue
                fi
 
                if ! GATEWAY_URL=$(jq -r '[.resources[0].urls[]? | select(.name == "http" and .isInternal != true) | .url][0] // empty' gateway.json); then
                    sleep 2
                    continue
                fi
 
                if [ -n "$GATEWAY_URL" ] &&
                   curl --connect-timeout 2 --max-time 5 -fsS "$GATEWAY_URL/client/" -o client-index.html &&
                   grep -q '<title>Client</title>' client-index.html &&
                   grep -q '_framework/blazor.webassembly' client-index.html; then
                    found=true
                    break
                fi
 
                sleep 2
            done
 
            $found
            """;
        await File.WriteAllTextAsync(verificationScriptPath, verificationScript.ReplaceLineEndings("\n"));
 
        try
        {
            await auto.RunCommandAsync(
                "bash verify-blazor-gateway.sh",
                counter,
                TimeSpan.FromMinutes(10));
        }
        catch (InvalidOperationException)
        {
            // RunCommandAsync observes the error prompt before throwing but leaves the sequence counter
            // unchanged. Advance it so TerminalRun can capture diagnostics from the next prompt.
            counter.Increment();
            throw;
        }
    }
}