File: CliBootstrapTests.cs
Web Access
Project: src\tests\Aspire.Cli.Tests\Aspire.Cli.Tests.csproj (Aspire.Cli.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.Reflection;
using Aspire.Cli.Acquisition;
using Aspire.Cli.Interaction;
using Aspire.Cli.Tests.TestServices;
using Aspire.Cli.Tests.Utils;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
 
#if DEBUG
using System.Globalization;
using Aspire.Cli.Commands;
using Aspire.Cli.Resources;
#endif
 
namespace Aspire.Cli.Tests;
 
/// <summary>
/// Integration tests for the bootstrap wiring: the running CLI's
/// <see cref="CliExecutionContext.IdentityChannel"/> is sourced from the binary's
/// <c>[AssemblyMetadata("AspireCliChannel")]</c> value via
/// <see cref="IIdentityChannelReader"/>, registered in DI by
/// <see cref="Aspire.Cli.Program.BuildApplicationAsync"/>.
/// </summary>
public class CliBootstrapTests(ITestOutputHelper outputHelper)
{
    private static readonly string[] s_fixedChannels = ["stable", "staging", "daily", "local"];
 
    private static async Task<IHost> BuildHostAsync()
    {
        var loggingOptions = Program.ParseLoggingOptions([]);
        var errorWriter = new TestStartupErrorWriter();
        var logBufferContext = new ConsoleLogBufferContext();
        var (loggerFactory, fileLoggerProvider) = Program.CreateLoggerFactory([], loggingOptions, errorWriter, logBufferContext);
        var identityChannelReader = new IdentityChannelReader(typeof(Program).Assembly);
        var startupContext = new Program.CliStartupContext(loggingOptions, errorWriter, loggerFactory, fileLoggerProvider, logBufferContext, loggerFactory.CreateLogger(Program.RootLoggerName), new ConsoleCancellationManager(finalDrainBudget: Timeout.InfiniteTimeSpan), identityChannelReader);
        return await Program.BuildApplicationAsync([], startupContext);
    }
 
    private static string GetBakedEntryAssemblyChannel()
    {
        var entryAssembly = Assembly.GetEntryAssembly();
        Assert.NotNull(entryAssembly);
        var bakedChannel = entryAssembly
            .GetCustomAttributes<AssemblyMetadataAttribute>()
            .Single(a => string.Equals(a.Key, "AspireCliChannel", StringComparison.Ordinal))
            .Value;
        Assert.False(string.IsNullOrEmpty(bakedChannel));
        return bakedChannel!;
    }
 
    [Fact]
    public void IdentityChannelReader_OnRunningCliAssembly_ReturnsKnownChannel()
    {
        var reader = new IdentityChannelReader(typeof(Aspire.Cli.Program).Assembly);
 
        Assert.True(reader.TryReadChannel(out var channel, out _));
 
        // Test host can be built with /p:AspireCliChannel=<anything in the accepted set>;
        // assert shape, not a single literal, so this test stops being an accidental
        // regression for non-default builds (including pr-<N> when the test host is a PR build).
        Assert.True(
            s_fixedChannels.Contains(channel) || channel.StartsWith("pr-", StringComparison.Ordinal),
            $"Unexpected channel '{channel}'; expected one of stable|staging|daily|local|pr-<N>.");
    }
 
    [Fact]
    public async Task BuildApplication_RegistersIIdentityChannelReader_AsIdentityChannelReaderInstance()
    {
        // Program.BuildApplicationAsync registers IIdentityChannelReader as a singleton,
        // backed by the default IdentityChannelReader (which reads from
        // typeof(Aspire.Cli.Program).Assembly).
        using var host = await BuildHostAsync();
 
        var reader = host.Services.GetRequiredService<IIdentityChannelReader>();
 
        Assert.NotNull(reader);
        Assert.IsType<IdentityChannelReader>(reader);
    }
 
    [Fact]
    public async Task BuildApplication_PopulatesCliExecutionContextChannel_FromIdentityChannelReader()
    {
        // The CliExecutionContext factory delegate must source Channel from
        // IIdentityChannelReader.ReadChannel() rather than the constructor default.
        // Without this wiring, the entire reseed chain would write "daily" for every
        // CLI build regardless of the baked AspireCliChannel.
        using var host = await BuildHostAsync();
 
        var reader = host.Services.GetRequiredService<IIdentityChannelReader>();
        var context = host.Services.GetRequiredService<CliExecutionContext>();
 
        Assert.True(reader.TryReadChannel(out var channel, out _));
        Assert.Equal(channel, context.IdentityChannel);
    }
 
    [Fact]
    public async Task BuildApplication_CliExecutionContextChannel_MatchesAssemblyMetadataAttribute()
    {
        // End-to-end coherence: the channel flowing through the DI container must equal the
        // value baked into the entry assembly by [AssemblyMetadata("AspireCliChannel", "...")].
        // IdentityChannelReader reads from typeof(Aspire.Cli.Program).Assembly; this test
        // reads Assembly.GetEntryAssembly() directly and the comparison works because
        // Aspire.Cli.csproj and the test csproj forward the same $(AspireCliChannel) MSBuild
        // property — keeping both assemblies in lockstep regardless of the build configuration
        // (so this test is also correct on /p:AspireCliChannel=stable or pr-<N> CI builds).
        var bakedChannel = GetBakedEntryAssemblyChannel();
 
        using var host = await BuildHostAsync();
 
        var context = host.Services.GetRequiredService<CliExecutionContext>();
 
        Assert.Equal(bakedChannel, context.IdentityChannel);
    }
 
    [Fact]
    public void ParseLoggingOptions_PrInstall_UsesInstallPrefixForDefaultLogsDirectory()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var installPrefix = Path.Combine(workspace.WorkspaceRoot.FullName, "aspire-pr-test");
        var binaryPath = WriteBinaryWithSidecar(Path.Combine(installPrefix, "dogfood", "pr-17159", "bin"), InstallSourceExtensions.PrWire);
 
        var loggingOptions = Program.ParseLoggingOptions([], binaryPath);
 
        Assert.Equal(Path.Combine(installPrefix, "logs"), loggingOptions.LogsDirectory);
        Assert.Equal(loggingOptions.LogsDirectory, Path.GetDirectoryName(loggingOptions.LogFilePath));
    }
 
    [Fact]
    public void BuildCliExecutionContext_PrInstall_UsesInstallPrefixForStateDirectoriesAndKeepsIdentityChannel()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var installPrefix = Path.Combine(workspace.WorkspaceRoot.FullName, "aspire-pr-test");
        var binaryDir = Path.Combine(installPrefix, "dogfood", "pr-17159", "bin");
        var binaryPath = WriteBinaryWithSidecar(binaryDir, InstallSourceExtensions.PrWire, channel: "pr-17159");
        var logsDirectory = Path.Combine(installPrefix, "logs");
        var logFilePath = Path.Combine(logsDirectory, "aspire.log");
 
        var environment = new TestEnvironment();
        var resolver = new IdentityResolver(
            CliTestHelper.CreateSidecarReader(outputHelper),
            typeof(Program).Assembly,
            binaryDir,
            environment);
 
        var context = Program.BuildCliExecutionContext(
            debugMode: true,
            consoleLogLevel: null,
            logsDirectory: logsDirectory,
            logFilePath: logFilePath,
            identityResolver: resolver,
            processPath: binaryPath);
 
        Assert.Equal(Path.Combine(installPrefix, "hives"), context.HivesDirectory.FullName);
        Assert.Equal(Path.Combine(installPrefix, "cache"), context.CacheDirectory.FullName);
        Assert.Equal(Path.Combine(installPrefix, "sdks"), context.SdksDirectory.FullName);
        Assert.Equal(Path.Combine(installPrefix, "packages"), context.PackagesDirectory?.FullName);
        Assert.Equal(installPrefix, context.AspireHomeDirectory.FullName);
        Assert.Equal(logsDirectory, context.LogsDirectory.FullName);
        Assert.Equal(logFilePath, context.LogFilePath);
        Assert.True(context.DebugMode);
        Assert.Equal("pr-17159", context.IdentityChannel);
    }
 
    [Fact]
    public void BuildCliExecutionContext_NuGetServiceIndexOverrideFromEnv_MarksIdentityOverridden()
    {
        // Setting only ASPIRE_CLI_NUGET_SERVICE_INDEX must still flag the run as an emulation so the
        // startup override notice fires and tooling does not mistake a diagnostic run for a real build.
        // Regression guard: this source was previously omitted from the identityOverridden computation.
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var envVars = new Dictionary<string, string?> { [IdentityResolver.NuGetServiceIndexEnvVar] = "http://localhost:5000/v3/index.json" };
        var environment = new TestEnvironment(envVars);
        var resolver = new IdentityResolver(
            CliTestHelper.CreateSidecarReader(outputHelper),
            typeof(Program).Assembly,
            binaryDir: null,
            environment);
 
        var context = Program.BuildCliExecutionContext(
            debugMode: false,
            consoleLogLevel: null,
            logsDirectory: Path.Combine(workspace.WorkspaceRoot.FullName, "logs"),
            logFilePath: Path.Combine(workspace.WorkspaceRoot.FullName, "logs", "aspire.log"),
            identityResolver: resolver);
 
        Assert.True(context.IdentityOverridden);
        Assert.Equal("http://localhost:5000/v3/index.json", context.NuGetServiceIndexOverride);
    }
 
    [Fact]
    public void BuildCliExecutionContext_NoOverrides_DoesNotMarkIdentityOverridden()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var environment = new TestEnvironment();
        var resolver = new IdentityResolver(
            CliTestHelper.CreateSidecarReader(outputHelper),
            typeof(Program).Assembly,
            binaryDir: null,
            environment);
 
        var context = Program.BuildCliExecutionContext(
            debugMode: false,
            consoleLogLevel: null,
            logsDirectory: Path.Combine(workspace.WorkspaceRoot.FullName, "logs"),
            logFilePath: Path.Combine(workspace.WorkspaceRoot.FullName, "logs", "aspire.log"),
            identityResolver: resolver);
 
        Assert.False(context.IdentityOverridden);
        Assert.Null(context.NuGetServiceIndexOverride);
    }
 
#if DEBUG
    [Theory]
    [InlineData("ls --cli-wait-for-debugger")]
    [InlineData("run --cli-wait-for-debugger")]
    [InlineData("doctor --cli-wait-for-debugger")]
    public void WaitForDebuggerIfRequested_WithSubcommand_CallsShowStatus(string commandLine)
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var testInteractionService = new TestInteractionService();
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.InteractionServiceFactory = _ => testInteractionService;
        });
        using var provider = services.BuildServiceProvider();
        var command = provider.GetRequiredService<RootCommand>();
        var parseResult = command.Parse(commandLine);
 
        var waitActionCalled = false;
        Program.WaitForDebuggerIfRequested(parseResult, provider, waitAction: () => waitActionCalled = true);
 
        Assert.True(waitActionCalled);
        var expectedStatus = string.Format(CultureInfo.CurrentCulture, RootCommandStrings.WaitingForDebugger, Environment.ProcessId);
        Assert.Collection(testInteractionService.ShownStatuses, status => Assert.Equal(expectedStatus, status));
    }
 
    [Fact]
    public void WaitForDebuggerIfRequested_WithoutFlag_DoesNotCallShowStatus()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var testInteractionService = new TestInteractionService();
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
        {
            options.InteractionServiceFactory = _ => testInteractionService;
        });
        using var provider = services.BuildServiceProvider();
        var command = provider.GetRequiredService<RootCommand>();
        var parseResult = command.Parse("ls");
 
        var waitActionCalled = false;
        Program.WaitForDebuggerIfRequested(parseResult, provider, waitAction: () => waitActionCalled = true);
 
        Assert.False(waitActionCalled);
        Assert.Empty(testInteractionService.ShownStatuses);
    }
#endif
 
    private static string WriteBinaryWithSidecar(string binaryDir, string source, string? channel = null)
    {
        Directory.CreateDirectory(binaryDir);
        var binaryPath = Path.Combine(binaryDir, OperatingSystem.IsWindows() ? "aspire.exe" : "aspire");
        File.WriteAllText(binaryPath, string.Empty);
        var channelField = channel is not null ? $",\"channel\":\"{channel}\"" : "";
        File.WriteAllText(Path.Combine(binaryDir, InstallSidecarReader.SidecarFileName), $$"""{"source":"{{source}}"{{channelField}}}""");
 
        return binaryPath;
    }
}