File: TestServices\TestDotNetCliExecutionFactory.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 Aspire.Cli.Caching;
using Aspire.Cli.Configuration;
using Aspire.Cli.DotNet;
using Aspire.Cli.Interaction;
using Aspire.Cli.Telemetry;
using Aspire.Cli.Tests.Telemetry;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
 
namespace Aspire.Cli.Tests.TestServices;
 
internal sealed class TestDotNetCliExecutionFactory : IDotNetCliExecutionFactory
{
    private int _attemptCount;
 
    /// <summary>
    /// Gets or sets a callback that is invoked when <see cref="CreateExecution"/> is called.
    /// If this returns an <see cref="IDotNetCliExecution"/>, that execution is returned directly.
    /// </summary>
    public Func<string[], IDictionary<string, string>?, DirectoryInfo, DotNetCliRunnerInvocationOptions, IDotNetCliExecution>? CreateExecutionCallback { get; set; }
 
    /// <summary>
    /// Gets or sets an action that is invoked when <see cref="CreateExecution"/> is called,
    /// typically used for assertions on the arguments.
    /// </summary>
    public Action<string[], IDictionary<string, string>?, DirectoryInfo, DotNetCliRunnerInvocationOptions>? AssertionCallback { get; set; }
 
    /// <summary>
    /// Gets or sets a callback that is invoked for each execution attempt, receiving the attempt number (1-based)
    /// and options, and returning the exit code and optional stdout content.
    /// This is used for testing retry scenarios.
    /// </summary>
    public Func<int, DotNetCliRunnerInvocationOptions, (int ExitCode, string? Stdout)>? AttemptCallback { get; set; }
 
    /// <summary>
    /// When set, the execution will use this exit code when <see cref="IDotNetCliExecution.WaitForExitAsync"/> is called.
    /// </summary>
    public int DefaultExitCode { get; set; }
 
    /// <summary>
    /// When set, the interaction service that may be used to simulate DevKit extension behavior.
    /// </summary>
    public IInteractionService? InteractionService { get; set; }
 
    /// <summary>
    /// Gets the number of times <see cref="CreateExecution"/> has been called.
    /// </summary>
    public int AttemptCount => _attemptCount;
 
    public IDotNetCliExecution CreateExecution(string[] args, IDictionary<string, string>? env, DirectoryInfo workingDirectory, DotNetCliRunnerInvocationOptions options)
    {
        _attemptCount++;
 
        // Invoke assertion callback if set
        AssertionCallback?.Invoke(args, env, workingDirectory, options);
 
        // If a custom callback is provided, use it
        if (CreateExecutionCallback is not null)
        {
            return CreateExecutionCallback(args, env, workingDirectory, options);
        }
 
        // Use AttemptCallback if provided, otherwise create a simple callback that returns the default exit code
        var callback = AttemptCallback ?? ((_, _) => (DefaultExitCode, null));
        return new TestDotNetCliExecution(args, env, options, callback, () => _attemptCount);
    }
}
 
internal sealed class TestDotNetCliExecution : IDotNetCliExecution
{
    private readonly DotNetCliRunnerInvocationOptions _options;
    private readonly Func<int, DotNetCliRunnerInvocationOptions, (int ExitCode, string? Stdout)> _attemptCallback;
    private readonly Func<int> _attemptCounter;
    private bool _started;
 
    public TestDotNetCliExecution(
        string[] args,
        IDictionary<string, string>? env,
        DotNetCliRunnerInvocationOptions options,
        Func<int, DotNetCliRunnerInvocationOptions, (int ExitCode, string? Stdout)> attemptCallback,
        Func<int> attemptCounter)
    {
        Arguments = args;
        EnvironmentVariables = env?.ToDictionary(kvp => kvp.Key, kvp => (string?)kvp.Value)
            ?? new Dictionary<string, string?>();
        _options = options;
        _attemptCallback = attemptCallback;
        _attemptCounter = attemptCounter;
    }
 
    public string FileName => "dotnet";
 
    public IReadOnlyList<string> Arguments { get; }
 
    public IReadOnlyDictionary<string, string?> EnvironmentVariables { get; }
 
    public bool HasExited => false;
 
    public int ExitCode => 0;
 
    public bool Start()
    {
        _started = true;
        return true;
    }
 
    public Task<int> WaitForExitAsync(CancellationToken cancellationToken)
    {
        if (!_started)
        {
            throw new InvalidOperationException("Process has not been started.");
        }
 
        var attempt = _attemptCounter();
        var (exitCode, stdout) = _attemptCallback(attempt, _options);
        if (stdout is not null)
        {
            _options.StandardOutputCallback?.Invoke(stdout);
        }
        return Task.FromResult(exitCode);
    }
}
 
/// <summary>
/// Helper class for creating a <see cref="DotNetCliRunner"/> with a <see cref="TestDotNetCliExecutionFactory"/>
/// configured for assertion-based testing.
/// </summary>
internal static class DotNetCliRunnerTestHelper
{
    /// <summary>
    /// Creates a <see cref="DotNetCliRunner"/> with an assertion callback that is invoked on each execution.
    /// </summary>
    public static DotNetCliRunner Create(
        IServiceProvider serviceProvider,
        CliExecutionContext executionContext,
        Action<string[], IDictionary<string, string>?, DirectoryInfo, DotNetCliRunnerInvocationOptions> assertionCallback,
        int exitCode = 0,
        ILogger<DotNetCliRunner>? logger = null,
        AspireCliTelemetry? telemetry = null,
        IConfiguration? configuration = null,
        IDiskCache? diskCache = null)
    {
        var executionFactory = new TestDotNetCliExecutionFactory
        {
            AssertionCallback = assertionCallback,
            DefaultExitCode = exitCode
        };
 
        return new DotNetCliRunner(
            logger ?? serviceProvider.GetRequiredService<ILogger<DotNetCliRunner>>(),
            serviceProvider,
            telemetry ?? TestTelemetryHelper.CreateInitializedTelemetry(),
            configuration ?? serviceProvider.GetRequiredService<IConfiguration>(),
            diskCache ?? new NullDiskCache(),
            serviceProvider.GetRequiredService<IFeatures>(),
            serviceProvider.GetRequiredService<IInteractionService>(),
            executionContext,
            executionFactory);
    }
 
    /// <summary>
    /// Creates a <see cref="DotNetCliRunner"/> with an attempt callback for testing retry scenarios.
    /// Returns both the runner and the factory so the test can check <see cref="TestDotNetCliExecutionFactory.AttemptCount"/>.
    /// </summary>
    public static (DotNetCliRunner Runner, TestDotNetCliExecutionFactory ExecutionFactory) CreateWithRetry(
        IServiceProvider serviceProvider,
        CliExecutionContext executionContext,
        Func<int, DotNetCliRunnerInvocationOptions, (int ExitCode, string? Stdout)> attemptCallback,
        ILogger<DotNetCliRunner>? logger = null,
        AspireCliTelemetry? telemetry = null,
        IConfiguration? configuration = null,
        IDiskCache? diskCache = null)
    {
        var executionFactory = new TestDotNetCliExecutionFactory
        {
            AttemptCallback = attemptCallback
        };
 
        var runner = new DotNetCliRunner(
            logger ?? serviceProvider.GetRequiredService<ILogger<DotNetCliRunner>>(),
            serviceProvider,
            telemetry ?? TestTelemetryHelper.CreateInitializedTelemetry(),
            configuration ?? serviceProvider.GetRequiredService<IConfiguration>(),
            diskCache ?? new NullDiskCache(),
            serviceProvider.GetRequiredService<IFeatures>(),
            serviceProvider.GetRequiredService<IInteractionService>(),
            executionContext,
            executionFactory);
 
        return (runner, executionFactory);
    }
}