File: Layout\LayoutDiscoveryReparsePointTests.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.Layout;
using Aspire.Cli.Tests.Acquisition;
using Aspire.Cli.Tests.Utils;
using Aspire.Cli.Utils;
using Aspire.Shared;
using Microsoft.Extensions.Logging.Abstractions;
 
namespace Aspire.Cli.Tests.LayoutTests;
 
/// <summary>
/// Verifies that <see cref="LayoutDiscovery"/> successfully discovers a bundle layout
/// whose <c>bundle/</c> entry is a reparse point pointing at a versioned subdirectory
/// (the new on-disk shape introduced for transactional installs).
/// </summary>
/// <remarks>
/// The Aspire-home fallback test mutates the process-wide <c>ASPIRE_HOME</c> environment
/// variable, which is also read by <see cref="CliPathHelper.GetDefaultAspireHomeDirectory()"/>
/// and indirectly by <c>BundleService.ComputeDefaultExtractDir</c>. xUnit runs test classes
/// in parallel by default, so without joining <see cref="EnvVarMutatingTestCollection"/> a
/// concurrent reader in another suite could observe the temporary override and assert against
/// the wrong path. Disabling parallelization across the mutating tests prevents that race.
/// </remarks>
[Collection(EnvVarMutatingTestCollection.Name)]
public class LayoutDiscoveryReparsePointTests(ITestOutputHelper outputHelper)
{
    [Fact]
    public void DiscoverLayout_ResolvesProcessPathSymlinkBeforeRelativeDiscovery()
    {
        Assert.SkipUnless(OperatingSystem.IsLinux() || OperatingSystem.IsMacOS(),
            "Symlink resolution test only runs on Linux/macOS where unprivileged symlink creation is reliable.");
 
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var realLayoutRoot = Path.Combine(
            workspace.WorkspaceRoot.FullName,
            "WinGet",
            "Packages",
            "Microsoft.Aspire_Microsoft.Winget.Source_8wekyb3d8bbwe");
        CreateValidBundleLayout(realLayoutRoot);
 
        var realBinary = Path.Combine(realLayoutRoot, "aspire");
        File.WriteAllText(realBinary, "stub");
 
        var linksDir = Path.Combine(workspace.WorkspaceRoot.FullName, "WinGet", "Links");
        Directory.CreateDirectory(linksDir);
        var linkPath = Path.Combine(linksDir, "aspire");
        File.CreateSymbolicLink(linkPath, realBinary);
 
        var discovery = new LayoutDiscovery(NullLogger<LayoutDiscovery>.Instance, new HostEnvironment())
        {
            ProcessPathOverride = linkPath
        };
 
        var layout = discovery.DiscoverLayout();
 
        Assert.NotNull(layout);
        Assert.Equal(realLayoutRoot, layout!.LayoutPath);
    }
 
    [Fact]
    public void DiscoverLayout_FallsBackToRawProcessPathWhenResolvedPathHasNoLayout()
    {
        Assert.SkipUnless(OperatingSystem.IsLinux() || OperatingSystem.IsMacOS(),
            "Symlink resolution test only runs on Linux/macOS where unprivileged symlink creation is reliable.");
 
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var rawLayoutRoot = Path.Combine(workspace.WorkspaceRoot.FullName, "custom-layout");
        CreateValidBundleLayout(rawLayoutRoot);
 
        var realBinaryDir = Path.Combine(workspace.WorkspaceRoot.FullName, "real-binary");
        Directory.CreateDirectory(realBinaryDir);
        var realBinary = Path.Combine(realBinaryDir, "aspire");
        File.WriteAllText(realBinary, "stub");
 
        var linkPath = Path.Combine(rawLayoutRoot, "aspire");
        File.CreateSymbolicLink(linkPath, realBinary);
 
        var discovery = new LayoutDiscovery(NullLogger<LayoutDiscovery>.Instance, new HostEnvironment())
        {
            ProcessPathOverride = linkPath
        };
 
        var layout = discovery.DiscoverLayout();
 
        Assert.NotNull(layout);
        Assert.Equal(rawLayoutRoot, layout!.LayoutPath);
    }
 
    [Fact]
    public void DiscoverLayout_ResolvesThroughReparsePoints()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var layoutRoot = workspace.WorkspaceRoot.FullName;
 
        var versionsDir = Path.Combine(layoutRoot, "versions", "v1");
        var versionedManaged = Path.Combine(versionsDir, BundleDiscovery.ManagedDirectoryName);
        var versionedDcp = Path.Combine(versionsDir, BundleDiscovery.DcpDirectoryName);
        Directory.CreateDirectory(versionedManaged);
        Directory.CreateDirectory(versionedDcp);
        File.WriteAllText(
            Path.Combine(versionedManaged, BundleDiscovery.GetExecutableFileName(BundleDiscovery.ManagedExecutableName)),
            "stub");
        File.WriteAllText(BundleDiscovery.GetDcpExecutablePath(versionedDcp), "stub");
 
        // Create a single bundle/ link pointing at the versioned directory.
        var bundleLink = Path.Combine(layoutRoot, BundleDiscovery.BundleDirectoryName);
        ReparsePoint.CreateOrReplace(bundleLink, versionsDir);
 
        Assert.True(ReparsePoint.IsReparsePoint(bundleLink));
 
        var originalEnv = Environment.GetEnvironmentVariable(BundleDiscovery.LayoutPathEnvVar);
        try
        {
            Environment.SetEnvironmentVariable(BundleDiscovery.LayoutPathEnvVar, layoutRoot);
            var discovery = new LayoutDiscovery(NullLogger<LayoutDiscovery>.Instance, new HostEnvironment());
            var layout = discovery.DiscoverLayout();
 
            Assert.NotNull(layout);
            Assert.Equal(layoutRoot, layout!.LayoutPath);
            Assert.True(discovery.IsBundleModeAvailable());
        }
        finally
        {
            Environment.SetEnvironmentVariable(BundleDiscovery.LayoutPathEnvVar, originalEnv);
            ReparsePoint.RemoveIfExists(bundleLink);
        }
    }
 
    [Fact]
    public void DiscoverLayout_FallsBackToAspireHomeWhenLayoutIsNotRelativeToCli()
    {
        // Simulates a sidecar-less install where the CLI binary lives somewhere
        // unrelated to the extracted bundle (e.g. a Nix store / read-only path)
        // and the bundle is extracted to $ASPIRE_HOME.
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
 
        var binaryDir = Path.Combine(workspace.WorkspaceRoot.FullName, "opt", "aspire", "bin");
        Directory.CreateDirectory(binaryDir);
        var binaryPath = Path.Combine(binaryDir, OperatingSystem.IsWindows() ? "aspire.exe" : "aspire");
        File.WriteAllText(binaryPath, "stub");
 
        var aspireHome = Path.Combine(workspace.WorkspaceRoot.FullName, "home", ".aspire");
        CreateValidBundleLayout(aspireHome);
 
        var originalAspireHome = Environment.GetEnvironmentVariable(CliPathHelper.AspireHomeEnvironmentVariable);
        try
        {
            Environment.SetEnvironmentVariable(CliPathHelper.AspireHomeEnvironmentVariable, aspireHome);
 
            var discovery = new LayoutDiscovery(NullLogger<LayoutDiscovery>.Instance, new HostEnvironment())
            {
                ProcessPathOverride = binaryPath
            };
 
            var layout = discovery.DiscoverLayout();
 
            Assert.NotNull(layout);
            Assert.Equal(aspireHome, layout!.LayoutPath);
        }
        finally
        {
            Environment.SetEnvironmentVariable(CliPathHelper.AspireHomeEnvironmentVariable, originalAspireHome);
        }
    }
 
    [Fact]
    public void DiscoverLayout_PrefersRelativeLayoutOverAspireHomeLayout()
    {
        // The Aspire-home probe is the last fallback in DiscoverLayout so that
        // package-managed or sidecar-owned installs (winget/brew/dotnet-tool/script/
        // pr/localhive) — which already colocate the bundle next to or above the
        // CLI binary — are never shadowed by a stale or differently-versioned
        // layout left over in $HOME/.aspire from an earlier sidecar-less run.
        // This regression test pins that ordering: when both layouts exist, the
        // relative-to-CLI layout wins.
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
 
        // Colocated (relative) layout: CLI in {layoutRoot}/bin/aspire with the
        // bundle as a sibling of bin/.
        var relativeLayoutRoot = Path.Combine(workspace.WorkspaceRoot.FullName, "colocated");
        var binDir = Path.Combine(relativeLayoutRoot, "bin");
        Directory.CreateDirectory(binDir);
        var binaryPath = Path.Combine(binDir, OperatingSystem.IsWindows() ? "aspire.exe" : "aspire");
        File.WriteAllText(binaryPath, "stub");
        CreateValidBundleLayout(relativeLayoutRoot);
 
        // Competing $ASPIRE_HOME layout: also valid but should not be selected.
        var aspireHome = Path.Combine(workspace.WorkspaceRoot.FullName, "home", ".aspire");
        CreateValidBundleLayout(aspireHome);
 
        var originalAspireHome = Environment.GetEnvironmentVariable(CliPathHelper.AspireHomeEnvironmentVariable);
        try
        {
            Environment.SetEnvironmentVariable(CliPathHelper.AspireHomeEnvironmentVariable, aspireHome);
 
            var discovery = new LayoutDiscovery(NullLogger<LayoutDiscovery>.Instance, new HostEnvironment())
            {
                ProcessPathOverride = binaryPath
            };
 
            var layout = discovery.DiscoverLayout();
 
            Assert.NotNull(layout);
            Assert.Equal(relativeLayoutRoot, layout!.LayoutPath);
            Assert.NotEqual(aspireHome, layout.LayoutPath);
        }
        finally
        {
            Environment.SetEnvironmentVariable(CliPathHelper.AspireHomeEnvironmentVariable, originalAspireHome);
        }
    }
 
    [Theory]
    [InlineData(false)]
    [InlineData(true)]
    public void DiscoverLayout_RejectsRelativeLayoutWithoutDcpExecutable(bool useBundleDirectory)
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var layoutRoot = workspace.WorkspaceRoot.FullName;
        var componentRoot = useBundleDirectory
            ? Path.Combine(layoutRoot, BundleDiscovery.BundleDirectoryName)
            : layoutRoot;
        var managedDir = Directory.CreateDirectory(Path.Combine(componentRoot, BundleDiscovery.ManagedDirectoryName));
        Directory.CreateDirectory(Path.Combine(componentRoot, BundleDiscovery.DcpDirectoryName));
        File.WriteAllText(
            Path.Combine(managedDir.FullName, BundleDiscovery.GetExecutableFileName(BundleDiscovery.ManagedExecutableName)),
            "stub");
        var binaryPath = Path.Combine(layoutRoot, OperatingSystem.IsWindows() ? "aspire.exe" : "aspire");
        File.WriteAllText(binaryPath, "stub");
 
        var discovery = new LayoutDiscovery(NullLogger<LayoutDiscovery>.Instance, new TestEnvironment())
        {
            ProcessPathOverride = binaryPath
        };
 
        var layout = discovery.DiscoverLayout();
 
        Assert.NotEqual(layoutRoot, layout?.LayoutPath);
    }
 
    private static void CreateValidBundleLayout(string layoutRoot)
    {
        var bundleDir = Path.Combine(layoutRoot, BundleDiscovery.BundleDirectoryName);
        var managedDir = Path.Combine(bundleDir, BundleDiscovery.ManagedDirectoryName);
        var dcpDir = Path.Combine(bundleDir, BundleDiscovery.DcpDirectoryName);
        Directory.CreateDirectory(managedDir);
        Directory.CreateDirectory(dcpDir);
        File.WriteAllText(
            Path.Combine(managedDir, BundleDiscovery.GetExecutableFileName(BundleDiscovery.ManagedExecutableName)),
            "stub");
        File.WriteAllText(BundleDiscovery.GetDcpExecutablePath(dcpDir), "stub");
    }
}