File: IntegrationHostLauncherTests.cs
Web Access
Project: src\tests\Aspire.Hosting.RemoteHost.Tests\Aspire.Hosting.RemoteHost.Tests.csproj (Aspire.Hosting.RemoteHost.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 Aspire.Hosting.RemoteHost.Ats;
using Aspire.Hosting.RemoteHost.Language;
using Aspire.TypeSystem;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting.Internal;
using Microsoft.Extensions.Logging.Abstractions;
using Xunit;
 
namespace Aspire.Hosting.RemoteHost.Tests;
 
public class IntegrationHostLauncherTests
{
    [Fact]
    public async Task StartAsync_BootstrapSkipsUnavailableIntegrationHosts()
    {
        using var services = new ServiceCollection().BuildServiceProvider();
        var configuration = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?>
        {
            ["ASPIRE_INTEGRATION_HOSTS_ENABLED"] = "true",
            ["ASPIRE_INTEGRATION_HOST_BOOTSTRAP"] = "true",
            ["IntegrationHosts:0:PackageName"] = "needs-generated-sdk"
        }).Build();
        using var registry = new ExternalCapabilityRegistry(NullLogger<ExternalCapabilityRegistry>.Instance);
        await using var launcher = IntegrationHostTestServices.CreateLauncher(
            new LanguageSupportResolver(services, () => [], NullLogger<LanguageSupportResolver>.Instance),
            registry,
            configuration,
            new ApplicationLifetime(NullLogger<ApplicationLifetime>.Instance),
            NullLogger<IntegrationHostLauncher>.Instance);
 
        await launcher.StartAsync(TestContext.Current.CancellationToken);
        await launcher.ReadyAsync(TestContext.Current.CancellationToken);
        await launcher.StopAsync(TestContext.Current.CancellationToken);
    }
 
    [Fact]
    public async Task StartAsync_InvalidIntegrationFaultsReadiness()
    {
        using var services = new ServiceCollection().BuildServiceProvider();
        var configuration = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?>
        {
            ["ASPIRE_INTEGRATION_HOSTS_ENABLED"] = "true",
            ["IntegrationHosts:0:PackageName"] = "missing-language-and-entrypoint"
        }).Build();
        using var registry = new ExternalCapabilityRegistry(NullLogger<ExternalCapabilityRegistry>.Instance);
        await using var launcher = IntegrationHostTestServices.CreateLauncher(
            new LanguageSupportResolver(services, () => [], NullLogger<LanguageSupportResolver>.Instance),
            registry,
            configuration,
            new ApplicationLifetime(NullLogger<ApplicationLifetime>.Instance),
            NullLogger<IntegrationHostLauncher>.Instance);
 
        var startupException = await Assert.ThrowsAsync<InvalidOperationException>(() =>
            launcher.StartAsync(TestContext.Current.CancellationToken));
        var readinessException = await Assert.ThrowsAsync<InvalidOperationException>(() =>
            launcher.ReadyAsync(TestContext.Current.CancellationToken));
 
        Assert.Same(startupException, readinessException);
    }
 
    [Theory]
    [InlineData(false)]
    [InlineData(true)]
    public async Task StartAsync_DisabledIntegrationHostsFaultReadinessBeforeBootstrap(bool bootstrap)
    {
        using var services = new ServiceCollection().BuildServiceProvider();
        var configuration = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?>
        {
            ["ASPIRE_INTEGRATION_HOST_BOOTSTRAP"] = bootstrap.ToString(),
            ["IntegrationHosts:0:PackageName"] = "disabled-integration"
        }).Build();
        using var registry = new ExternalCapabilityRegistry(NullLogger<ExternalCapabilityRegistry>.Instance);
        await using var launcher = IntegrationHostTestServices.CreateLauncher(
            new LanguageSupportResolver(services, () => [], NullLogger<LanguageSupportResolver>.Instance),
            registry, configuration,
            new ApplicationLifetime(NullLogger<ApplicationLifetime>.Instance),
            NullLogger<IntegrationHostLauncher>.Instance);
 
        var exception = await Assert.ThrowsAsync<InvalidOperationException>(() =>
            launcher.StartAsync(TestContext.Current.CancellationToken));
        var readinessException = await Assert.ThrowsAsync<InvalidOperationException>(() =>
            launcher.ReadyAsync(TestContext.Current.CancellationToken));
 
        Assert.Equal("Integration hosts require ASPIRE_INTEGRATION_HOSTS_ENABLED=true.", exception.Message);
        Assert.Same(exception, readinessException);
    }
 
    [Fact]
    public async Task InitializeHostsAsync_WaitsForEveryRegistrationBeforeDiscovery()
    {
        using var services = new ServiceCollection().BuildServiceProvider();
        using var registry = new ExternalCapabilityRegistry(NullLogger<ExternalCapabilityRegistry>.Instance);
        await using var launcher = IntegrationHostTestServices.CreateLauncher(
            new LanguageSupportResolver(services, () => [], NullLogger<LanguageSupportResolver>.Instance),
            registry, new ConfigurationBuilder().Build(),
            new ApplicationLifetime(NullLogger<ApplicationLifetime>.Instance), NullLogger<IntegrationHostLauncher>.Instance);
        var discoveryStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
        using var firstConnection = new IntegrationHostTestConnection(_ =>
        {
            discoveryStarted.TrySetResult();
            return Task.FromResult(JsonSerializer.SerializeToElement(new[]
            {
                new { id = "test/first", method = "first", returnType = new { typeId = "void", category = "Primitive" } }
            }));
        });
        using var secondConnection = new IntegrationHostTestConnection(
            JsonSerializer.SerializeToElement(new[]
            {
                new { id = "test/second", method = "second", returnType = new { typeId = "void", category = "Primitive" } }
            }));
        registry.AddIntegrationHost(firstConnection.ServerRpc);
 
        var initialization = launcher.InitializeHostsAsync(
            2, Timeout.InfiniteTimeSpan, TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken);
 
        Assert.False(initialization.IsCompleted);
        Assert.False(discoveryStarted.Task.IsCompleted);
        registry.AddIntegrationHost(secondConnection.ServerRpc);
        await initialization.WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken);
 
        Assert.True(registry.IsRegistered("test/first"));
        Assert.True(registry.IsRegistered("test/second"));
    }
 
    [Fact]
    public async Task InitializeHostsAsync_MissingRegistrationStopsDiscovery()
    {
        using var services = new ServiceCollection().BuildServiceProvider();
        using var registry = new ExternalCapabilityRegistry(NullLogger<ExternalCapabilityRegistry>.Instance);
        await using var launcher = IntegrationHostTestServices.CreateLauncher(
            new LanguageSupportResolver(services, () => [], NullLogger<LanguageSupportResolver>.Instance),
            registry, new ConfigurationBuilder().Build(),
            new ApplicationLifetime(NullLogger<ApplicationLifetime>.Instance), NullLogger<IntegrationHostLauncher>.Instance);
        using var connection = new IntegrationHostTestConnection(
            JsonSerializer.SerializeToElement(new[] { new { id = "test/partial" } }));
        registry.AddIntegrationHost(connection.ServerRpc);
 
        var exception = await Assert.ThrowsAsync<TimeoutException>(() =>
            launcher.InitializeHostsAsync(2, TimeSpan.Zero, TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken));
 
        Assert.StartsWith("Only 1 of 2 integration hosts registered", exception.Message);
        Assert.False(registry.IsRegistered("test/partial"));
    }
 
    [Fact]
    public async Task InitializeHostsAsync_DisconnectedRegistrationCannotPublishPartialCapabilities()
    {
        using var services = new ServiceCollection().BuildServiceProvider();
        using var registry = new ExternalCapabilityRegistry(NullLogger<ExternalCapabilityRegistry>.Instance);
        await using var launcher = IntegrationHostTestServices.CreateLauncher(
            new LanguageSupportResolver(services, () => [], NullLogger<LanguageSupportResolver>.Instance),
            registry, new ConfigurationBuilder().Build(),
            new ApplicationLifetime(NullLogger<ApplicationLifetime>.Instance), NullLogger<IntegrationHostLauncher>.Instance);
        using var connection = new IntegrationHostTestConnection(
            JsonSerializer.SerializeToElement(new[] { new { id = "test/partial" } }));
        registry.AddIntegrationHost(connection.ServerRpc);
        registry.MarkHostUnavailable(connection.ServerRpc);
 
        var exception = await Assert.ThrowsAsync<InvalidOperationException>(() =>
            launcher.InitializeHostsAsync(1, TimeSpan.Zero, TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken));
 
        Assert.Equal("Only 0 of 1 integration hosts remain connected for capability discovery.", exception.Message);
        Assert.Throws<InvalidOperationException>(() => registry.AugmentContext(new AtsContext
        {
            Capabilities = [],
            HandleTypes = [],
            DtoTypes = [],
            EnumTypes = [],
            ExportedValues = [],
            Diagnostics = []
        }));
    }
 
    [Fact]
    public async Task InitializeHostsAsync_CancellationStopsWaitingForRegistration()
    {
        using var services = new ServiceCollection().BuildServiceProvider();
        using var registry = new ExternalCapabilityRegistry(NullLogger<ExternalCapabilityRegistry>.Instance);
        await using var launcher = IntegrationHostTestServices.CreateLauncher(
            new LanguageSupportResolver(services, () => [], NullLogger<LanguageSupportResolver>.Instance),
            registry,
            new ConfigurationBuilder().Build(),
            new ApplicationLifetime(NullLogger<ApplicationLifetime>.Instance), NullLogger<IntegrationHostLauncher>.Instance);
        using var cancellation = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken);
 
        var initialization = launcher.InitializeHostsAsync(
            1, Timeout.InfiniteTimeSpan, TimeSpan.FromSeconds(10), cancellation.Token);
        Assert.False(initialization.IsCompleted);
        cancellation.Cancel();
 
        await Assert.ThrowsAnyAsync<OperationCanceledException>(() => initialization);
    }
 
    [Fact]
    public void CreateProcessStartInfo_PreservesArgumentBoundariesAndExpandsEntryPoint()
    {
        var entryPoint = Path.GetFullPath(Path.Combine("integration packages", "host entry.mts"));
        var startInfo = IntegrationHostProcessLauncher.CreateProcessStartInfo(
            "integration-runtime",
            [
                "--no-install",
                "tsx",
                "{entryPoint}",
                "--entry={entryPoint}",
                "{entryPoint};{entryPoint}",
                "--label=a \"quoted\" value",
                @"C:\tools with spaces\trailing\",
                "\\\"",
                "",
                "{otherPlaceholder}"
            ],
            entryPoint,
            isWindows: false);
 
        Assert.Equal(
            [
                "--no-install",
                "tsx",
                entryPoint,
                $"--entry={entryPoint}",
                $"{entryPoint};{entryPoint}",
                "--label=a \"quoted\" value",
                @"C:\tools with spaces\trailing\",
                "\\\"",
                "",
                "{otherPlaceholder}"
            ],
            startInfo.ArgumentList.ToArray());
        Assert.Empty(startInfo.Arguments);
        Assert.Equal("integration-runtime", startInfo.FileName);
        Assert.Equal(Path.GetDirectoryName(entryPoint), startInfo.WorkingDirectory);
        Assert.False(startInfo.UseShellExecute);
        Assert.True(startInfo.RedirectStandardOutput);
        Assert.True(startInfo.RedirectStandardError);
    }
 
    [Fact]
    public void CreateProcessStartInfo_AllowsEmptyArgumentList()
    {
        var entryPoint = Path.GetFullPath("host.mts");
 
        var startInfo = IntegrationHostProcessLauncher.CreateProcessStartInfo("integration-runtime", [], entryPoint, isWindows: false);
 
        Assert.Empty(startInfo.ArgumentList);
        Assert.Empty(startInfo.Arguments);
    }
 
    [Theory]
    [InlineData("npx.cmd")]
    [InlineData("npx.CMD")]
    [InlineData("npx.bat")]
    public void CreateProcessStartInfo_WindowsBatchShim_UsesOuterQuotedCommand(string shim)
    {
        var command = $@"C:\Program Files\%TEMP%\nodejs\{shim}";
        var entryPoint = Path.GetFullPath(Path.Combine("integration %TEMP%! packages", "host entry.mts"));
 
        var startInfo = IntegrationHostProcessLauncher.CreateProcessStartInfo(
            command, ["--no-install", "tsx", "{entryPoint}", "", "literal %PATH%! & value"], entryPoint, isWindows: true);
 
        Assert.Equal(Environment.GetEnvironmentVariable("ComSpec") ?? "cmd.exe", startInfo.FileName);
        Assert.Empty(startInfo.ArgumentList);
        Assert.Equal(
            "/D /V:OFF /S /C \"\"%ASPIRE_COMMAND_SHIM_PATH%\" \"%ASPIRE_COMMAND_SHIM_ARGUMENT_0%\" \"%ASPIRE_COMMAND_SHIM_ARGUMENT_1%\" \"%ASPIRE_COMMAND_SHIM_ARGUMENT_2%\" \"\" \"%ASPIRE_COMMAND_SHIM_ARGUMENT_4%\"\"",
            startInfo.Arguments);
        Assert.Equal(command, startInfo.Environment["ASPIRE_COMMAND_SHIM_PATH"]);
        Assert.Equal(
            ["--no-install", "tsx", entryPoint, "literal %PATH%! & value"],
            startInfo.Environment
                .Where(variable => variable.Key.StartsWith("ASPIRE_COMMAND_SHIM_ARGUMENT_", StringComparison.Ordinal))
                .OrderBy(variable => variable.Key, StringComparer.Ordinal)
                .Select(variable => variable.Value));
        Assert.False(startInfo.UseShellExecute);
        Assert.True(startInfo.RedirectStandardOutput);
        Assert.True(startInfo.RedirectStandardError);
    }
}