File: Acquisition\InstallationDiscoveryDiscoverAllTests.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.Acquisition;
using Aspire.Cli.Tests.TestServices;
using Aspire.Cli.Tests.Utils;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
 
namespace Aspire.Cli.Tests.Acquisition;
 
/// <summary>
/// Behavior tests for <see cref="InstallationDiscovery.DiscoverAllAsync(System.Threading.CancellationToken)"/>
/// focused on install metadata requirements and dedup-by-canonical-path semantics.
/// PATH and well-known-prefix walks are exercised via an isolated
/// HOME-equivalent so a developer's real home directory doesn't leak
/// into the test.
/// </summary>
/// <remarks>
/// Tests in this class mutate process-wide environment variables (PATH,
/// PATHEXT, HOME/USERPROFILE) via <see cref="EnvVarOverride"/>. xUnit runs
/// test classes in parallel by default, so any other test in this assembly
/// that reads PATH (directly or via <c>Process.Start</c> / path lookup
/// helpers) could see the override transiently. The collection definition
/// disables parallelization across these tests and any other suite that
/// joins <c>EnvVarMutatingTests</c>.
/// </remarks>
[Collection(EnvVarMutatingTestCollection.Name)]
public class InstallationDiscoveryDiscoverAllTests(ITestOutputHelper outputHelper)
{
    [Fact]
    public async Task DiscoverAllAsync_PathHit_WithoutSidecar_IsListedAsNotProbed_AndNeverSpawned()
    {
        // A binary on $PATH with no .aspire-install.json next to it must not be
        // spawned. The user-installed binary on PATH is the most dangerous case:
        // if a user runs `aspire doctor`, we cannot execute arbitrary
        // same-named binaries we happened to find.
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var pathDir = Path.Combine(workspace.WorkspaceRoot.FullName, "no-sidecar-bin");
        Directory.CreateDirectory(pathDir);
        var noSidecarBinary = WriteFakeBinary(pathDir);
 
        var probe = new FakePeerInstallProbe();
        using var _ = new EnvVarOverride("PATH", pathDir + Path.PathSeparator + (Environment.GetEnvironmentVariable("PATH") ?? string.Empty));
 
        var discovery = NewDiscovery(probe, workspace);
        var results = await discovery.DiscoverAllAsync(TestContext.Current.CancellationToken);
 
        // Install metadata is required before probing: this PATH hit must not be probed.
        Assert.DoesNotContain(probe.ProbedPaths, p => string.Equals(p, noSidecarBinary, StringComparison.Ordinal));
 
        var noSidecarRow = Assert.Single(results, r =>
            string.Equals(r.CanonicalPath, noSidecarBinary, OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal));
        Assert.Equal(InstallationInfoStatus.NotProbed, noSidecarRow.Status);
        var expectedSidecarPath = Path.Combine(pathDir, InstallSidecarReader.SidecarFileName);
        Assert.Equal($"No install-route sidecar found at {expectedSidecarPath}; peer was not probed.", noSidecarRow.StatusReason);
    }
 
    [Fact]
    public async Task DiscoverAllAsync_PathHit_WithUnreadableSidecar_IsListedAsNotProbedWithInvalidSidecarReason()
    {
        if (OperatingSystem.IsWindows())
        {
            Assert.Skip("Unix file modes are required to create a deterministic unreadable sidecar.");
            return;
        }
 
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var pathDir = Path.Combine(workspace.WorkspaceRoot.FullName, "unreadable-bin");
        Directory.CreateDirectory(pathDir);
        var binary = WriteFakeBinary(pathDir);
        var sidecarPath = Path.Combine(pathDir, InstallSidecarReader.SidecarFileName);
        File.WriteAllText(sidecarPath, "{\"source\":\"script\"}");
        var originalMode = File.GetUnixFileMode(sidecarPath);
 
        try
        {
            File.SetUnixFileMode(sidecarPath, UnixFileMode.None);
 
            var probe = new FakePeerInstallProbe();
            using var _ = new EnvVarOverride("PATH", pathDir);
 
            var discovery = NewDiscovery(probe, workspace);
            var results = await discovery.DiscoverAllAsync(TestContext.Current.CancellationToken);
 
            Assert.DoesNotContain(probe.ProbedPaths, p => string.Equals(p, binary, StringComparison.Ordinal));
            var row = Assert.Single(results, r => string.Equals(r.CanonicalPath, binary, StringComparison.Ordinal));
            Assert.Equal(InstallationInfoStatus.NotProbed, row.Status);
            Assert.Equal($"Install-route sidecar at {sidecarPath} could not be read or parsed; peer was not probed.", row.StatusReason);
        }
        finally
        {
            File.SetUnixFileMode(sidecarPath, originalMode | UnixFileMode.UserRead | UnixFileMode.UserWrite);
        }
    }
 
    [Fact]
    public async Task DiscoverAllAsync_PathHit_WithMalformedSidecar_IsListedAsNotProbedWithInvalidSidecarReason()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var pathDir = Path.Combine(workspace.WorkspaceRoot.FullName, "malformed-bin");
        Directory.CreateDirectory(pathDir);
        var binary = WriteFakeBinary(pathDir);
        var sidecarPath = Path.Combine(pathDir, InstallSidecarReader.SidecarFileName);
        File.WriteAllText(sidecarPath, "{not valid json");
 
        var probe = new FakePeerInstallProbe();
        using var _ = new EnvVarOverride("PATH", pathDir);
 
        var discovery = NewDiscovery(probe, workspace);
        var results = await discovery.DiscoverAllAsync(TestContext.Current.CancellationToken);
 
        Assert.DoesNotContain(probe.ProbedPaths, p => string.Equals(p, binary, OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal));
        var row = Assert.Single(results, r =>
            string.Equals(r.CanonicalPath, binary, OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal));
        Assert.Equal(InstallationInfoStatus.NotProbed, row.Status);
        Assert.Equal($"Install-route sidecar at {sidecarPath} could not be read or parsed; peer was not probed.", row.StatusReason);
    }
 
    [Fact]
    public async Task DiscoverAllAsync_TrustedSidecar_IsSpawnedAndDecoratedWithDiscoveredPath()
    {
        // A binary with a script-route sidecar in its directory has enough
        // install metadata to be probed. The peer probe is called, and its returned
        // InstallationInfo is merged with the discovered path so the row
        // displayed to the user matches what `which` would show.
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var binDir = Path.Combine(workspace.WorkspaceRoot.FullName, ".aspire", "bin");
        Directory.CreateDirectory(binDir);
        var binary = WriteFakeBinary(binDir);
        File.WriteAllText(Path.Combine(binDir, InstallSidecarReader.SidecarFileName), "{\"source\":\"script\"}");
 
        var probe = new FakePeerInstallProbe(new Dictionary<string, PeerProbeResult>
        {
            [binary] = new PeerProbeResult.Ok(new InstallationInfo
            {
                Path = "/peer-says/aspire",
                Version = "12.5.0",
                Channel = "stable",
                Route = "script",
                Status = InstallationInfoStatus.Ok,
            }),
        });
 
        var discovery = NewDiscovery(probe, workspace);
        var results = await discovery.DiscoverAllAsync(TestContext.Current.CancellationToken);
 
        Assert.Contains(probe.ProbedPaths, p => string.Equals(p, binary, OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal));
 
        var discoveredRow = results.Single(r =>
            string.Equals(r.CanonicalPath, binary, OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal));
        Assert.Equal("12.5.0", discoveredRow.Version);
        Assert.Equal("stable", discoveredRow.Channel);
        // Discovered path wins over what the peer reported, so the table
        // reflects where the binary lives on disk.
        Assert.Equal(binary, discoveredRow.Path);
    }
 
    [Fact]
    public async Task DiscoverAllAsync_PathStatusTracksActiveShadowedAndOffPathInstalls()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var firstPathDir = Path.Combine(workspace.WorkspaceRoot.FullName, "path-first");
        var secondPathDir = Path.Combine(workspace.WorkspaceRoot.FullName, "path-second");
        var offPathDir = Path.Combine(workspace.WorkspaceRoot.FullName, ".aspire", "bin");
        Directory.CreateDirectory(firstPathDir);
        Directory.CreateDirectory(secondPathDir);
        Directory.CreateDirectory(offPathDir);
 
        var firstPathBinary = WriteTrustedFakeBinary(firstPathDir, "script");
        var secondPathBinary = WriteTrustedFakeBinary(secondPathDir, "pr");
        var offPathBinary = WriteTrustedFakeBinary(offPathDir, "script");
 
        var probe = new FakePeerInstallProbe(new Dictionary<string, PeerProbeResult>
        {
            [firstPathBinary] = new PeerProbeResult.Ok(new InstallationInfo
            {
                Path = firstPathBinary,
                Version = "13.0.1",
                Status = InstallationInfoStatus.Ok,
            }),
            [secondPathBinary] = new PeerProbeResult.Ok(new InstallationInfo
            {
                Path = secondPathBinary,
                Version = "13.0.2",
                Status = InstallationInfoStatus.Ok,
            }),
            [offPathBinary] = new PeerProbeResult.Ok(new InstallationInfo
            {
                Path = offPathBinary,
                Version = "13.0.3",
                Status = InstallationInfoStatus.Ok,
            }),
        });
 
        using var pathOverride = new EnvVarOverride("PATH", firstPathDir + Path.PathSeparator + secondPathDir);
        using var pathExtOverride = new EnvVarOverride("PATHEXT", ".EXE");
 
        var discovery = NewDiscovery(probe, workspace);
        var results = await discovery.DiscoverAllAsync(TestContext.Current.CancellationToken);
 
        var firstPathRow = results.Single(r =>
            string.Equals(r.CanonicalPath, firstPathBinary, OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal));
        var secondPathRow = results.Single(r =>
            string.Equals(r.CanonicalPath, secondPathBinary, OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal));
        var offPathRow = results.Single(r =>
            string.Equals(r.CanonicalPath, offPathBinary, OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal));
 
        Assert.Equal(InstallationPathStatus.Active, firstPathRow.PathStatus);
        Assert.Equal(InstallationPathStatus.Shadowed, secondPathRow.PathStatus);
        Assert.Equal(InstallationPathStatus.NotOnPath, offPathRow.PathStatus);
    }
 
    [Fact]
    public async Task DiscoverAllAsync_UnknownSidecarSource_WithSuccessfulProbe_ProbesAndSurfacesRawRoute()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var binDir = Path.Combine(workspace.WorkspaceRoot.FullName, ".aspire", "bin");
        Directory.CreateDirectory(binDir);
        var binary = WriteTrustedFakeBinary(binDir, "apt");
 
        var probe = new FakePeerInstallProbe(new Dictionary<string, PeerProbeResult>
        {
            [binary] = new PeerProbeResult.Ok(new InstallationInfo
            {
                Path = binary,
                Version = "13.1.0",
                Channel = "daily",
                Status = InstallationInfoStatus.Ok,
            }),
        });
 
        var discovery = NewDiscovery(probe, workspace);
        var results = await discovery.DiscoverAllAsync(TestContext.Current.CancellationToken);
 
        Assert.Contains(probe.ProbedPaths, p => string.Equals(p, binary, OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal));
        var row = results.Single(r =>
            string.Equals(r.CanonicalPath, binary, OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal));
        Assert.Equal(InstallationInfoStatus.Ok, row.Status);
        Assert.Equal("apt", row.Route);
        Assert.Equal("13.1.0", row.Version);
        Assert.Equal("daily", row.Channel);
    }
 
    [Fact]
    public async Task DiscoverAllAsync_UnknownSidecarSource_WithFailedProbe_ProbesAndSurfacesFailedWithRawRoute()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var binDir = Path.Combine(workspace.WorkspaceRoot.FullName, ".aspire", "bin");
        Directory.CreateDirectory(binDir);
        var binary = WriteTrustedFakeBinary(binDir, "apt");
 
        var probe = new FakePeerInstallProbe(new Dictionary<string, PeerProbeResult>
        {
            [binary] = new PeerProbeResult.Failed("simulated probe failure"),
        });
 
        var discovery = NewDiscovery(probe, workspace);
        var results = await discovery.DiscoverAllAsync(TestContext.Current.CancellationToken);
 
        Assert.Contains(probe.ProbedPaths, p => string.Equals(p, binary, OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal));
        var row = results.Single(r =>
            string.Equals(r.CanonicalPath, binary, OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal));
        Assert.Equal(InstallationInfoStatus.Failed, row.Status);
        Assert.Equal("apt", row.Route);
        Assert.Equal("simulated probe failure", row.StatusReason);
    }
 
    [Fact]
    public async Task DiscoverAllAsync_PeerProbeFails_RowSurvivesAsFailed_WithRouteIntact()
    {
        // A peer that fails (timeout / non-zero exit / invalid JSON) is per-row,
        // not whole-command. The route from the sidecar is still surfaced so the
        // user sees "this is a PR install but it wouldn't talk to me", not nothing.
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var prDir = Path.Combine(workspace.WorkspaceRoot.FullName, ".aspire", "dogfood", "pr-9999", "bin");
        Directory.CreateDirectory(prDir);
        var binary = WriteFakeBinary(prDir);
        File.WriteAllText(Path.Combine(prDir, InstallSidecarReader.SidecarFileName), "{\"source\":\"pr\"}");
 
        var probe = new FakePeerInstallProbe(new Dictionary<string, PeerProbeResult>
        {
            [binary] = new PeerProbeResult.Failed("simulated peer hang"),
        });
 
        var discovery = NewDiscovery(probe, workspace);
        var results = await discovery.DiscoverAllAsync(TestContext.Current.CancellationToken);
 
        var row = results.Single(r =>
            string.Equals(r.CanonicalPath, binary, OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal));
        Assert.Equal(InstallationInfoStatus.Failed, row.Status);
        Assert.Equal("pr", row.Route);
        Assert.Contains("simulated peer hang", row.StatusReason!);
    }
 
    [Fact]
    public async Task DiscoverAllAsync_UnknownPeerProbeResult_Throws()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var binDir = Path.Combine(workspace.WorkspaceRoot.FullName, ".aspire", "bin");
        Directory.CreateDirectory(binDir);
        var binary = WriteFakeBinary(binDir);
        File.WriteAllText(Path.Combine(binDir, InstallSidecarReader.SidecarFileName), "{\"source\":\"script\"}");
 
        var probe = new FakePeerInstallProbe(new Dictionary<string, PeerProbeResult>
        {
            [binary] = new UnknownPeerProbeResult(),
        });
 
        var discovery = NewDiscovery(probe, workspace);
 
        var exception = await Assert.ThrowsAsync<NotSupportedException>(() => discovery.DiscoverAllAsync(TestContext.Current.CancellationToken));
        Assert.Contains(nameof(UnknownPeerProbeResult), exception.Message);
    }
 
    [Theory]
    [InlineData(true, InstallationInfoStatus.Failed)]
    [InlineData(false, InstallationInfoStatus.NotProbed)]
    public async Task DiscoverAllAsync_ProbeFailureAndMissingInstallMetadata_SurfaceDistinctStatuses(bool hasInstallMetadata, string expectedStatus)
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var binDir = Path.Combine(workspace.WorkspaceRoot.FullName, hasInstallMetadata ? "metadata-bin" : "no-metadata-bin");
        Directory.CreateDirectory(binDir);
        var binary = WriteFakeBinary(binDir);
 
        if (hasInstallMetadata)
        {
            File.WriteAllText(Path.Combine(binDir, InstallSidecarReader.SidecarFileName), "{\"source\":\"script\"}");
        }
 
        var probe = new FakePeerInstallProbe(new Dictionary<string, PeerProbeResult>
        {
            [binary] = new PeerProbeResult.Failed("peer returned malformed JSON"),
        });
 
        using var _ = new EnvVarOverride("PATH", binDir + Path.PathSeparator + (Environment.GetEnvironmentVariable("PATH") ?? string.Empty));
 
        var discovery = NewDiscovery(probe, workspace);
        var results = await discovery.DiscoverAllAsync(TestContext.Current.CancellationToken);
 
        var row = results.Single(r =>
            string.Equals(r.CanonicalPath, binary, OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal));
        Assert.Equal(expectedStatus, row.Status);
 
        if (hasInstallMetadata)
        {
            Assert.Contains(probe.ProbedPaths, p => string.Equals(p, binary, OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal));
            Assert.Equal("script", row.Route);
            Assert.Contains("peer returned malformed JSON", row.StatusReason!);
        }
        else
        {
            Assert.DoesNotContain(probe.ProbedPaths, p => string.Equals(p, binary, OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal));
            Assert.Contains("peer was not probed", row.StatusReason!, StringComparison.OrdinalIgnoreCase);
        }
    }
 
    [Fact]
    public async Task DiscoverAllAsync_PrRoute_DerivesChannelFromDogfoodPathWhenPeerOmits()
    {
        // The structural channel for a PR install is `pr-<N>` regardless
        // of whether the older peer's --version output includes channel
        // info. Discovery should overlay it from the dogfood/pr-<N>/
        // path layout when probe.Channel comes back null.
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var prDir = Path.Combine(workspace.WorkspaceRoot.FullName, ".aspire", "dogfood", "pr-12345", "bin");
        Directory.CreateDirectory(prDir);
        var binary = WriteFakeBinary(prDir);
        File.WriteAllText(Path.Combine(prDir, InstallSidecarReader.SidecarFileName), "{\"source\":\"pr\"}");
 
        var probe = new FakePeerInstallProbe(new Dictionary<string, PeerProbeResult>
        {
            // Older peer using --version fallback: version only, no channel.
            [binary] = new PeerProbeResult.Ok(new InstallationInfo
            {
                Path = binary,
                Version = "13.4.0-pr.12345.gabcdef",
                Status = InstallationInfoStatus.Ok,
            }),
        });
 
        var discovery = NewDiscovery(probe, workspace);
        var results = await discovery.DiscoverAllAsync(TestContext.Current.CancellationToken);
 
        var prRow = results.Single(r =>
            string.Equals(r.CanonicalPath, binary, OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal));
        Assert.Equal("pr-12345", prRow.Channel);
        Assert.Equal("pr", prRow.Route);
        Assert.Equal("13.4.0-pr.12345.gabcdef", prRow.Version);
    }
 
    [Fact]
    public async Task DiscoverAllAsync_PrRoute_DoesNotOverwritePeerReportedChannel()
    {
        // When the peer DOES report a channel, the discovery layer must not overwrite it
        // with the path-derived value, even if they happen to match.
        // This guards against a bug where overlay logic assumes channel
        // is always missing on the fallback path.
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var prDir = Path.Combine(workspace.WorkspaceRoot.FullName, ".aspire", "dogfood", "pr-12345", "bin");
        Directory.CreateDirectory(prDir);
        var binary = WriteFakeBinary(prDir);
        File.WriteAllText(Path.Combine(prDir, InstallSidecarReader.SidecarFileName), "{\"source\":\"pr\"}");
 
        var probe = new FakePeerInstallProbe(new Dictionary<string, PeerProbeResult>
        {
            [binary] = new PeerProbeResult.Ok(new InstallationInfo
            {
                Path = binary,
                Version = "13.4.0-pr.12345.gabcdef",
                Channel = "pr-12345-from-peer", // intentionally distinct
                Status = InstallationInfoStatus.Ok,
            }),
        });
 
        var discovery = NewDiscovery(probe, workspace);
        var results = await discovery.DiscoverAllAsync(TestContext.Current.CancellationToken);
 
        var prRow = results.Single(r =>
            string.Equals(r.CanonicalPath, binary, OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal));
        Assert.Equal("pr-12345-from-peer", prRow.Channel);
    }
 
    [Fact]
    public async Task DiscoverAllAsync_BrewRoute_DerivesPrChannelFromVersionWhenPeerOmits()
    {
        // Brew-installed PR builds (e.g. from `brew install aspire@pr-N`) live
        // under a path that doesn't carry the `dogfood/pr-<N>/bin` shape, so
        // the path-based derivation can't help. The version string, on the
        // other hand, IS baked at build time as `<x.y.z>-pr.<N>.<hash>` —
        // discovery should fall back to that signal so older brew peers,
        // which don't recognize the `doctor --self` self-describe contract,
        // still surface their channel instead of "(unknown)".
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var brewDir = Path.Combine(workspace.WorkspaceRoot.FullName, "Caskroom", "aspire", "13.4.0-pr.17115.gcd700928");
        Directory.CreateDirectory(brewDir);
        var binary = WriteFakeBinary(brewDir);
        File.WriteAllText(Path.Combine(brewDir, InstallSidecarReader.SidecarFileName), "{\"source\":\"brew\"}");
 
        var probe = new FakePeerInstallProbe(new Dictionary<string, PeerProbeResult>
        {
            // Older brew peer: doctor --self unsupported, so the probe
            // took the --version fallback path and reported version only.
            [binary] = new PeerProbeResult.Ok(new InstallationInfo
            {
                Path = binary,
                Version = "13.4.0-pr.17115.gcd700928",
                Status = InstallationInfoStatus.Ok,
            }),
        });
        using var _ = new EnvVarOverride("PATH", brewDir);
 
        var discovery = NewDiscovery(probe, workspace);
        var results = await discovery.DiscoverAllAsync(TestContext.Current.CancellationToken);
 
        var brewRow = results.Single(r =>
            string.Equals(r.CanonicalPath, binary, OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal));
        Assert.Equal("pr-17115", brewRow.Channel);
        Assert.Equal("brew", brewRow.Route);
        Assert.Equal("13.4.0-pr.17115.gcd700928", brewRow.Version);
    }
 
    [Fact]
    public async Task DiscoverAllAsync_BrewRoute_LeavesChannelNullForStableVersion()
    {
        // Same shape as the previous test, but with a non-PR version. The
        // version-based derivation must return null (we can't recover the
        // channel from a `13.4.0` style stable version), so the channel
        // stays unset and the doctor table renders "(unknown)".
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var brewDir = Path.Combine(workspace.WorkspaceRoot.FullName, "Caskroom", "aspire", "13.4.0");
        Directory.CreateDirectory(brewDir);
        var binary = WriteFakeBinary(brewDir);
        File.WriteAllText(Path.Combine(brewDir, InstallSidecarReader.SidecarFileName), "{\"source\":\"brew\"}");
 
        var probe = new FakePeerInstallProbe(new Dictionary<string, PeerProbeResult>
        {
            [binary] = new PeerProbeResult.Ok(new InstallationInfo
            {
                Path = binary,
                Version = "13.4.0",
                Status = InstallationInfoStatus.Ok,
            }),
        });
        using var _ = new EnvVarOverride("PATH", brewDir);
 
        var discovery = NewDiscovery(probe, workspace);
        var results = await discovery.DiscoverAllAsync(TestContext.Current.CancellationToken);
 
        var brewRow = results.Single(r =>
            string.Equals(r.CanonicalPath, binary, OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal));
        Assert.Null(brewRow.Channel);
        Assert.Equal("brew", brewRow.Route);
    }
 
    [Theory]
    [InlineData("pr-")]              // empty PR number suffix
    [InlineData("pr-not-digits")]    // non-digit suffix
    [InlineData("pull-12345")]       // wrong prefix
    [InlineData("PR-1234")]          // wrong case — producer only emits lowercase
    [InlineData("Pr-1234")]          // wrong case — producer only emits lowercase
    public void TryDerivePrChannel_RejectsMalformedPrLabels(string labelName)
    {
        // We only synthesize a channel when the directory name strictly
        // matches `pr-<digits>`; anything else (custom --install-path
        // installs, manual layouts, future label shapes) returns null so
        // we don't surface a misleading channel string.
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var binary = Path.Combine(workspace.WorkspaceRoot.FullName, "dogfood", labelName, "bin", "aspire");
        Directory.CreateDirectory(Path.GetDirectoryName(binary)!);
 
        var derived = InstallationDiscovery.TryDerivePrChannel(binary);
        Assert.Null(derived);
    }
 
    [Fact]
    public void TryDerivePrChannel_RejectsNonDogfoodGrandparent()
    {
        // The grandparent dir must literally be `dogfood` — anything else
        // (e.g., `~/.aspire/staging/pr-1/bin`) is not the conventional
        // PR-script layout and we shouldn't synthesize a channel from it.
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var binary = Path.Combine(workspace.WorkspaceRoot.FullName, "staging", "pr-1234", "bin", "aspire");
        Directory.CreateDirectory(Path.GetDirectoryName(binary)!);
 
        var derived = InstallationDiscovery.TryDerivePrChannel(binary);
        Assert.Null(derived);
    }
 
    [Fact]
    public void TryDerivePrChannel_AcceptsValidDogfoodLayout()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var binary = Path.Combine(workspace.WorkspaceRoot.FullName, "dogfood", "pr-9876", "bin", "aspire");
 
        var derived = InstallationDiscovery.TryDerivePrChannel(binary);
        Assert.Equal("pr-9876", derived);
    }
 
    [Theory]
    [InlineData("13.4.0-pr.17115.gcd700928", "pr-17115")]    // canonical PR build
    [InlineData("13.3.0-pr.1234.abc",         "pr-1234")]     // short hash suffix
    [InlineData("13.4.0-pr.5",                "pr-5")]        // missing hash suffix (defensive)
    [InlineData("13.4.0-pr.99999+build.42",   "pr-99999")]    // SemVer build-metadata separator
    public void TryDerivePrChannelFromVersion_AcceptsPrShapedVersions(string version, string expected)
    {
        Assert.Equal(expected, InstallationDiscovery.TryDerivePrChannelFromVersion(version));
    }
 
    [Theory]
    [InlineData(null)]                                  // null version (peer reported nothing)
    [InlineData("")]                                    // empty version
    [InlineData("13.4.0")]                              // stable release
    [InlineData("13.4.0-staging.42")]                   // staging release
    [InlineData("13.4.0-daily.abc123")]                 // daily release
    [InlineData("13.4.0-preview.1.99999.1")]            // preview build (predates pr-channel)
    [InlineData("13.4.0-pr.")]                          // marker with no digits
    [InlineData("13.4.0-pr.foo.bar")]                   // non-digit suffix
    [InlineData("13.4.0-pr.1foo.bar")]                  // mixed digits/letters in the N segment
    [InlineData("13.4.0-prerelease.1.gabc")]            // contains "pr" but not "-pr."
    public void TryDerivePrChannelFromVersion_RejectsNonPrShapedVersions(string? version)
    {
        Assert.Null(InstallationDiscovery.TryDerivePrChannelFromVersion(version));
    }
 
    [Fact]
    public async Task DiscoverAllAsync_LogsInstallMetadataRejection_AtDebugLevel()
    {
        // When the install metadata check rejects a candidate (no sidecar), the user
        // should see why in --log-level debug output. Without this, an
        // install that "doesn't show up correctly" in `aspire doctor`
        // is hard to diagnose.
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var pathDir = Path.Combine(workspace.WorkspaceRoot.FullName, "no-sidecar-bin");
        Directory.CreateDirectory(pathDir);
        WriteFakeBinary(pathDir);
 
        using var _ = new EnvVarOverride("PATH", pathDir + Path.PathSeparator + (Environment.GetEnvironmentVariable("PATH") ?? string.Empty));
 
        var capturedLog = new CapturingLogger<InstallationDiscovery>();
        var discovery = new InstallationDiscovery(
            channelReader: new FakeIdentityChannelReader("local"),
            sidecarReader: CliTestHelper.CreateSidecarReader(outputHelper),
            peerProbe: new FakePeerInstallProbe(),
            executionContext: CreateExecutionContext(workspace),
            environment: new TestEnvironment(),
            logger: capturedLog,
            candidateSources: []);
 
        await discovery.DiscoverAllAsync(TestContext.Current.CancellationToken);
 
        Assert.Contains(capturedLog.Entries, e =>
            e.Level == LogLevel.Debug &&
            e.Message.Contains("did not pass install metadata sidecar read", StringComparison.Ordinal) &&
            e.Message.Contains("NotFound", StringComparison.Ordinal) &&
            e.Message.Contains("not-probed", StringComparison.Ordinal));
    }
 
    [Fact]
    public async Task DiscoverAllAsync_LogsDogfoodDirectoryWithoutBinary_AtDebugLevel()
    {
        // A stale ~/.aspire/dogfood/pr-N directory without a bin/aspire
        // inside (failed install, partial uninstall, manual mucking) is
        // worth flagging in debug output so the user can correlate.
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var staleDogfoodDir = Path.Combine(workspace.WorkspaceRoot.FullName, ".aspire", "dogfood", "pr-9999");
        Directory.CreateDirectory(staleDogfoodDir); // exists, but no bin/aspire inside
 
        var capturedLog = new CapturingLogger<InstallationDiscovery>();
        var discovery = new InstallationDiscovery(
            channelReader: new FakeIdentityChannelReader("local"),
            sidecarReader: CliTestHelper.CreateSidecarReader(outputHelper),
            peerProbe: new FakePeerInstallProbe(),
            executionContext: CreateExecutionContext(workspace),
            environment: new TestEnvironment(),
            logger: capturedLog,
            candidateSources: []);
 
        await discovery.DiscoverAllAsync(TestContext.Current.CancellationToken);
 
        Assert.Contains(capturedLog.Entries, e =>
            e.Level == LogLevel.Debug &&
            e.Message.Contains(staleDogfoodDir, StringComparison.Ordinal) &&
            e.Message.Contains("not classifying as a real install", StringComparison.Ordinal));
    }
 
    [Fact]
    public async Task DiscoverAllAsync_UnreadableDogfoodRoot_DoesNotFailDiscovery()
    {
        if (OperatingSystem.IsWindows())
        {
            Assert.Skip("Unix file modes are required to create a deterministic unreadable directory.");
            return;
        }
 
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var dogfoodRoot = Path.Combine(workspace.WorkspaceRoot.FullName, ".aspire", "dogfood");
        Directory.CreateDirectory(dogfoodRoot);
        var originalMode = File.GetUnixFileMode(dogfoodRoot);
 
        try
        {
            File.SetUnixFileMode(dogfoodRoot, UnixFileMode.None);
 
            var capturedLog = new CapturingLogger<InstallationDiscovery>();
            var discovery = new InstallationDiscovery(
                channelReader: new FakeIdentityChannelReader("local"),
                sidecarReader: CliTestHelper.CreateSidecarReader(outputHelper),
                peerProbe: new FakePeerInstallProbe(),
                executionContext: CreateExecutionContext(workspace),
                environment: new TestEnvironment(),
                logger: capturedLog,
                candidateSources: []);
 
            var results = await discovery.DiscoverAllAsync(TestContext.Current.CancellationToken);
 
            Assert.NotEmpty(results);
            Assert.Equal(InstallationInfoStatus.Ok, results[0].Status);
            Assert.Contains(capturedLog.Entries, e =>
                e.Level == LogLevel.Debug &&
                e.Message.Contains("failed to enumerate directories", StringComparison.Ordinal) &&
                e.Message.Contains(dogfoodRoot, StringComparison.Ordinal));
        }
        finally
        {
            File.SetUnixFileMode(dogfoodRoot, originalMode | UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute);
        }
    }
 
    [Fact]
    public async Task DiscoverAllAsync_UnreadableDotnetToolStore_DoesNotFailDiscovery()
    {
        if (OperatingSystem.IsWindows())
        {
            Assert.Skip("Unix file modes are required to create a deterministic unreadable directory.");
            return;
        }
 
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var toolStore = Path.Combine(workspace.WorkspaceRoot.FullName, ".dotnet", "tools", ".store", "aspire.cli");
        Directory.CreateDirectory(toolStore);
        var originalMode = File.GetUnixFileMode(toolStore);
 
        try
        {
            File.SetUnixFileMode(toolStore, UnixFileMode.None);
 
            var capturedLog = new CapturingLogger<InstallationDiscovery>();
            var discovery = new InstallationDiscovery(
                channelReader: new FakeIdentityChannelReader("local"),
                sidecarReader: CliTestHelper.CreateSidecarReader(outputHelper),
                peerProbe: new FakePeerInstallProbe(),
                executionContext: CreateExecutionContext(workspace),
                environment: new TestEnvironment(),
                logger: capturedLog,
                candidateSources: []);
 
            var results = await discovery.DiscoverAllAsync(TestContext.Current.CancellationToken);
 
            // The contract is "an unreadable tool store does not break discovery":
            // self still resolves, and no entry from the unreadable tree leaks
            // into the result set. The candidate source uses
            // EnumerationOptions.IgnoreInaccessible to silently skip the
            // inaccessible root, so MoveNext returns false and the walk
            // produces zero candidates — there is intentionally no error log
            // to assert on for this case (an unreadable subtree is exactly
            // the IgnoreInaccessible scenario, not an error).
            Assert.NotEmpty(results);
            Assert.Equal(InstallationInfoStatus.Ok, results[0].Status);
            Assert.DoesNotContain(results, info =>
                info.Path.StartsWith(toolStore, StringComparison.Ordinal) ||
                (info.CanonicalPath?.StartsWith(toolStore, StringComparison.Ordinal) ?? false));
        }
        finally
        {
            File.SetUnixFileMode(toolStore, originalMode | UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute);
        }
    }
 
    [Fact]
    public async Task DiscoverAllAsync_RunningCliIsAlwaysFirst()
    {
        // Self must appear first regardless of what walks find — both for
        // the table display contract ("(current)" marker) and to keep peer
        // dedup deterministic.
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
 
        var discovery = NewDiscovery(new FakePeerInstallProbe(), workspace);
        var results = await discovery.DiscoverAllAsync(TestContext.Current.CancellationToken);
 
        Assert.NotEmpty(results);
        Assert.Equal(InstallationInfoStatus.Ok, results[0].Status);
        var canonicalSelf = ResolveCanonicalProcessPath();
        Assert.Equal(canonicalSelf, results[0].CanonicalPath, StringComparer.OrdinalIgnoreCase);
    }
 
    [Fact]
    public async Task DiscoverAllAsync_RunningCliUsesIdentityChannel()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
 
        var discovery = NewDiscovery(new FakePeerInstallProbe(), workspace, identityChannel: "staging");
        var results = await discovery.DiscoverAllAsync(TestContext.Current.CancellationToken);
 
        Assert.NotEmpty(results);
        Assert.Equal("staging", results[0].Channel);
    }
 
    [Fact]
    public async Task DiscoverAllAsync_HonorsExecutionContextHomeDirectory_WithoutEnvOverride()
    {
        // Regression: discovery must read home from the injected
        // CliExecutionContext, not from Environment.GetFolderPath /
        // USERPROFILE. The HOME/USERPROFILE env-var override technique is
        // platform-conditional — it propagates through GetFolderPath on
        // Unix but not on Windows (where GetFolderPath reads from the
        // security token). Injecting via CliExecutionContext.HomeDirectory
        // is the only redirection that works uniformly. This test
        // intentionally does NOT set HOME/USERPROFILE so it would fail on
        // Windows pre-fix.
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var releaseBinDir = Path.Combine(workspace.WorkspaceRoot.FullName, ".aspire", "bin");
        Directory.CreateDirectory(releaseBinDir);
        var binary = WriteFakeBinary(releaseBinDir);
        File.WriteAllText(Path.Combine(releaseBinDir, InstallSidecarReader.SidecarFileName), "{\"source\":\"script\"}");
 
        var probe = new FakePeerInstallProbe(new Dictionary<string, PeerProbeResult>
        {
            [binary] = new PeerProbeResult.Ok(new InstallationInfo
            {
                Path = binary,
                Version = "13.4.0",
                Channel = "stable",
                Route = "script",
                Status = InstallationInfoStatus.Ok,
            }),
        });
 
        var discovery = NewDiscovery(probe, workspace);
        var results = await discovery.DiscoverAllAsync(TestContext.Current.CancellationToken);
 
        Assert.Contains(results, r =>
            string.Equals(r.CanonicalPath, binary, OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal));
    }
 
    [Fact]
    public async Task DiscoverAllAsync_UsesAspireHomeDirectoryForPortableInstallPrefixes()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var userHome = workspace.CreateDirectory("user-home");
        var aspireHome = workspace.CreateDirectory("portable-home");
        var releaseBinDir = Path.Combine(aspireHome.FullName, "bin");
        Directory.CreateDirectory(releaseBinDir);
        var binary = WriteFakeBinary(releaseBinDir);
        File.WriteAllText(Path.Combine(releaseBinDir, InstallSidecarReader.SidecarFileName), "{\"source\":\"localhive\"}");
 
        var probe = new FakePeerInstallProbe(new Dictionary<string, PeerProbeResult>
        {
            [binary] = new PeerProbeResult.Ok(new InstallationInfo
            {
                Path = binary,
                Version = "13.4.0",
                Channel = "local",
                Route = "localhive",
                Status = InstallationInfoStatus.Ok,
            }),
        });
        var root = workspace.WorkspaceRoot;
        var context = new CliExecutionContext(
            workingDirectory: root,
            hivesDirectory: root,
            cacheDirectory: root,
            sdksDirectory: root,
            logsDirectory: root,
            logFilePath: Path.Combine(root.FullName, "test.log"),
            identityChannel: "local",
            homeDirectory: userHome,
            aspireHomeDirectory: aspireHome);
        var discovery = new InstallationDiscovery(
            channelReader: new FakeIdentityChannelReader("local"),
            sidecarReader: CliTestHelper.CreateSidecarReader(outputHelper),
            peerProbe: probe,
            executionContext: context,
            environment: new TestEnvironment(),
            logger: NullLogger<InstallationDiscovery>.Instance,
            candidateSources: []);
 
        var results = await discovery.DiscoverAllAsync(TestContext.Current.CancellationToken);
 
        Assert.Contains(results, r =>
            string.Equals(r.CanonicalPath, binary, OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal));
    }
 
    [Fact]
    public async Task DiscoverAllAsync_OnMacOS_DedupsSelfAgainstPathHit_AcrossFirmlinkBoundary()
    {
        // Bug A regression: when Environment.ProcessPath comes back firmlinked
        // (/private/var/...) but the same physical binary is discovered via $PATH
        // in the un-firmlinked form (/var/...), the dedup `seen` HashSet treats
        // them as distinct strings and the same install lands in the table twice.
        // The fix lives in CliPathHelper.ResolveSymlinkToFullPath which strips
        // macOS firmlink prefixes; this test pins the end-to-end behavior so
        // a regression in either the helper or the dedup site is caught.
        Assert.SkipUnless(OperatingSystem.IsMacOS(), "Firmlink dedup only applies on macOS where /var → /private/var is a firmlink.");
 
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var binDir = Path.Combine(workspace.WorkspaceRoot.FullName, "firmlink-self-bin");
        Directory.CreateDirectory(binDir);
        var binary = WriteTrustedFakeBinary(binDir, "pr");
 
        // Drop the binary's bin directory at the front of PATH so the PATH walk
        // discovers it. PATH entries don't pass through firmlinking, so this
        // appears to discovery as /var/folders/.../aspire.
        using var pathOverride = new EnvVarOverride(
            "PATH",
            binDir + Path.PathSeparator + (Environment.GetEnvironmentVariable("PATH") ?? string.Empty));
 
        // Simulate the macOS .NET runtime: Environment.ProcessPath always comes
        // back realpath-resolved on macOS, which collapses /var → /private/var.
        // Both forms resolve to the same physical file via APFS firmlinks.
        var firmlinkedProcessPath = "/private" + binary;
        Assert.True(File.Exists(firmlinkedProcessPath), "firmlinked /private/var path should resolve to the same file");
 
        var probe = new FakePeerInstallProbe(new Dictionary<string, PeerProbeResult>
        {
            // The probe is keyed on the canonical (firmlink-stripped) path.
            // If dedup is broken, the second row will be probed too and the
            // result count will exceed 1.
            [binary] = new PeerProbeResult.Ok(new InstallationInfo
            {
                Path = binary,
                CanonicalPath = binary,
                Version = "test-version",
                Channel = "pr-99999",
                Route = "pr",
                Status = InstallationInfoStatus.Ok,
            }),
        });
        var discovery = NewDiscovery(probe, workspace);
 
        var results = await discovery.DiscoverAllAsync(firmlinkedProcessPath, TestContext.Current.CancellationToken);
 
        // Exactly one row for this physical binary across every canonical
        // form that could point at it. Pre-fix the self row would carry the
        // firmlinked canonical (`/private/var/...`) while the $PATH-walked
        // candidate would carry the un-firmlinked form (`/var/...`), and
        // both would land in `results` because the dedup `seen` HashSet
        // saw two distinct strings. Match by either form so a regression
        // shows up as a count of two.
        var matching = results
            .Where(r =>
                string.Equals(r.CanonicalPath, binary, StringComparison.Ordinal) ||
                string.Equals(r.CanonicalPath, firmlinkedProcessPath, StringComparison.Ordinal))
            .ToList();
        Assert.Single(matching);
        var row = matching[0];
        // Self surfaces the un-firmlinked path everywhere (Path,
        // CanonicalPath) so the displayed table is consistent with peer
        // rows (PATH walks return un-firmlinked entries; candidate sources
        // derive from the firmlink-stripped AspireHome).
        Assert.Equal(binary, row.Path);
        Assert.Equal(binary, row.CanonicalPath);
        Assert.DoesNotContain("/private/", row.Path, StringComparison.Ordinal);
        Assert.DoesNotContain("/private/", row.CanonicalPath!, StringComparison.Ordinal);
        // Self is `active` on PATH (first hit), not `notOnPath`. Pre-fix, the
        // pathStatus comparison used distinct strings and would land on
        // notOnPath even though the binary was clearly on PATH.
        Assert.Equal(InstallationPathStatus.Active, row.PathStatus);
    }
 
    [Fact]
    public async Task DiscoverAllAsync_UsesPreResolvedCanonicalHint_WithoutReResolving()
    {
        // Pins the contract that DiscoverAllAsync uses InstallationDiscoveryCandidate.CanonicalPath
        // verbatim instead of re-resolving via CliPathHelper. We construct a candidate whose
        // CanonicalPath is a deliberately-different non-existent path (no real symlink involved):
        // if DiscoverAllAsync were re-resolving, ResolveSymlinkToFullPath would return the real
        // BinaryPath (or an empty string for a fake path) and the resulting row's CanonicalPath
        // would not match the hint. This is the strongest way to pin "we used the hint" because
        // there's no filesystem trick that could produce this canonical from the binary on disk.
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var binDir = Path.Combine(workspace.WorkspaceRoot.FullName, "hint-bin");
        Directory.CreateDirectory(binDir);
        var binary = WriteFakeBinary(binDir);
        var fakeCanonical = Path.Combine(workspace.WorkspaceRoot.FullName, "deliberately-different-canonical-path", "aspire");
 
        var probe = new FakePeerInstallProbe();
        var hintSource = new FixedCandidateSource(new InstallationDiscoveryCandidate(binary, "test-hint", fakeCanonical));
        var discovery = new InstallationDiscovery(
            channelReader: new FakeIdentityChannelReader("local"),
            sidecarReader: CliTestHelper.CreateSidecarReader(outputHelper),
            peerProbe: probe,
            executionContext: CreateExecutionContext(workspace),
            environment: new TestEnvironment(),
            logger: NullLogger<InstallationDiscovery>.Instance,
            candidateSources: [hintSource]);
 
        var results = await discovery.DiscoverAllAsync(TestContext.Current.CancellationToken);
 
        // The candidate row carries CanonicalPath = fakeCanonical, proving DiscoverAllAsync
        // did NOT re-resolve via ResolveSymlinkToFullPath (which would have returned `binary`
        // or empty). The Path field carries the original BinaryPath unchanged.
        var hintRow = Assert.Single(results, r => string.Equals(r.Path, binary, StringComparison.Ordinal));
        Assert.Equal(fakeCanonical, hintRow.CanonicalPath);
    }
 
    private sealed class FixedCandidateSource(params InstallationDiscoveryCandidate[] candidates) : IInstallationCandidateSource
    {
        public IEnumerable<InstallationDiscoveryCandidate> GetCandidates(InstallationCandidateContext context) => candidates;
    }
 
    private static string ResolveCanonicalProcessPath()
    {
        var path = Environment.ProcessPath!;
        try
        {
            var resolved = File.ResolveLinkTarget(path, returnFinalTarget: true);
            return resolved?.FullName ?? Path.GetFullPath(path);
        }
        catch (IOException)
        {
            return Path.GetFullPath(path);
        }
    }
 
    private InstallationDiscovery NewDiscovery(FakePeerInstallProbe probe, TemporaryWorkspace workspace, string identityChannel = "local")
    {
        return new InstallationDiscovery(
            channelReader: new FakeIdentityChannelReader(identityChannel),
            sidecarReader: CliTestHelper.CreateSidecarReader(outputHelper),
            peerProbe: probe,
            executionContext: CreateExecutionContext(workspace),
            environment: new TestEnvironment(),
            logger: NullLogger<InstallationDiscovery>.Instance,
            candidateSources: []);
    }
 
    /// <summary>
    /// Builds a <see cref="CliExecutionContext"/> whose <c>HomeDirectory</c>
    /// points at the test workspace. This is the canonical knob the
    /// <see cref="InstallationDiscovery"/> walk reads to resolve
    /// <c>~/.aspire</c> and <c>~/.dotnet</c> — bypassing the developer's real
    /// home directory and avoiding Windows-specific behavior of
    /// <see cref="Environment.GetFolderPath(Environment.SpecialFolder)"/>
    /// (which ignores <c>USERPROFILE</c> overrides on Windows).
    /// </summary>
    private static CliExecutionContext CreateExecutionContext(TemporaryWorkspace workspace)
    {
        var root = workspace.WorkspaceRoot;
        return new CliExecutionContext(
            workingDirectory: root,
            hivesDirectory: root,
            cacheDirectory: root,
            sdksDirectory: root,
            logsDirectory: root,
            logFilePath: Path.Combine(root.FullName, "test.log"),
            identityChannel: "local",
            homeDirectory: root);
    }
 
    /// <summary>
    /// Writes a stub "binary" file to disk. The discovery walk only checks
    /// existence; it never executes — the FakePeerInstallProbe handles
    /// what would have been the spawn.
    /// </summary>
    private static string WriteFakeBinary(string dir)
    {
        var name = OperatingSystem.IsWindows() ? "aspire.exe" : "aspire";
        var path = Path.Combine(dir, name);
        File.WriteAllBytes(path, [0x00]); // existence is what matters
        if (!OperatingSystem.IsWindows())
        {
            // Match shell semantics: a non-executable file is not a binary on PATH.
            // PathLookupHelper.FindFullPathFromPath honors the executable bit on Unix,
            // so without this chmod the PATH walk would skip the test fixture.
            File.SetUnixFileMode(path,
                UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute
                | UnixFileMode.GroupRead | UnixFileMode.GroupExecute
                | UnixFileMode.OtherRead | UnixFileMode.OtherExecute);
        }
        return path;
    }
 
    private static string WriteTrustedFakeBinary(string dir, string source)
    {
        var binary = WriteFakeBinary(dir);
        File.WriteAllText(Path.Combine(dir, InstallSidecarReader.SidecarFileName), $"{{\"source\":\"{source}\"}}");
        return binary;
    }
}
 
/// <summary>
/// Collection definition that disables parallel execution for tests which
/// mutate process-wide environment variables (PATH, PATHEXT, HOME) and
/// would otherwise race with other tests in the assembly.
/// </summary>
[CollectionDefinition(Name, DisableParallelization = true)]
public sealed class EnvVarMutatingTestCollection
{
    public const string Name = "EnvVarMutatingTests";
}