File: Utils\CliTestHelper.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.Text;
using Aspire.Cli.Agents;
using Aspire.Cli.Backchannel;
using Aspire.Cli.Certificates;
using Aspire.Cli.Commands;
using Aspire.Cli.Commands.Sdk;
using Aspire.Cli.DotNet;
using Aspire.Cli.Git;
using Aspire.Cli.Interaction;
using Aspire.Cli.Layout;
using Aspire.Cli.Mcp;
using Aspire.Cli.Mcp.Docs;
using Aspire.Cli.NuGet;
using Aspire.Cli.Projects;
using Aspire.Cli.Scaffolding;
using Aspire.Cli.Telemetry;
using Aspire.Cli.Templating;
using Aspire.Cli.Tests.Telemetry;
using Aspire.Cli.Tests.TestServices;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Spectre.Console;
using Aspire.Cli.Configuration;
using Aspire.Cli.Utils;
using Aspire.Cli.Utils.EnvironmentChecker;
using Aspire.Cli.Packaging;
using Aspire.Cli.Caching;
using Aspire.Cli.Diagnostics;
 
namespace Aspire.Cli.Tests.Utils;
 
internal static class CliTestHelper
{
    public static IServiceCollection CreateServiceCollection(TemporaryWorkspace workspace, ITestOutputHelper outputHelper, Action<CliServiceCollectionTestOptions>? configure = null)
    {
        var options = new CliServiceCollectionTestOptions(outputHelper, workspace.WorkspaceRoot);
        configure?.Invoke(options);
 
        var services = new ServiceCollection();
 
        var configBuilder = new ConfigurationBuilder();
 
        var configurationValues = new Dictionary<string, string?>();
 
        // Populate feature flag configuration in in-memory collection.
        options.ConfigurationCallback += config => {
            foreach (var featureFlag in options.EnabledFeatures)
            {
                config[$"{KnownFeatures.FeaturePrefix}:{featureFlag}"] = "true";
            }
 
            foreach (var featureFlag in options.DisabledFeatures)
            {
                config[$"{KnownFeatures.FeaturePrefix}:{featureFlag}"] = "false";
            }
        };
 
        options.ConfigurationCallback(configurationValues);
 
        configBuilder.AddInMemoryCollection(configurationValues);
 
        var globalSettingsFilePath = Path.Combine(options.WorkingDirectory.FullName, ".aspire", "settings.global.json");
        var globalSettingsFile = new FileInfo(globalSettingsFilePath);
        ConfigurationHelper.RegisterSettingsFiles(configBuilder, options.WorkingDirectory, globalSettingsFile);
 
        var configuration = configBuilder.Build();
        services.AddSingleton<IConfiguration>(configuration);
 
        services.AddLogging(b => b.SetMinimumLevel(LogLevel.Trace)).AddXunitLogging(outputHelper);
 
        // Register a FileLoggerProvider that writes to a test-specific temp directory
        var testLogsDirectory = Path.Combine(options.WorkingDirectory.FullName, ".aspire", "logs");
        var fileLoggerProvider = new FileLoggerProvider(testLogsDirectory, TimeProvider.System);
        services.AddSingleton(fileLoggerProvider);
 
        services.AddMemoryCache();
 
        services.AddSingleton(options.ConsoleEnvironmentFactory);
        services.AddSingleton(sp => sp.GetRequiredService<ConsoleEnvironment>().Out);
        services.AddSingleton(TimeProvider.System);
        services.AddSingleton(options.TelemetryFactory);
        services.AddSingleton(options.ProjectLocatorFactory);
        services.AddSingleton(options.SolutionLocatorFactory);
        services.AddSingleton(options.ExtensionRpcTargetFactory);
        services.AddTransient(options.ExtensionBackchannelFactory);
        services.AddSingleton(options.InteractionServiceFactory);
        services.AddSingleton(options.CertificateToolRunnerFactory);
        services.AddSingleton(options.CertificateServiceFactory);
        services.AddSingleton(options.NewCommandPrompterFactory);
        services.AddSingleton(options.AddCommandPrompterFactory);
        services.AddSingleton(options.PublishCommandPrompterFactory);
        services.AddTransient(options.DotNetCliExecutionFactoryFactory);
        services.AddTransient(options.DotNetCliRunnerFactory);
        services.AddTransient(options.NuGetPackageCacheFactory);
        services.AddSingleton(options.TemplateProviderFactory);
        services.TryAddEnumerable(ServiceDescriptor.Singleton<ITemplateFactory, DotNetTemplateFactory>());
        services.AddSingleton(options.ConfigurationServiceFactory);
        services.AddSingleton(options.FeatureFlagsFactory);
        services.AddSingleton(options.CliUpdateNotifierFactory);
        services.AddSingleton<IDotNetSdkInstaller>(options.DotNetSdkInstallerFactory);
        services.AddSingleton(options.PackagingServiceFactory);
        services.AddSingleton(options.CliExecutionContextFactory);
        services.AddSingleton(options.DiskCacheFactory);
        services.AddSingleton(options.CliHostEnvironmentFactory);
        services.AddSingleton(options.CliDownloaderFactory);
        services.AddSingleton(options.FirstTimeUseNoticeSentinelFactory);
        services.AddSingleton(options.BannerServiceFactory);
        services.AddSingleton<FallbackProjectParser>();
        services.AddSingleton(options.ProjectUpdaterFactory);
        services.AddSingleton<NuGetPackagePrefetcher>();
        services.AddSingleton<IHostedService>(sp => sp.GetRequiredService<NuGetPackagePrefetcher>());
        services.AddSingleton(options.AuxiliaryBackchannelMonitorFactory);
        services.AddSingleton(options.AgentEnvironmentDetectorFactory);
        services.AddSingleton(options.GitRepositoryFactory);
        services.AddSingleton<IScaffoldingService, ScaffoldingService>();
        services.AddSingleton<IAppHostServerProjectFactory, AppHostServerProjectFactory>();
        services.AddSingleton(options.AppHostServerSessionFactory);
        services.AddSingleton<ILanguageDiscovery, DefaultLanguageDiscovery>();
        services.AddSingleton(options.LanguageServiceFactory);
 
        // Bundle layout services - return null/no-op implementations to trigger SDK mode fallback
        // This ensures backward compatibility: no layout found = use legacy SDK mode
        services.AddSingleton(options.LayoutDiscoveryFactory);
        services.AddSingleton(options.BundleDownloaderFactory);
        services.AddSingleton<BundleNuGetService>();
 
        // AppHost project handlers - must match Program.cs registration pattern
        services.AddSingleton<DotNetAppHostProject>();
        services.AddSingleton<Func<LanguageInfo, GuestAppHostProject>>(sp =>
        {
            return language => ActivatorUtilities.CreateInstance<GuestAppHostProject>(sp, language);
        });
        services.AddSingleton<IAppHostProjectFactory, AppHostProjectFactory>();
 
        services.AddSingleton<IEnvironmentCheck, WslEnvironmentCheck>();
        services.AddSingleton<IEnvironmentCheck, DotNetSdkCheck>();
        services.AddSingleton<IEnvironmentCheck, DeprecatedWorkloadCheck>();
        services.AddSingleton<IEnvironmentCheck, DevCertsCheck>();
        services.AddSingleton<IEnvironmentCheck, ContainerRuntimeCheck>();
        services.AddSingleton<IEnvironmentCheck, DeprecatedAgentConfigCheck>();
        services.AddSingleton<IEnvironmentChecker, EnvironmentChecker>();
 
        // MCP server transport
        services.AddSingleton(options.McpServerTransportFactory);
 
        // MCP docs services - use test doubles
        services.AddSingleton<IDocsCache, DocsCache>();
        services.AddSingleton<IHttpClientFactory, TestHttpClientFactory>();
        services.AddSingleton<IDocsFetcher, TestDocsFetcher>();
        services.AddSingleton(options.DocsIndexServiceFactory);
        services.AddSingleton(options.DocsSearchServiceFactory);
 
        services.AddTransient<RootCommand>();
        services.AddTransient<NewCommand>();
        services.AddTransient<InitCommand>();
        services.AddTransient<RunCommand>();
        services.AddTransient<StopCommand>();
        services.AddTransient<StartCommand>();
        services.AddTransient<RestartCommand>();
        services.AddTransient<ResourceCommand>();
        services.AddTransient<PsCommand>();
        services.AddTransient<ResourcesCommand>();
        services.AddTransient<LogsCommand>();
        services.AddTransient<ExecCommand>();
        services.AddTransient<AddCommand>();
        services.AddTransient<DeployCommand>();
        services.AddTransient<DoCommand>();
        services.AddTransient<PublishCommand>();
        services.AddTransient<ConfigCommand>();
        services.AddTransient<CacheCommand>();
        services.AddTransient<DoctorCommand>();
        services.AddTransient<UpdateCommand>();
        services.AddTransient<McpCommand>();
        services.AddTransient<McpStartCommand>();
        services.AddTransient<McpInitCommand>();
        services.AddTransient<AgentCommand>();
        services.AddTransient<AgentMcpCommand>();
        services.AddTransient<AgentInitCommand>();
        services.AddTransient<TelemetryCommand>();
        services.AddTransient<TelemetryLogsCommand>();
        services.AddTransient<TelemetrySpansCommand>();
        services.AddTransient<TelemetryTracesCommand>();
        services.AddTransient<ExtensionInternalCommand>();
        services.AddTransient<SdkCommand>();
        services.AddTransient<SdkGenerateCommand>();
        services.AddTransient<SdkDumpCommand>();
        services.AddTransient<DocsCommand>();
        services.AddTransient<DocsListCommand>();
        services.AddTransient<DocsSearchCommand>();
        services.AddTransient<DocsGetCommand>();
        services.AddTransient(options.AppHostBackchannelFactory);
 
        return services;
    }
}
 
internal sealed class CliServiceCollectionTestOptions
{
    private readonly ITestOutputHelper _outputHelper;
 
    public CliServiceCollectionTestOptions(ITestOutputHelper outputHelper, DirectoryInfo workingDirectory)
    {
        _outputHelper = outputHelper;
        WorkingDirectory = workingDirectory;
 
        ProjectLocatorFactory = CreateDefaultProjectLocatorFactory;
        SolutionLocatorFactory = CreateDefaultSolutionLocatorFactory;
        ConfigurationServiceFactory = CreateDefaultConfigurationServiceFactory;
        CliExecutionContextFactory = CreateDefaultCliExecutionContextFactory;
    }
 
    private CliExecutionContext CreateDefaultCliExecutionContextFactory(IServiceProvider provider)
    {
        var hivesDirectory = new DirectoryInfo(Path.Combine(WorkingDirectory.FullName, ".aspire", "hives"));
        var cacheDirectory = new DirectoryInfo(Path.Combine(WorkingDirectory.FullName, ".aspire", "cache"));
        var logsDirectory = new DirectoryInfo(Path.Combine(WorkingDirectory.FullName, ".aspire", "logs"));
        var logFilePath = Path.Combine(logsDirectory.FullName, "test.log");
        return new CliExecutionContext(WorkingDirectory, hivesDirectory, cacheDirectory, new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-sdks")), logsDirectory, logFilePath);
    }
 
    public DirectoryInfo WorkingDirectory { get; set; }
 
    public Action<Dictionary<string, string?>> ConfigurationCallback { get; set; } = (Dictionary<string, string?> config) =>
    {
    };
 
    public string[] EnabledFeatures { get; set; } = Array.Empty<string>();
    public string[] DisabledFeatures { get; set; } = Array.Empty<string>();
 
    public TestOutputTextWriter? OutputTextWriter { get; set; }
    public StringWriter? ErrorTextWriter { get; set; }
 
    public Func<IServiceProvider, ConsoleEnvironment> ConsoleEnvironmentFactory => (IServiceProvider serviceProvider) =>
    {
        var outputTextWriter = OutputTextWriter ?? new TestOutputTextWriter(_outputHelper);
        var errorTextWriter = ErrorTextWriter ?? new StringWriter();
 
        var outConsole = CreateAnsiConsole(outputTextWriter);
        var errorConsole = CreateAnsiConsole(errorTextWriter);
 
        return new ConsoleEnvironment(outConsole, errorConsole);
    };
 
    private static IAnsiConsole CreateAnsiConsole(TextWriter textWriter)
    {
        var settings = new AnsiConsoleSettings()
        {
            Ansi = AnsiSupport.Yes,
            Interactive = InteractionSupport.Yes,
            ColorSystem = ColorSystemSupport.Standard,
            Out = new AnsiConsoleOutput(textWriter)
        };
        return AnsiConsole.Create(settings);
    }
 
    public Func<IServiceProvider, INewCommandPrompter> NewCommandPrompterFactory { get; set; } = (IServiceProvider serviceProvider) =>
    {
        var interactionService = serviceProvider.GetRequiredService<IInteractionService>();
        return new NewCommandPrompter(interactionService);
    };
 
    public Func<IServiceProvider, ICliUpdateNotifier> CliUpdateNotifierFactory { get; set; } = (IServiceProvider serviceProvider) =>
    {
        var logger = NullLoggerFactory.Instance.CreateLogger<CliUpdateNotifier>();
        var nuGetPackageCache = serviceProvider.GetRequiredService<INuGetPackageCache>();
        var interactionService = serviceProvider.GetRequiredService<IInteractionService>();
        return new CliUpdateNotifier(logger, nuGetPackageCache, interactionService);
    };
 
    public Func<IServiceProvider, IAddCommandPrompter> AddCommandPrompterFactory { get; set; } = (IServiceProvider serviceProvider) =>
    {
        var interactionService = serviceProvider.GetRequiredService<IInteractionService>();
        return new AddCommandPrompter(interactionService);
    };
 
    public Func<IServiceProvider, IPublishCommandPrompter> PublishCommandPrompterFactory { get; set; } = (IServiceProvider serviceProvider) =>
    {
        var interactionService = serviceProvider.GetRequiredService<IInteractionService>();
        return new PublishCommandPrompter(interactionService);
    };
 
    public Func<IServiceProvider, IConfigurationService> ConfigurationServiceFactory { get; set; }
 
    public IConfigurationService CreateDefaultConfigurationServiceFactory(IServiceProvider serviceProvider)
    {
        var configuration = serviceProvider.GetRequiredService<IConfiguration>();
        var executionContext = serviceProvider.GetRequiredService<CliExecutionContext>();
        return new ConfigurationService(configuration, executionContext, GetGlobalSettingsFile(WorkingDirectory));
    }
 
    private static FileInfo GetGlobalSettingsFile(DirectoryInfo workingDirectory)
    {
        var globalSettingsFilePath = Path.Combine(workingDirectory.FullName, ".aspire", "settings.global.json");
        return new FileInfo(globalSettingsFilePath);
    }
 
    public Func<IServiceProvider, IProjectLocator> ProjectLocatorFactory { get; set; }
    public Func<IServiceProvider, ISolutionLocator> SolutionLocatorFactory { get; set; }
    public Func<IServiceProvider, CliExecutionContext> CliExecutionContextFactory { get; set; }
    public Func<IServiceProvider, IFirstTimeUseNoticeSentinel> FirstTimeUseNoticeSentinelFactory { get; set; } = _ => new TestFirstTimeUseNoticeSentinel();
    public Func<IServiceProvider, IBannerService> BannerServiceFactory { get; set; } = _ => new TestBannerService();
 
    public IProjectLocator CreateDefaultProjectLocatorFactory(IServiceProvider serviceProvider)
    {
        var logger = serviceProvider.GetRequiredService<ILogger<ProjectLocator>>();
        var executionContext = serviceProvider.GetRequiredService<CliExecutionContext>();
        var interactionService = serviceProvider.GetRequiredService<IInteractionService>();
        var configurationService = serviceProvider.GetRequiredService<IConfigurationService>();
        var projectFactory = serviceProvider.GetService<IAppHostProjectFactory>() ?? new TestAppHostProjectFactory();
        var languageDiscovery = serviceProvider.GetService<ILanguageDiscovery>() ?? new TestLanguageDiscovery();
        var telemetry = serviceProvider.GetRequiredService<AspireCliTelemetry>();
        return new ProjectLocator(logger, executionContext, interactionService, configurationService, projectFactory, languageDiscovery, telemetry);
    }
 
    public ISolutionLocator CreateDefaultSolutionLocatorFactory(IServiceProvider serviceProvider)
    {
        var logger = serviceProvider.GetRequiredService<ILogger<SolutionLocator>>();
        var interactionService = serviceProvider.GetRequiredService<IInteractionService>();
        return new SolutionLocator(logger, interactionService);
    }
 
    public Func<IServiceProvider, AspireCliTelemetry> TelemetryFactory { get; set; } = (IServiceProvider serviceProvider) =>
    {
        return TestTelemetryHelper.CreateInitializedTelemetry();
    };
 
    public Func<IServiceProvider, IProjectUpdater> ProjectUpdaterFactory { get; set; } = (IServiceProvider serviceProvider) =>
    {
        var logger = serviceProvider.GetRequiredService<ILogger<ProjectUpdater>>();
        var runner = serviceProvider.GetRequiredService<IDotNetCliRunner>();
        var interactionService = serviceProvider.GetRequiredService<IInteractionService>();
        var cache = serviceProvider.GetRequiredService<IMemoryCache>();
        var executionContext = serviceProvider.GetRequiredService<CliExecutionContext>();
        var fallbackParser = serviceProvider.GetRequiredService<FallbackProjectParser>();
        return new ProjectUpdater(logger, runner, interactionService, cache, executionContext, fallbackParser);
    };
 
    public Func<IServiceProvider, ICliHostEnvironment> CliHostEnvironmentFactory { get; set; } = (IServiceProvider serviceProvider) =>
    {
        var configuration = serviceProvider.GetRequiredService<IConfiguration>();
        return new CliHostEnvironment(configuration, nonInteractive: false);
    };
 
    public Func<IServiceProvider, IInteractionService> InteractionServiceFactory { get; set; } = (IServiceProvider serviceProvider) =>
    {
        var consoleEnvironment = serviceProvider.GetRequiredService<ConsoleEnvironment>();
        var executionContext = serviceProvider.GetRequiredService<CliExecutionContext>();
        var hostEnvironment = serviceProvider.GetRequiredService<ICliHostEnvironment>();
        return new ConsoleInteractionService(consoleEnvironment, executionContext, hostEnvironment);
    };
 
    public Func<IServiceProvider, ICertificateToolRunner> CertificateToolRunnerFactory { get; set; } = (IServiceProvider _) =>
    {
        // Use TestCertificateToolRunner by default to avoid calling real dotnet dev-certs
        // which can be slow or block on macOS (keychain access prompts)
        return new TestCertificateToolRunner();
    };
 
    public Func<IServiceProvider, ICertificateService> CertificateServiceFactory { get; set; } = (IServiceProvider serviceProvider) =>
    {
        var certificateToolRunner = serviceProvider.GetRequiredService<ICertificateToolRunner>();
        var interactiveService = serviceProvider.GetRequiredService<IInteractionService>();
        var telemetry = serviceProvider.GetRequiredService<AspireCliTelemetry>();
        return new CertificateService(certificateToolRunner, interactiveService, telemetry);
    };
 
    public Func<IServiceProvider, IDotNetCliExecutionFactory> DotNetCliExecutionFactoryFactory { get; set; } = (IServiceProvider serviceProvider) =>
    {
        return new TestDotNetCliExecutionFactory();
    };
 
    public Func<IServiceProvider, IDotNetCliRunner> DotNetCliRunnerFactory { get; set; } = (IServiceProvider serviceProvider) =>
    {
        var logger = serviceProvider.GetRequiredService<ILogger<DotNetCliRunner>>();
        var telemetry = serviceProvider.GetRequiredService<AspireCliTelemetry>();
        var configuration = serviceProvider.GetRequiredService<IConfiguration>();
        var features = serviceProvider.GetRequiredService<IFeatures>();
        var diskCache = serviceProvider.GetRequiredService<IDiskCache>();
        var executionContext = serviceProvider.GetRequiredService<CliExecutionContext>();
        var executionFactory = serviceProvider.GetRequiredService<IDotNetCliExecutionFactory>();
        var interactionService = serviceProvider.GetRequiredService<IInteractionService>();
 
        return new DotNetCliRunner(logger, serviceProvider, telemetry, configuration, diskCache, features, interactionService, executionContext, executionFactory);
    };
 
    public Func<IServiceProvider, IDotNetSdkInstaller> DotNetSdkInstallerFactory { get; set; } = (IServiceProvider serviceProvider) =>
    {
        return new TestDotNetSdkInstaller();
    };
 
    public Func<IServiceProvider, INuGetPackageCache> NuGetPackageCacheFactory { get; set; } = (IServiceProvider serviceProvider) =>
    {
        var runner = serviceProvider.GetRequiredService<IDotNetCliRunner>();
        var cache = serviceProvider.GetRequiredService<IMemoryCache>();
        var telemetry = serviceProvider.GetRequiredService<AspireCliTelemetry>();
        var features = serviceProvider.GetRequiredService<IFeatures>();
        return new NuGetPackageCache(runner, cache, telemetry, features);
    };
 
    public Func<IServiceProvider, IAppHostCliBackchannel> AppHostBackchannelFactory { get; set; } = (IServiceProvider serviceProvider) =>
    {
        var logger = serviceProvider.GetRequiredService<ILogger<AppHostCliBackchannel>>();
        var telemetry = serviceProvider.GetRequiredService<AspireCliTelemetry>();
        return new AppHostCliBackchannel(logger, telemetry);
    };
 
    public Func<IServiceProvider, IExtensionRpcTarget> ExtensionRpcTargetFactory { get; set; } = (IServiceProvider serviceProvider) =>
    {
        var configuration = serviceProvider.GetRequiredService<IConfiguration>();
        return new ExtensionRpcTarget(configuration);
    };
 
    public Func<IServiceProvider, IExtensionBackchannel> ExtensionBackchannelFactory { get; set; } = serviceProvider =>
    {
        var logger = serviceProvider.GetRequiredService<ILogger<ExtensionBackchannel>>();
        var rpcTarget = serviceProvider.GetRequiredService<IExtensionRpcTarget>();
        var configuration = serviceProvider.GetRequiredService<IConfiguration>();
        return new ExtensionBackchannel(logger, rpcTarget, configuration);
    };
 
    public Func<IServiceProvider, IFeatures> FeatureFlagsFactory { get; set; } = (IServiceProvider serviceProvider) =>
    {
        var configuration = serviceProvider.GetRequiredService<IConfiguration>();
        var logger = serviceProvider.GetRequiredService<ILogger<Features>>();
        return new Features(configuration, logger);
    };
 
    public Func<IServiceProvider, ITemplateProvider> TemplateProviderFactory { get; set; } = (IServiceProvider serviceProvider) =>
    {
        var interactionService = serviceProvider.GetRequiredService<IInteractionService>();
        var runner = serviceProvider.GetRequiredService<IDotNetCliRunner>();
        var certificateService = serviceProvider.GetRequiredService<ICertificateService>();
        var packagingService = serviceProvider.GetRequiredService<IPackagingService>();
        var prompter = serviceProvider.GetRequiredService<INewCommandPrompter>();
        var executionContext = serviceProvider.GetRequiredService<CliExecutionContext>();
        var features = serviceProvider.GetRequiredService<IFeatures>();
        var configurationService = serviceProvider.GetRequiredService<IConfigurationService>();
        var hostEnvironment = serviceProvider.GetRequiredService<ICliHostEnvironment>();
        var factory = new DotNetTemplateFactory(interactionService, runner, certificateService, packagingService, prompter, executionContext, features, configurationService, hostEnvironment);
        return new TemplateProvider([factory]);
    };
 
    public Func<IServiceProvider, IPackagingService> PackagingServiceFactory { get; set; } = (IServiceProvider serviceProvider) =>
    {
        var executionContext = serviceProvider.GetRequiredService<CliExecutionContext>();
        var nuGetPackageCache = serviceProvider.GetRequiredService<INuGetPackageCache>();
        var features = serviceProvider.GetRequiredService<IFeatures>();
        var configuration = serviceProvider.GetRequiredService<IConfiguration>();
        return new PackagingService(executionContext, nuGetPackageCache, features, configuration);
    };
 
    public Func<IServiceProvider, IDiskCache> DiskCacheFactory { get; set; } = (IServiceProvider serviceProvider) => new NullDiskCache();
 
    public Func<IServiceProvider, ICliDownloader> CliDownloaderFactory { get; set; } = (IServiceProvider serviceProvider) =>
    {
        var executionContext = serviceProvider.GetRequiredService<CliExecutionContext>();
        var tmpDirectory = new DirectoryInfo(Path.Combine(executionContext.WorkingDirectory.FullName, "tmp"));
        return new TestCliDownloader(tmpDirectory);
    };
 
    public Func<IServiceProvider, IAuxiliaryBackchannelMonitor> AuxiliaryBackchannelMonitorFactory { get; set; } = (IServiceProvider serviceProvider) =>
    {
        return new TestAuxiliaryBackchannelMonitor();
    };
 
    public Func<IServiceProvider, IAgentEnvironmentDetector> AgentEnvironmentDetectorFactory { get; set; } = (IServiceProvider serviceProvider) =>
    {
        return new AgentEnvironmentDetector([]);
    };
 
    public Func<IServiceProvider, IGitRepository> GitRepositoryFactory { get; set; } = (IServiceProvider serviceProvider) =>
    {
        var executionContext = serviceProvider.GetRequiredService<CliExecutionContext>();
        var logger = serviceProvider.GetRequiredService<ILogger<GitRepository>>();
        return new GitRepository(executionContext, logger);
    };
 
    public Func<IServiceProvider, ILanguageService> LanguageServiceFactory { get; set; } = (IServiceProvider serviceProvider) =>
    {
        var projects = serviceProvider.GetServices<IAppHostProject>();
        var defaultProject = projects.FirstOrDefault(p => p.LanguageId == KnownLanguageId.CSharp);
        return new TestLanguageService { DefaultProject = defaultProject };
    };
 
    public Func<IServiceProvider, IAppHostServerSessionFactory> AppHostServerSessionFactory { get; set; } = (IServiceProvider serviceProvider) =>
    {
        return new TestAppHostServerSessionFactory();
    };
 
    // Layout discovery - returns null by default (no bundle layout), causing SDK mode fallback
    public Func<IServiceProvider, ILayoutDiscovery> LayoutDiscoveryFactory { get; set; } = _ => new NullLayoutDiscovery();
 
    // Bundle downloader - returns a no-op implementation that indicates no bundle mode
    // This causes UpdateCommand to fall back to CLI-only update or show dotnet tool instructions
    public Func<IServiceProvider, IBundleDownloader> BundleDownloaderFactory { get; set; } = (IServiceProvider serviceProvider) =>
    {
        return new NullBundleDownloader();
    };
 
    public Func<IServiceProvider, IMcpTransportFactory> McpServerTransportFactory { get; set; } = (IServiceProvider serviceProvider) =>
    {
        var loggerFactory = serviceProvider.GetService<ILoggerFactory>();
        return new StdioMcpTransportFactory(loggerFactory ?? NullLoggerFactory.Instance);
    };
 
    public Func<IServiceProvider, IDocsIndexService> DocsIndexServiceFactory { get; set; } = (IServiceProvider serviceProvider) =>
    {
        var fetcher = serviceProvider.GetRequiredService<IDocsFetcher>();
        var cache = serviceProvider.GetRequiredService<IDocsCache>();
        var logger = serviceProvider.GetRequiredService<ILogger<DocsIndexService>>();
        return new DocsIndexService(fetcher, cache, logger);
    };
 
    public Func<IServiceProvider, IDocsSearchService> DocsSearchServiceFactory { get; set; } = (IServiceProvider serviceProvider) =>
    {
        var indexService = serviceProvider.GetRequiredService<IDocsIndexService>();
        var logger = serviceProvider.GetRequiredService<ILogger<DocsSearchService>>();
        return new DocsSearchService(indexService, logger);
    };
}
 
/// <summary>
/// A layout discovery that always returns null (no bundle layout).
/// Used in tests to ensure SDK mode is used.
/// </summary>
internal sealed class NullLayoutDiscovery : ILayoutDiscovery
{
    public LayoutConfiguration? DiscoverLayout(string? projectDirectory = null) => null;
 
    public string? GetComponentPath(LayoutComponent component, string? projectDirectory = null) => null;
 
    public bool IsBundleModeAvailable(string? projectDirectory = null) => false;
}
 
/// <summary>
/// A no-op bundle downloader that always returns "no updates available".
/// Used in tests to ensure backward compatibility - no layout = SDK mode.
/// </summary>
internal sealed class NullBundleDownloader : IBundleDownloader
{
    public Task<string> DownloadLatestBundleAsync(CancellationToken cancellationToken)
        => throw new NotSupportedException("Bundle downloads not available in test environment");
 
    public Task<string?> GetLatestVersionAsync(CancellationToken cancellationToken)
        => Task.FromResult<string?>(null);
 
    public Task<bool> IsUpdateAvailableAsync(string currentVersion, CancellationToken cancellationToken)
        => Task.FromResult(false);
 
    public Task<BundleUpdateResult> ApplyUpdateAsync(string archivePath, string installPath, CancellationToken cancellationToken)
        => Task.FromResult(BundleUpdateResult.Failed("Bundle updates not available in test environment"));
}
 
internal sealed class TestOutputTextWriter : TextWriter
{
    private readonly ITestOutputHelper _outputHelper;
    public List<string> Logs { get; } = new List<string>();
 
    public TestOutputTextWriter(ITestOutputHelper outputHelper) : this(outputHelper, null)
    {
    }
 
    public TestOutputTextWriter(ITestOutputHelper outputHelper, IFormatProvider? formatProvider) : base(formatProvider)
    {
        _outputHelper = outputHelper;
    }
 
    public override Encoding Encoding => Encoding.UTF8;
 
    public override void WriteLine(string? message)
    {
        _outputHelper.WriteLine(message!);
        if (message is not null)
        {
            Logs.Add(message);
        }
    }
 
    public override void Write(string? message)
    {
        _outputHelper.Write(message!);
        if (message is not null)
        {
            Logs.Add(message);
        }
    }
 
}