File: Commands\DoctorCommandTests.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.Json;
using Aspire.Cli.Acquisition;
using Aspire.Cli.Interaction;
using Aspire.Cli.Projects;
using Aspire.Cli.Tests.TestServices;
using Aspire.Cli.Tests.Utils;
using Aspire.Cli.Utils;
using Aspire.Cli.Utils.EnvironmentChecker;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.AspNetCore.InternalTesting;
using Spectre.Console;
 
namespace Aspire.Cli.Tests.Commands;
 
public class DoctorCommandTests(ITestOutputHelper outputHelper)
{
    [Fact]
    public async Task DoctorCommand_Help_Works()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper);
        using var provider = services.BuildServiceProvider();
 
        var command = provider.GetRequiredService<Aspire.Cli.Commands.RootCommand>();
        var result = command.Parse("doctor --help");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
        
        // Help should return success
        Assert.Equal(CliExitCodes.Success, exitCode);
    }
 
    [Fact]
    public async Task DoctorCommand_Json_IncludesCliVersionStatus()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        using var doc = await RunDoctorJsonAsync(workspace,
            configureOptions: options =>
            {
                options.CliUpdateNotifierFactory = _ => new TestCliUpdateNotifier
                {
                    GetVersionStatusAsyncCallback = (_, _) => Task.FromResult(new CliVersionStatus("13.0.0", "13.1.0", "aspire update"))
                };
            });
 
        var cliVersionCheck = GetCheckByName(doc, AspireVersionCheck.CliVersionCheckName);
        Assert.Equal(EnvironmentCheckCategories.Aspire, cliVersionCheck.GetProperty("category").GetString());
        Assert.Equal("warning", cliVersionCheck.GetProperty("status").GetString());
        Assert.Contains("13.0.0", cliVersionCheck.GetProperty("message").GetString()!);
        Assert.Contains("13.1.0", cliVersionCheck.GetProperty("message").GetString()!);
        var cliVersionMetadata = cliVersionCheck.GetProperty("metadata");
        Assert.Equal("13.0.0", cliVersionMetadata.GetProperty("currentVersion").GetString());
        Assert.Equal("13.1.0", cliVersionMetadata.GetProperty("latestVersion").GetString());
        Assert.Equal("aspire update", cliVersionMetadata.GetProperty("updateCommand").GetString());
    }
 
    [Fact]
    public async Task DoctorCommand_Json_IncludesOperatingSystemStatus()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        using var doc = await RunDoctorJsonAsync(workspace,
            configureOptions: options =>
            {
                options.CliUpdateNotifierFactory = _ => new TestCliUpdateNotifier();
            });
 
        var osCheck = GetCheckByName(doc, OperatingSystemCheck.CheckName);
        Assert.Equal(EnvironmentCheckCategories.Environment, osCheck.GetProperty("category").GetString());
        Assert.Equal("pass", osCheck.GetProperty("status").GetString());
        Assert.StartsWith("Operating system: ", osCheck.GetProperty("message").GetString(), StringComparison.Ordinal);
        var metadata = osCheck.GetProperty("metadata");
        Assert.True(metadata.TryGetProperty("osType", out _));
        Assert.True(metadata.TryGetProperty("displayName", out _));
        Assert.True(metadata.TryGetProperty("version", out _));
    }
 
    [Fact]
    [SkipOnPlatform(TestPlatforms.Windows | TestPlatforms.OSX | TestPlatforms.FreeBSD, "Validates Linux /etc/os-release values.")]
    public async Task DoctorCommand_Json_OnLinux_UsesOsReleaseValues()
    {
        Assert.SkipUnless(File.Exists("/etc/os-release"), "Linux /etc/os-release is required for this test.");
 
        var osRelease = OperatingSystemCheck.ParseLinuxOsRelease(
            await File.ReadAllTextAsync("/etc/os-release", TestContext.Current.CancellationToken));
        Assert.True(TryGetOsReleaseValue(osRelease, "NAME", out var name), "Expected /etc/os-release to include NAME.");
 
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        using var doc = await RunDoctorJsonAsync(workspace,
            configureOptions: options =>
            {
                options.CliUpdateNotifierFactory = _ => new TestCliUpdateNotifier();
            });
 
        var osCheck = GetCheckByName(doc, OperatingSystemCheck.CheckName);
        var metadata = osCheck.GetProperty("metadata");
        var expectedDisplayName = GetExpectedLinuxDisplayName(name);
 
        Assert.Equal("Linux", metadata.GetProperty("osType").GetString());
        Assert.Equal(expectedDisplayName, metadata.GetProperty("displayName").GetString());
 
        if (TryGetOsReleaseValue(osRelease, "VERSION_ID", out var version))
        {
            Assert.Equal(version, metadata.GetProperty("version").GetString());
            Assert.Equal($"Operating system: {expectedDisplayName} {version}", osCheck.GetProperty("message").GetString());
        }
 
        if (TryGetOsReleaseValue(osRelease, "PRETTY_NAME", out var prettyName))
        {
            Assert.Equal(prettyName, metadata.GetProperty("description").GetString());
        }
    }
 
    [Fact]
    public async Task DoctorCommand_Json_VersionUpdateBanner_IsSuppressed()
    {
        // The cli-version environment check already surfaces "newer version available" inside
        // checks[]; the post-command update banner would be a second, less-structured copy of
        // the same data. DoctorCommand opts out of BaseCommand's update notifier
        // (UpdateNotificationsEnabled => false) so the banner does not fire at all — neither
        // on stdout (which would break JSON parsing) nor on stderr (where it would just be noise
        // duplicating checks[].cli-version).
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var outputWriter = new TestOutputTextWriter(outputHelper);
        var errorWriter = new StringWriter();
        var notifierInvoked = false;
 
        var services = CreateDoctorVersionServiceCollection(workspace, outputHelper, options =>
        {
            options.OutputTextWriter = outputWriter;
            options.ErrorTextWriter = errorWriter;
            options.CliUpdateNotifierFactory = sp => new TestCliUpdateNotifier
            {
                NotifyIfUpdateAvailableCallback = () =>
                {
                    notifierInvoked = true;
                    var interactionService = sp.GetRequiredService<IInteractionService>();
                    interactionService.DisplayVersionUpdateNotification("13.99.0", "aspire update");
                }
            };
        });
        using var provider = services.BuildServiceProvider();
 
        var command = provider.GetRequiredService<Aspire.Cli.Commands.RootCommand>();
        var result = command.Parse("doctor --format json");
        var exitCode = await result.InvokeAsync().DefaultTimeout();
        Assert.Equal(CliExitCodes.Success, exitCode);
 
        Assert.False(notifierInvoked, "DoctorCommand should not invoke the CLI update notifier; the cli-version check carries that information directly in checks[].");
 
        var stdoutText = string.Concat(outputWriter.Logs);
        using var doc = JsonDocument.Parse(stdoutText);
        Assert.True(doc.RootElement.TryGetProperty("checks", out _));
 
        var stderrText = errorWriter.ToString();
        Assert.DoesNotContain("13.99.0", stderrText, StringComparison.Ordinal);
    }
 
    [Fact]
    public async Task DoctorCommand_Json_IncludesAppHostVersionWhenAppHostExists()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var appHostFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "AppHost.csproj"));
        await File.WriteAllTextAsync(appHostFile.FullName, "<Project />");
 
        using var doc = await RunDoctorJsonAsync(workspace,
            configureOptions: options =>
            {
                options.CliUpdateNotifierFactory = _ => new TestCliUpdateNotifier();
                options.AppHostProjectFactory = _ => new TestAppHostProjectFactory
                {
                    GetAspireHostingVersionAsyncCallback = (_, _) => Task.FromResult<string?>("13.0.0")
                };
            });
 
        var appHostVersionCheck = GetCheckByName(doc, AspireVersionCheck.AppHostVersionCheckName);
        Assert.Equal(EnvironmentCheckCategories.AppHost, appHostVersionCheck.GetProperty("category").GetString());
        Assert.Equal("pass", appHostVersionCheck.GetProperty("status").GetString());
        Assert.Contains("13.0.0", appHostVersionCheck.GetProperty("message").GetString()!);
        Assert.Contains("AppHost.csproj", appHostVersionCheck.GetProperty("message").GetString()!);
        var appHostVersionMetadata = appHostVersionCheck.GetProperty("metadata");
        Assert.Equal("13.0.0", appHostVersionMetadata.GetProperty("version").GetString());
        Assert.Equal("AppHost.csproj", appHostVersionMetadata.GetProperty("appHostPath").GetString());
    }
 
    [Fact]
    public async Task DoctorCommand_Json_IncludesTypeScriptAppHostVersionFromAspireConfig()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var appHostFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "apphost.ts"));
        await File.WriteAllTextAsync(appHostFile.FullName, "export {};");
        await File.WriteAllTextAsync(
            Path.Combine(workspace.WorkspaceRoot.FullName, "aspire.config.json"),
            """
            {
              "sdk": {
                "version": "13.1.0"
              }
            }
            """);
 
        var runnerCalled = false;
        using var doc = await RunDoctorJsonAsync(workspace,
            configureOptions: options =>
            {
                options.CliUpdateNotifierFactory = _ => new TestCliUpdateNotifier();
                options.AppHostProjectFactory = _ => new TestAppHostProjectFactory
                {
                    CanHandleCallback = file => file.Extension.Equals(".ts", StringComparison.OrdinalIgnoreCase),
                    DetectionPatterns = ["apphost.ts"],
                    GetAspireHostingVersionAsyncCallback = (_, _) => Task.FromResult<string?>("13.1.0")
                };
                options.DotNetCliRunnerFactory = _ => new TestDotNetCliRunner
                {
                    GetAppHostInformationAsyncCallback = (_, _, _) =>
                    {
                        runnerCalled = true;
                        return (0, true, "unexpected");
                    }
                };
            });
 
        Assert.False(runnerCalled);
 
        var appHostVersionCheck = GetCheckByName(doc, AspireVersionCheck.AppHostVersionCheckName);
        Assert.Equal(EnvironmentCheckCategories.AppHost, appHostVersionCheck.GetProperty("category").GetString());
        Assert.Equal("pass", appHostVersionCheck.GetProperty("status").GetString());
        Assert.Contains("13.1.0", appHostVersionCheck.GetProperty("message").GetString()!);
        Assert.Contains("apphost.ts", appHostVersionCheck.GetProperty("message").GetString()!);
        var appHostVersionMetadata = appHostVersionCheck.GetProperty("metadata");
        Assert.Equal("13.1.0", appHostVersionMetadata.GetProperty("version").GetString());
        Assert.Equal("apphost.ts", appHostVersionMetadata.GetProperty("appHostPath").GetString());
    }
 
    [Fact]
    public async Task DoctorCommand_Json_DoesNotDiscoverNestedAppHostWithoutConfig()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var appHostFile = CreateDeepAppHostFile(workspace, depth: LanguageInfo.DetectionRecurseLimit + 1);
        await File.WriteAllTextAsync(appHostFile.FullName, "<Project />");
 
        var versionLookupCalled = false;
        using var doc = await RunDoctorJsonAsync(workspace,
            configureOptions: options =>
            {
                options.CliUpdateNotifierFactory = _ => new TestCliUpdateNotifier();
                options.AppHostProjectFactory = _ => new TestAppHostProjectFactory
                {
                    GetAspireHostingVersionAsyncCallback = (_, _) =>
                    {
                        versionLookupCalled = true;
                        return Task.FromResult<string?>("unexpected");
                    }
                };
            });
 
        Assert.False(versionLookupCalled);
        Assert.DoesNotContain(doc.RootElement.GetProperty("checks").EnumerateArray(),
            check => check.GetProperty("name").GetString() == AspireVersionCheck.AppHostVersionCheckName);
    }
 
    [Fact]
    public async Task DoctorCommand_Json_DoesNotShowAppHostVersionForNonAppHostProject()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var projectFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "Normal.csproj"));
        await File.WriteAllTextAsync(projectFile.FullName, "<Project />");
 
        var versionLookupCalled = false;
        using var doc = await RunDoctorJsonAsync(workspace,
            configureOptions: options =>
            {
                options.CliUpdateNotifierFactory = _ => new TestCliUpdateNotifier();
                options.AppHostProjectFactory = _ => new TestAppHostProjectFactory
                {
                    ValidateAppHostCallback = _ => new AppHostValidationResult(IsValid: false),
                    GetAspireHostingVersionAsyncCallback = (_, _) =>
                    {
                        versionLookupCalled = true;
                        return Task.FromResult<string?>("unexpected");
                    }
                };
            });
 
        Assert.False(versionLookupCalled);
        Assert.DoesNotContain(doc.RootElement.GetProperty("checks").EnumerateArray(),
            check => check.GetProperty("name").GetString() == AspireVersionCheck.AppHostVersionCheckName);
    }
 
    [Fact]
    public async Task DoctorCommand_Json_DoesNotDiscoverNestedAppHostWhenAnotherProjectExists()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var projectFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "Normal.csproj"));
        await File.WriteAllTextAsync(projectFile.FullName, "<Project />");
        var appHostDirectory = workspace.WorkspaceRoot.CreateSubdirectory("app");
        var appHostFile = new FileInfo(Path.Combine(appHostDirectory.FullName, "AppHost.csproj"));
        await File.WriteAllTextAsync(appHostFile.FullName, "<Project />");
 
        using var doc = await RunDoctorJsonAsync(workspace,
            configureOptions: options =>
            {
                options.CliUpdateNotifierFactory = _ => new TestCliUpdateNotifier();
                options.AppHostProjectFactory = _ => new TestAppHostProjectFactory
                {
                    ValidateAppHostCallback = file => new AppHostValidationResult(
                        IsValid: file.Name.Equals("AppHost.csproj", StringComparison.OrdinalIgnoreCase)),
                    GetAspireHostingVersionAsyncCallback = (_, _) => Task.FromResult<string?>("13.2.0")
                };
            });
 
        Assert.DoesNotContain(doc.RootElement.GetProperty("checks").EnumerateArray(),
            check => check.GetProperty("name").GetString() == AspireVersionCheck.AppHostVersionCheckName);
    }
 
    [Fact]
    public async Task DoctorCommand_Json_DoesNotChooseBetweenMultipleDirectAppHostsWithoutConfig()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        await File.WriteAllTextAsync(Path.Combine(workspace.WorkspaceRoot.FullName, "AppHost.csproj"), "<Project />");
        await File.WriteAllTextAsync(Path.Combine(workspace.WorkspaceRoot.FullName, "AppHost.fsproj"), "<Project />");
 
        var versionLookupCalled = false;
        using var doc = await RunDoctorJsonAsync(workspace,
            configureOptions: options =>
            {
                options.CliUpdateNotifierFactory = _ => new TestCliUpdateNotifier();
                options.AppHostProjectFactory = _ => new TestAppHostProjectFactory
                {
                    GetAspireHostingVersionAsyncCallback = (_, _) =>
                    {
                        versionLookupCalled = true;
                        return Task.FromResult<string?>("unexpected");
                    }
                };
            });
 
        Assert.False(versionLookupCalled);
        Assert.DoesNotContain(doc.RootElement.GetProperty("checks").EnumerateArray(),
            check => check.GetProperty("name").GetString() == AspireVersionCheck.AppHostVersionCheckName);
    }
 
    [Fact]
    public async Task DoctorCommand_Json_PreservesCliVersionWhenAppHostVersionResolutionFails()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var appHostFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "apphost.ts"));
        await File.WriteAllTextAsync(appHostFile.FullName, "export {};");
 
        using var doc = await RunDoctorJsonAsync(workspace,
            configureOptions: options =>
            {
                options.CliUpdateNotifierFactory = _ => new TestCliUpdateNotifier();
                options.AppHostProjectFactory = _ => new TestAppHostProjectFactory
                {
                    CanHandleCallback = file => file.Extension.Equals(".ts", StringComparison.OrdinalIgnoreCase),
                    DetectionPatterns = ["apphost.ts"],
                    GetAspireHostingVersionAsyncCallback = (_, _) =>
                        throw new InvalidOperationException("invalid aspire.config.json")
                };
            });
 
        var cliVersionCheck = GetCheckByName(doc, AspireVersionCheck.CliVersionCheckName);
        var appHostVersionCheck = GetCheckByName(doc, AspireVersionCheck.AppHostVersionCheckName);
 
        Assert.Equal("pass", cliVersionCheck.GetProperty("status").GetString());
        Assert.Equal("warning", appHostVersionCheck.GetProperty("status").GetString());
        Assert.Equal("invalid aspire.config.json", appHostVersionCheck.GetProperty("details").GetString());
        Assert.Equal(
            "apphost.ts",
            appHostVersionCheck.GetProperty("metadata").GetProperty("appHostPath").GetString());
    }
 
    [Fact]
    public async Task DoctorCommand_Json_PreservesCliVersionWhenAppHostDiscoveryFails()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
 
        using var doc = await RunDoctorJsonAsync(workspace,
            configureOptions: options =>
            {
                options.CliUpdateNotifierFactory = _ => new TestCliUpdateNotifier();
                options.ProjectLocatorFactory = _ => new TestProjectLocator
                {
                    GetAppHostFromSettingsAsyncCallback = _ => throw new IOException("settings lookup failed")
                };
            });
 
        var cliVersionCheck = GetCheckByName(doc, AspireVersionCheck.CliVersionCheckName);
        var appHostVersionCheck = GetCheckByName(doc, AspireVersionCheck.AppHostVersionCheckName);
 
        Assert.Equal("pass", cliVersionCheck.GetProperty("status").GetString());
        Assert.Equal("warning", appHostVersionCheck.GetProperty("status").GetString());
        Assert.Equal("settings lookup failed", appHostVersionCheck.GetProperty("details").GetString());
    }
 
    [Fact]
    public async Task DoctorCommand_Json_UsesConfiguredAppHostBeyondLanguageDetectionLimit()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var appHostFile = CreateDeepAppHostFile(workspace, depth: LanguageInfo.DetectionRecurseLimit + 1);
        await File.WriteAllTextAsync(appHostFile.FullName, "<Project />");
        await File.WriteAllTextAsync(
            Path.Combine(workspace.WorkspaceRoot.FullName, "aspire.config.json"),
            $$"""
            {
              "appHost": {
                "path": "{{Path.GetRelativePath(workspace.WorkspaceRoot.FullName, appHostFile.FullName).Replace('\\', '/')}}"
              }
            }
            """);
 
        using var doc = await RunDoctorJsonAsync(workspace,
            configureOptions: options =>
            {
                options.CliUpdateNotifierFactory = _ => new TestCliUpdateNotifier();
                options.AppHostProjectFactory = _ => new TestAppHostProjectFactory
                {
                    GetAspireHostingVersionAsyncCallback = (_, _) => Task.FromResult<string?>("13.2.0")
                };
            });
 
        var appHostVersionCheck = GetCheckByName(doc, AspireVersionCheck.AppHostVersionCheckName);
        Assert.Equal(EnvironmentCheckCategories.AppHost, appHostVersionCheck.GetProperty("category").GetString());
        Assert.Equal("pass", appHostVersionCheck.GetProperty("status").GetString());
        Assert.Contains("13.2.0", appHostVersionCheck.GetProperty("message").GetString()!);
        Assert.Contains("AppHost.csproj", appHostVersionCheck.GetProperty("message").GetString()!);
        var appHostVersionMetadata = appHostVersionCheck.GetProperty("metadata");
        Assert.Equal("13.2.0", appHostVersionMetadata.GetProperty("version").GetString());
        Assert.Equal(
            Path.Combine("level0", "level1", "level2", "level3", "level4", "level5", "AppHost.csproj"),
            appHostVersionMetadata.GetProperty("appHostPath").GetString());
    }
 
    [Fact]
    public async Task DoctorCommand_Json_CliVersion_IncludesIdentityChannelFromReader()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        // Override the channel reader registered by CliTestHelper with a fake
        // returning a deterministic value, so the assertion is not coupled to
        // whichever channel the test host's Aspire.Cli assembly happens to bake in.
        using var doc = await RunDoctorJsonAsync(workspace,
            configureOptions: options =>
            {
                options.CliUpdateNotifierFactory = _ => new TestCliUpdateNotifier
                {
                    GetVersionStatusAsyncCallback = (_, _) => Task.FromResult(new CliVersionStatus("13.0.0", LatestVersion: null, UpdateCommand: null))
                };
            },
            configureServices: services =>
            {
                services.RemoveAll<IIdentityChannelReader>();
                services.AddSingleton<IIdentityChannelReader>(_ => new FakeIdentityChannelReader("staging"));
            });
 
        var cliVersionCheck = GetCheckByName(doc, AspireVersionCheck.CliVersionCheckName);
        var metadata = cliVersionCheck.GetProperty("metadata");
        Assert.Equal("staging", metadata.GetProperty("identityChannel").GetString());
        Assert.Contains("channel: staging", cliVersionCheck.GetProperty("message").GetString()!);
    }
 
    [Fact]
    public async Task DoctorCommand_Json_CliVersion_OmitsIdentityChannelWhenReaderThrows()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        using var doc = await RunDoctorJsonAsync(workspace,
            configureOptions: options =>
            {
                options.CliUpdateNotifierFactory = _ => new TestCliUpdateNotifier
                {
                    GetVersionStatusAsyncCallback = (_, _) => Task.FromResult(new CliVersionStatus("13.0.0", LatestVersion: null, UpdateCommand: null))
                };
            },
            configureServices: services =>
            {
                services.RemoveAll<IIdentityChannelReader>();
                // Throws to simulate a misconfigured dev build with no AspireCliChannel metadata.
                services.AddSingleton<IIdentityChannelReader>(_ => new FakeIdentityChannelReader(failOnRead: true));
            });
 
        // The channel lookup failing is informational; the rest of doctor should still complete.
        var cliVersionCheck = GetCheckByName(doc, AspireVersionCheck.CliVersionCheckName);
        var metadata = cliVersionCheck.GetProperty("metadata");
        Assert.False(metadata.TryGetProperty("identityChannel", out _));
        Assert.DoesNotContain("channel:", cliVersionCheck.GetProperty("message").GetString()!);
    }
 
    [Fact]
    public async Task DoctorCommand_Json_AppHostVersion_IncludesPinnedChannelFromAspireConfig()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var appHostFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "AppHost.csproj"));
        await File.WriteAllTextAsync(appHostFile.FullName, "<Project />");
        await File.WriteAllTextAsync(
            Path.Combine(workspace.WorkspaceRoot.FullName, "aspire.config.json"),
            """
            {
              "channel": "daily"
            }
            """);
 
        using var doc = await RunDoctorJsonAsync(workspace,
            configureOptions: options =>
            {
                options.CliUpdateNotifierFactory = _ => new TestCliUpdateNotifier();
                options.AppHostProjectFactory = _ => new TestAppHostProjectFactory
                {
                    GetAspireHostingVersionAsyncCallback = (_, _) => Task.FromResult<string?>("13.0.0")
                };
            });
 
        var appHostVersionCheck = GetCheckByName(doc, AspireVersionCheck.AppHostVersionCheckName);
        var metadata = appHostVersionCheck.GetProperty("metadata");
        Assert.Equal("daily", metadata.GetProperty("pinnedChannel").GetString());
        Assert.Contains("channel: daily", appHostVersionCheck.GetProperty("message").GetString()!);
    }
 
    [Fact]
    public async Task DoctorCommand_Json_AppHostVersion_IncludesPinnedChannelFromAspireConfigWhenAppHostIsNested()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var nestedAppHostDir = workspace.WorkspaceRoot.CreateSubdirectory("src").CreateSubdirectory("NestedAppHost");
        var appHostFile = new FileInfo(Path.Combine(nestedAppHostDir.FullName, "AppHost.csproj"));
        await File.WriteAllTextAsync(appHostFile.FullName, "<Project />");
        await File.WriteAllTextAsync(
            Path.Combine(workspace.WorkspaceRoot.FullName, "aspire.config.json"),
            """
            {
              "appHost": {
                "path": "src/NestedAppHost/AppHost.csproj"
              },
              "channel": "daily"
            }
            """);
 
        using var doc = await RunDoctorJsonAsync(workspace,
            configureOptions: options =>
            {
                options.CliUpdateNotifierFactory = _ => new TestCliUpdateNotifier();
                options.AppHostProjectFactory = _ => new TestAppHostProjectFactory
                {
                    GetAspireHostingVersionAsyncCallback = (_, _) => Task.FromResult<string?>("13.0.0")
                };
            });
 
        var appHostVersionCheck = GetCheckByName(doc, AspireVersionCheck.AppHostVersionCheckName);
        var metadata = appHostVersionCheck.GetProperty("metadata");
        Assert.Equal("daily", metadata.GetProperty("pinnedChannel").GetString());
        Assert.Contains("channel: daily", appHostVersionCheck.GetProperty("message").GetString()!);
        Assert.Equal(Path.Combine("src", "NestedAppHost", "AppHost.csproj"), metadata.GetProperty("appHostPath").GetString());
    }
 
    [Fact]
    public async Task DoctorCommand_Json_AppHostVersion_OmitsPinnedChannelWhenAspireConfigAbsent()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var appHostFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "AppHost.csproj"));
        await File.WriteAllTextAsync(appHostFile.FullName, "<Project />");
        // Intentionally no aspire.config.json — verifies the lookup degrades silently.
 
        using var doc = await RunDoctorJsonAsync(workspace,
            configureOptions: options =>
            {
                options.CliUpdateNotifierFactory = _ => new TestCliUpdateNotifier();
                options.AppHostProjectFactory = _ => new TestAppHostProjectFactory
                {
                    GetAspireHostingVersionAsyncCallback = (_, _) => Task.FromResult<string?>("13.0.0")
                };
            });
 
        var appHostVersionCheck = GetCheckByName(doc, AspireVersionCheck.AppHostVersionCheckName);
        var metadata = appHostVersionCheck.GetProperty("metadata");
        Assert.False(metadata.TryGetProperty("pinnedChannel", out _));
        Assert.DoesNotContain("channel:", appHostVersionCheck.GetProperty("message").GetString()!);
    }
 
    [Fact]
    public async Task DoctorCommand_Json_CliVersion_IncludesLatestVersionChannel_WhenUpdateAvailable()
    {
        // When an update is available, doctor should surface BOTH channel
        // labels — identityChannel for the running CLI, latestVersionChannel
        // for the recommendation lane (stable vs prerelease) — so the user
        // can see exactly where the recommendation is being pulled from.
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        using var doc = await RunDoctorJsonAsync(workspace,
            configureOptions: options =>
            {
                options.CliUpdateNotifierFactory = _ => new TestCliUpdateNotifier
                {
                    GetVersionStatusAsyncCallback = (_, _) => Task.FromResult(new CliVersionStatus(
                        CurrentVersion: "13.4.0-dev",
                        LatestVersion: "13.4.0-preview.1.26264.8",
                        UpdateCommand: "aspire update",
                        UpdateCheckError: null,
                        LatestVersionChannel: "prerelease"))
                };
            },
            configureServices: services =>
            {
                services.RemoveAll<IIdentityChannelReader>();
                services.AddSingleton<IIdentityChannelReader>(_ => new FakeIdentityChannelReader("local"));
            });
 
        var cliVersionCheck = GetCheckByName(doc, AspireVersionCheck.CliVersionCheckName);
 
        // Both channels surface in metadata.
        var metadata = cliVersionCheck.GetProperty("metadata");
        Assert.Equal("local", metadata.GetProperty("identityChannel").GetString());
        Assert.Equal("prerelease", metadata.GetProperty("latestVersionChannel").GetString());
 
        // The human-readable message attaches the channel to each version
        // it qualifies. Both must appear at well-defined positions so the
        // user can't mis-read which channel is which.
        var message = cliVersionCheck.GetProperty("message").GetString()!;
        var currentIdx = message.IndexOf("13.4.0-dev (channel: local)", StringComparison.Ordinal);
        var latestIdx = message.IndexOf("13.4.0-preview.1.26264.8 (channel: prerelease)", StringComparison.Ordinal);
        Assert.True(currentIdx >= 0, $"Expected current version with channel suffix in message; got: {message}");
        Assert.True(latestIdx >= 0, $"Expected latest version with channel suffix in message; got: {message}");
        Assert.True(currentIdx < latestIdx, "Current version must appear before latest version in message.");
    }
 
    [Fact]
    public async Task DoctorCommand_Json_IncludesDiscoveredInstallations()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        using var doc = await RunDoctorJsonAsync(workspace,
            configureOptions: options =>
            {
                options.CliUpdateNotifierFactory = _ => new TestCliUpdateNotifier();
            },
            configureServices: services => UseFakeInstallationDiscovery(
                services,
                self: new InstallationInfo
                {
                    Path = "/home/test/.aspire/bin/aspire",
                    CanonicalPath = "/home/test/.aspire/bin/aspire",
                    Version = "13.0.0",
                    Channel = "stable",
                    Route = "script",
                    PathStatus = InstallationPathStatus.Active,
                    Status = InstallationInfoStatus.Ok,
                },
                others:
                [
                    new InstallationInfo
                    {
                        Path = "/home/test/.aspire/dogfood/pr-1234/bin/aspire",
                        CanonicalPath = "/home/test/.aspire/dogfood/pr-1234/bin/aspire",
                        Version = "13.1.0-preview",
                        Channel = "pr-1234",
                        Route = "pr",
                        PathStatus = InstallationPathStatus.Shadowed,
                        Status = InstallationInfoStatus.Ok,
                    },
                ]));
 
        var installations = doc.RootElement.GetProperty("installations").EnumerateArray().ToArray();
        Assert.Equal(2, installations.Length);
 
        var self = installations[0];
        Assert.Equal("/home/test/.aspire/bin/aspire", self.GetProperty("path").GetString());
        Assert.Equal("stable", self.GetProperty("channel").GetString());
        Assert.Equal("script", self.GetProperty("route").GetString());
        Assert.Equal(InstallationPathStatus.Active, self.GetProperty("pathStatus").GetString());
 
        var peer = installations[1];
        Assert.Equal("/home/test/.aspire/dogfood/pr-1234/bin/aspire", peer.GetProperty("path").GetString());
        Assert.Equal("pr-1234", peer.GetProperty("channel").GetString());
        Assert.Equal("pr", peer.GetProperty("route").GetString());
        Assert.Equal(InstallationPathStatus.Shadowed, peer.GetProperty("pathStatus").GetString());
    }
 
    [Fact]
    public async Task DoctorCommand_HumanReadable_Self_RendersOnlyRunningInstallationAndSkipsChecks()
    {
        // `doctor --self` is the peer-probe surface. Without --format the
        // human-readable table is the default; with --format json the
        // probe gets a machine-readable row. Either way, no environment
        // checks run and only the running CLI's row is rendered.
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var output = new StringWriter();
        var console = AnsiConsole.Create(new AnsiConsoleSettings
        {
            Ansi = AnsiSupport.No,
            ColorSystem = ColorSystemSupport.NoColors,
            Interactive = InteractionSupport.No,
            Out = new AnsiConsoleOutput(output),
            Enrichment = new ProfileEnrichment { UseDefaultEnrichers = false },
        });
        console.Profile.Width = int.MaxValue;
 
        var services = CreateDoctorVersionServiceCollection(workspace, outputHelper, options =>
        {
            options.CliUpdateNotifierFactory = _ => new TestCliUpdateNotifier();
        });
        services.RemoveAll<IAnsiConsole>();
        services.AddSingleton<IAnsiConsole>(console);
        UseFakeInstallationDiscovery(
            services,
            self: new InstallationInfo
            {
                Path = "/home/test/.aspire/bin/aspire",
                CanonicalPath = "/home/test/.aspire/bin/aspire",
                Version = "13.4.0-pr.17115.gcd700928",
                Channel = "pr-17115",
                Route = "brew",
                PathStatus = InstallationPathStatus.Active,
                Status = InstallationInfoStatus.Ok,
            },
            others:
            [
                new InstallationInfo
                {
                    Path = "/peer/aspire",
                    CanonicalPath = "/peer/aspire",
                    Version = "13.1.0-preview",
                    Channel = "pr-1234",
                    Route = "pr",
                    PathStatus = InstallationPathStatus.Shadowed,
                    Status = InstallationInfoStatus.Ok,
                },
            ]);
 
        using var provider = services.BuildServiceProvider();
        var command = provider.GetRequiredService<Aspire.Cli.Commands.RootCommand>();
        var result = command.Parse("doctor --self");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
 
        Assert.Equal(CliExitCodes.Success, exitCode);
        var rendered = output.ToString();
        Assert.Contains("Aspire CLI Installations", rendered, StringComparison.Ordinal);
        Assert.Contains("13.4.0-pr.17115.gcd700928", rendered, StringComparison.Ordinal);
        Assert.Contains("pr-17115", rendered, StringComparison.Ordinal);
        // No environment checks ran, so no Summary line.
        Assert.DoesNotContain("Summary:", rendered, StringComparison.Ordinal);
        // No peer rows — --self bounds the output to the running CLI only.
        Assert.DoesNotContain("/peer/aspire", rendered, StringComparison.Ordinal);
        Assert.DoesNotContain("pr-1234", rendered, StringComparison.Ordinal);
    }
 
    [Fact]
    public async Task DoctorCommand_HumanReadable_AppendsInstallationsAfterSummary()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var output = new StringWriter();
        var console = AnsiConsole.Create(new AnsiConsoleSettings
        {
            Ansi = AnsiSupport.No,
            ColorSystem = ColorSystemSupport.NoColors,
            Interactive = InteractionSupport.No,
            Out = new AnsiConsoleOutput(output),
            Enrichment = new ProfileEnrichment { UseDefaultEnrichers = false },
        });
        console.Profile.Width = int.MaxValue;
 
        var services = CreateDoctorVersionServiceCollection(workspace, outputHelper, options =>
        {
            options.CliUpdateNotifierFactory = _ => new TestCliUpdateNotifier();
        });
        services.RemoveAll<IAnsiConsole>();
        services.AddSingleton<IAnsiConsole>(console);
        UseFakeInstallationDiscovery(
            services,
            self: new InstallationInfo
            {
                Path = "/home/test/.aspire/bin/aspire",
                CanonicalPath = "/home/test/.aspire/bin/aspire",
                Version = "13.0.0",
                Channel = "stable",
                Route = "script",
                PathStatus = InstallationPathStatus.Active,
                Status = InstallationInfoStatus.Ok,
            },
            others:
            [
                new InstallationInfo
                {
                    Path = "/peer/aspire",
                    CanonicalPath = "/peer/aspire",
                    Version = "13.1.0-preview",
                    Channel = "pr-1234",
                    Route = "pr",
                    PathStatus = InstallationPathStatus.Shadowed,
                    Status = InstallationInfoStatus.Ok,
                },
            ]);
 
        using var provider = services.BuildServiceProvider();
        var command = provider.GetRequiredService<Aspire.Cli.Commands.RootCommand>();
        var result = command.Parse("doctor");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
 
        Assert.Equal(CliExitCodes.Success, exitCode);
        var rendered = output.ToString();
        var summaryIndex = rendered.IndexOf("Summary:", StringComparison.Ordinal);
        var installationsIndex = rendered.IndexOf("Aspire CLI Installations", StringComparison.Ordinal);
        Assert.True(summaryIndex >= 0, $"Expected doctor summary in output:{Environment.NewLine}{rendered}");
        Assert.True(installationsIndex > summaryIndex, $"Expected installations after summary in output:{Environment.NewLine}{rendered}");
        Assert.Contains("/peer/aspire", rendered, StringComparison.Ordinal);
        Assert.Contains("pr-1234", rendered, StringComparison.Ordinal);
    }
 
    [Fact]
    public async Task DoctorCommand_HumanReadable_EscapesUnknownPathStatus()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var output = new StringWriter();
        var console = AnsiConsole.Create(new AnsiConsoleSettings
        {
            Ansi = AnsiSupport.No,
            ColorSystem = ColorSystemSupport.NoColors,
            Interactive = InteractionSupport.No,
            Out = new AnsiConsoleOutput(output),
            Enrichment = new ProfileEnrichment { UseDefaultEnrichers = false },
        });
        console.Profile.Width = int.MaxValue;
 
        // pathStatus is parsed from untrusted peer-probe stdout. A peer
        // that emits an unrecognized string must not be able to inject
        // Spectre markup into the parent's rendered table; the default
        // branch of PathStatusDisplay must EscapeMarkup() the value.
        var services = CreateDoctorVersionServiceCollection(workspace, outputHelper, options =>
        {
            options.CliUpdateNotifierFactory = _ => new TestCliUpdateNotifier();
        });
        services.RemoveAll<IAnsiConsole>();
        services.AddSingleton<IAnsiConsole>(console);
        UseFakeInstallationDiscovery(
            services,
            self: new InstallationInfo
            {
                Path = "/home/test/.aspire/bin/aspire",
                CanonicalPath = "/home/test/.aspire/bin/aspire",
                Version = "13.0.0",
                Channel = "stable",
                Route = "script",
                PathStatus = "custom[red]status[/]",
                Status = InstallationInfoStatus.Ok,
            });
 
        using var provider = services.BuildServiceProvider();
        var command = provider.GetRequiredService<Aspire.Cli.Commands.RootCommand>();
        var result = command.Parse("doctor");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
 
        Assert.Equal(CliExitCodes.Success, exitCode);
        var rendered = output.ToString();
        Assert.Contains("custom[red]status[/]", rendered, StringComparison.Ordinal);
    }
 
    [Fact]
    public void DoctorCommand_InfoCommandIsNotRegistered()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper);
        using var provider = services.BuildServiceProvider();
 
        var command = provider.GetRequiredService<Aspire.Cli.Commands.RootCommand>();
 
        Assert.DoesNotContain(command.Subcommands, subcommand => subcommand.Name == "info");
    }
 
    [Fact]
    public async Task DoctorCommand_Json_Self_ReturnsOnlyRunningInstallation()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        using var doc = await RunDoctorJsonAsync(workspace,
            commandLine: "doctor --self --format json",
            configureOptions: options =>
            {
                options.CliUpdateNotifierFactory = _ => new TestCliUpdateNotifier();
            },
            configureServices: services => UseFakeInstallationDiscovery(
                services,
                self: new InstallationInfo
                {
                    Path = "/usr/local/bin/aspire",
                    CanonicalPath = "/usr/local/bin/aspire",
                    Version = "13.0.0",
                    Channel = "stable",
                    Route = "script",
                    PathStatus = InstallationPathStatus.Active,
                    Status = InstallationInfoStatus.Ok,
                }));
 
        Assert.Empty(doc.RootElement.GetProperty("checks").EnumerateArray());
        var installations = doc.RootElement.GetProperty("installations").EnumerateArray().ToArray();
        var row = Assert.Single(installations);
        Assert.Equal("/usr/local/bin/aspire", row.GetProperty("path").GetString());
        Assert.Equal("13.0.0", row.GetProperty("version").GetString());
        Assert.Equal("stable", row.GetProperty("channel").GetString());
        Assert.Equal("script", row.GetProperty("route").GetString());
        Assert.Equal(InstallationPathStatus.Active, row.GetProperty("pathStatus").GetString());
        Assert.Equal(InstallationInfoStatus.Ok, row.GetProperty("status").GetString());
    }
 
    [Fact]
    public async Task DoctorCommand_Json_WhenInstallDiscoveryFails_StillReturnsDoctorResults()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        using var doc = await RunDoctorJsonAsync(workspace,
            configureOptions: options =>
            {
                options.CliUpdateNotifierFactory = _ => new TestCliUpdateNotifier();
            },
            configureServices: services => UseFakeInstallationDiscovery(
                services,
                self: new InstallationInfo
                {
                    Path = "/test/aspire",
                    Status = InstallationInfoStatus.Ok,
                },
                discoverAllException: new IOException("PATH lookup failed")));
 
        Assert.NotEmpty(doc.RootElement.GetProperty("checks").EnumerateArray());
        var row = Assert.Single(doc.RootElement.GetProperty("installations").EnumerateArray());
        Assert.Equal(InstallationInfoStatus.Failed, row.GetProperty("status").GetString());
        Assert.Equal("Install discovery failed. See the Aspire CLI logs for details.", row.GetProperty("statusReason").GetString());
    }
 
    [Theory]
    [InlineData(InstallationInfoStatus.Failed, "(probe failed)")]
    [InlineData(InstallationInfoStatus.NotProbed, "(not probed)")]
    [InlineData(InstallationInfoStatus.Ok, "(unknown)")]
    public async Task DoctorCommand_HumanReadable_RendersMissingInstallationValuesBasedOnStatus(string status, string expectedPlaceholder)
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var output = new StringWriter();
        var console = AnsiConsole.Create(new AnsiConsoleSettings
        {
            Ansi = AnsiSupport.No,
            ColorSystem = ColorSystemSupport.NoColors,
            Interactive = InteractionSupport.No,
            Out = new AnsiConsoleOutput(output),
            Enrichment = new ProfileEnrichment { UseDefaultEnrichers = false },
        });
        console.Profile.Width = int.MaxValue;
 
        var services = CreateDoctorVersionServiceCollection(workspace, outputHelper, options =>
        {
            options.CliUpdateNotifierFactory = _ => new TestCliUpdateNotifier();
        });
        services.RemoveAll<IAnsiConsole>();
        services.AddSingleton<IAnsiConsole>(console);
        UseFakeInstallationDiscovery(
            services,
            self: new InstallationInfo
            {
                Path = "/home/test/.aspire/bin/aspire",
                CanonicalPath = "/home/test/.aspire/bin/aspire",
                Version = "13.0.0",
                Channel = "stable",
                Route = "script",
                PathStatus = InstallationPathStatus.Active,
                Status = InstallationInfoStatus.Ok,
            },
            others:
            [
                new InstallationInfo
                {
                    Path = $"/peer/{status}/aspire",
                    CanonicalPath = $"/peer/{status}/aspire",
                    Status = status,
                },
            ]);
        using var provider = services.BuildServiceProvider();
 
        var command = provider.GetRequiredService<Aspire.Cli.Commands.RootCommand>();
        var result = command.Parse("doctor");
 
        var exitCode = await result.InvokeAsync().DefaultTimeout();
        Assert.Equal(CliExitCodes.Success, exitCode);
 
        var rendered = output.ToString();
        Assert.Contains($"/peer/{status}/aspire", rendered, StringComparison.Ordinal);
        Assert.Contains(expectedPlaceholder, rendered, StringComparison.Ordinal);
 
        foreach (var otherPlaceholder in new[] { "(probe failed)", "(not probed)", "(unknown)" }.Where(p => p != expectedPlaceholder))
        {
            Assert.DoesNotContain(otherPlaceholder, rendered, StringComparison.Ordinal);
        }
    }
 
    // Centralizes the scaffolding shared by `doctor --format json` tests:
    // build services via CreateDoctorVersionServiceCollection wired to a
    // TextWriter capturing the real stdout sink, optionally tweak the
    // registered services (e.g. swap IIdentityChannelReader), run the
    // requested doctor command, assert success, and hand the caller a
    // parsed JsonDocument.
    //
    // Capturing from the actual stdout sink (rather than a TestInteractionService
    // collection) means any non-JSON text emitted on stdout — status messages,
    // update notifications, error banners — fails the test at JsonDocument.Parse.
    // This matches the pattern used by every other `--format json` test in the
    // CLI (see e.g. LsCommandTests.LsCommand_JsonFormat_ReturnsCandidateAppHosts)
    // and is what guarantees `aspire doctor --format json` stdout stays
    // machine-readable.
    //
    // The caller owns disposal of the returned JsonDocument so it can read
    // elements off it across multiple assertions in the test body.
    private async Task<JsonDocument> RunDoctorJsonAsync(
        TemporaryWorkspace workspace,
        Action<CliServiceCollectionTestOptions> configureOptions,
        Action<IServiceCollection>? configureServices = null,
        string commandLine = "doctor --format json")
    {
        var outputWriter = new TestOutputTextWriter(outputHelper);
        var services = CreateDoctorVersionServiceCollection(workspace, outputHelper, options =>
        {
            options.OutputTextWriter = outputWriter;
            configureOptions(options);
        });
        configureServices?.Invoke(services);
        using var provider = services.BuildServiceProvider();
 
        var command = provider.GetRequiredService<Aspire.Cli.Commands.RootCommand>();
        var result = command.Parse(commandLine);
        var exitCode = await result.InvokeAsync().DefaultTimeout();
        Assert.Equal(CliExitCodes.Success, exitCode);
 
        var stdoutText = string.Concat(outputWriter.Logs);
        return JsonDocument.Parse(stdoutText);
    }
 
    private static JsonElement GetCheckByName(JsonDocument document, string checkName)
        => document.RootElement.GetProperty("checks").EnumerateArray()
            .Single(check => check.GetProperty("name").GetString() == checkName);
 
    private static IServiceCollection CreateDoctorVersionServiceCollection(
        TemporaryWorkspace workspace,
        ITestOutputHelper outputHelper,
        Action<CliServiceCollectionTestOptions>? configure)
    {
        var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, configure);
        services.RemoveAll<IEnvironmentCheck>();
        services.AddSingleton<IEnvironmentCheck, AspireVersionCheck>();
        services.AddSingleton<IEnvironmentCheck, OperatingSystemCheck>();
        UseFakeInstallationDiscovery(
            services,
            self: new InstallationInfo
            {
                Path = "/test/aspire",
                CanonicalPath = "/test/aspire",
                Version = "13.0.0",
                Channel = "stable",
                Route = "script",
                PathStatus = InstallationPathStatus.Active,
                Status = InstallationInfoStatus.Ok,
            });
        return services;
    }
 
    private static void UseFakeInstallationDiscovery(
        IServiceCollection services,
        InstallationInfo self,
        IReadOnlyList<InstallationInfo>? others = null,
        Exception? discoverAllException = null)
    {
        services.RemoveAll<IInstallationDiscovery>();
        services.AddSingleton<IInstallationDiscovery>(_ => new FakeInstallationDiscovery(self, others, discoverAllException));
    }
 
    private static FileInfo CreateDeepAppHostFile(TemporaryWorkspace workspace, int depth)
    {
        var directory = workspace.WorkspaceRoot;
        for (var i = 0; i < depth; i++)
        {
            directory = directory.CreateSubdirectory($"level{i}");
        }
 
        return new FileInfo(Path.Combine(directory.FullName, "AppHost.csproj"));
    }
 
    private static bool TryGetOsReleaseValue(Dictionary<string, string> osRelease, string key, out string value)
    {
        if (osRelease.TryGetValue(key, out var rawValue) && !string.IsNullOrWhiteSpace(rawValue))
        {
            value = rawValue;
            return true;
        }
 
        value = string.Empty;
        return false;
    }
 
    private static string GetExpectedLinuxDisplayName(string osReleaseName)
    {
        var name = osReleaseName.Trim();
        foreach (var suffix in new[] { " GNU/Linux", " Linux" })
        {
            if (name.EndsWith(suffix, StringComparison.OrdinalIgnoreCase))
            {
                name = name[..^suffix.Length];
                break;
            }
        }
 
        return name.Equals("Linux", StringComparison.OrdinalIgnoreCase) ? "Linux" : $"Linux {name}";
    }
}