File: BundleServiceComputeDefaultExtractDirTests.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.Bundles;
using Aspire.Cli.Utils;
 
namespace Aspire.Cli.Tests;
 
// Producer↔consumer coverage for BundleService.ComputeDefaultExtractDir: each
// distribution shape that ships an .aspire-install.json sidecar must land
// extraction at the install prefix the packager owns so versions/<id>/ stays
// inside the directory the packager actually manages. These tests construct the
// real on-disk layout each route produces, then assert the helper picks the
// directory whose versions/ subdirectory the bundle should populate. They are
// the regression net against a packager moving the sidecar or the bundle
// pipeline changing its switch on the sidecar's source field.
public class BundleServiceComputeDefaultExtractDirTests(ITestOutputHelper outputHelper)
{
    private const string SidecarFileName = ".aspire-install.json";
 
    [Theory]
    [InlineData("script")]    // get-aspire-cli.{sh,ps1}
    [InlineData("localhive")] // localhive.{sh,ps1}
    public void ComputeDefaultExtractDir_SharedPrefixSource_ReturnsParentOfBinaryDir(string source)
    {
        // Both the get-aspire-cli script route and the localhive route lay the
        // CLI out at <prefix>/bin/aspire with a colocated .aspire-install.json,
        // so extraction must land at <prefix>/ (parent of the binary's dir) so
        // the eventual versions/<id>/ tree sits next to the bin/ directory
        // rather than inside it.
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var prefixDir = Path.Combine(workspace.Path, "aspire");
        var binDir = Path.Combine(prefixDir, "bin");
        Directory.CreateDirectory(binDir);
 
        var binaryPath = Path.Combine(binDir, ExeName("aspire"));
        File.WriteAllText(binaryPath, string.Empty);
        File.WriteAllText(Path.Combine(binDir, SidecarFileName), $"{{\"source\":\"{source}\"}}");
 
        var result = BundleService.ComputeDefaultExtractDir(binaryPath);
 
        Assert.Equal(prefixDir, result);
    }
 
    [Fact]
    public void ComputeDefaultExtractDir_PRSource_ReturnsPRSubprefix()
    {
        // get-aspire-cli-pr.{sh,ps1} stages each PR build under a channel-isolated
        // dogfood/pr-<N>/bin subdirectory with its own colocated sidecar. The
        // sidecar value is always source=pr; the per-PR namespace lives in the
        // directory layout. Extraction must land at the dogfood/pr-<N>/ directory
        // — not the outer install root — to keep stable and PR-route versions/
        // trees from colliding.
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var prDir = Path.Combine(workspace.Path, "dogfood", "pr-12345");
        var binDir = Path.Combine(prDir, "bin");
        Directory.CreateDirectory(binDir);
 
        var binaryPath = Path.Combine(binDir, ExeName("aspire"));
        File.WriteAllText(binaryPath, string.Empty);
        File.WriteAllText(Path.Combine(binDir, SidecarFileName), "{\"source\":\"pr\"}");
 
        var result = BundleService.ComputeDefaultExtractDir(binaryPath);
 
        Assert.Equal(prDir, result);
    }
 
    [Fact]
    public void ComputeDefaultExtractDir_WingetSource_ReturnsInstallDirectory()
    {
        // Winget extracts the zip flat: aspire.exe and .aspire-install.json land in the
        // same install dir. Extraction must land in that directory (beside the binary),
        // not its parent — otherwise versions/<id>/ would leak above the package-managed
        // prefix that winget owns.
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var installDir = Path.Combine(workspace.Path, "WindowsApps", "Microsoft.Aspire_8wekyb3d8bbwe");
        Directory.CreateDirectory(installDir);
 
        var binaryPath = Path.Combine(installDir, "aspire.exe");
        File.WriteAllText(binaryPath, string.Empty);
        File.WriteAllText(Path.Combine(installDir, SidecarFileName), "{\"source\":\"winget\"}");
 
        var result = BundleService.ComputeDefaultExtractDir(binaryPath);
 
        Assert.Equal(installDir, result);
    }
 
    [Fact]
    public void ComputeDefaultExtractDir_BrewSymlinkSource_ReturnsCellarPrefix_NotSymlinkParent()
    {
        // Regression net for the brew bug: /opt/homebrew/bin/aspire is a symlink into
        // the cellar (e.g. /opt/homebrew/Caskroom/aspire/<version>/aspire) where the
        // brew cask writes the binary AND .aspire-install.json side-by-side. The helper
        // follows the symlink before sidecar lookup; extraction must land at the cellar
        // prefix so `brew uninstall aspire` (which wipes that prefix per the cask
        // template) cleans the versions/<id>/ tree too. Using the symlink's parent
        // (/opt/homebrew/) would leak versions/ outside brew's ownership.
        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 cellarDir = Path.Combine(workspace.Path, "Caskroom", "aspire", "13.2.0");
        Directory.CreateDirectory(cellarDir);
        var cellarBinary = Path.Combine(cellarDir, "aspire");
        File.WriteAllText(cellarBinary, string.Empty);
        File.WriteAllText(Path.Combine(cellarDir, SidecarFileName), "{\"source\":\"brew\"}");
 
        var brewBinDir = Path.Combine(workspace.Path, "bin");
        Directory.CreateDirectory(brewBinDir);
        var symlinkPath = Path.Combine(brewBinDir, "aspire");
        File.CreateSymbolicLink(symlinkPath, cellarBinary);
 
        var result = BundleService.ComputeDefaultExtractDir(symlinkPath);
 
        Assert.Equal(cellarDir, result);
        Assert.NotEqual(brewBinDir, result);
        Assert.NotEqual(Path.GetDirectoryName(brewBinDir), result);
    }
 
    [Fact]
    public void ComputeDefaultExtractDir_DotnetToolSource_ReturnsToolStoreDirectory()
    {
        // RID-specific dotnet-tool nupkg lands at
        // ~/.dotnet/tools/.store/aspire.cli/<ver>/aspire.cli.<rid>/<ver>/tools/net10.0/<rid>/
        // with aspire and .aspire-install.json colocated. The dotnet-tool runtime
        // launches the binary directly from this RID directory; extraction must stay
        // within that same directory so versions/<id>/ moves with the tool.
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var ridDir = Path.Combine(
            workspace.Path,
            ".dotnet",
            "tools",
            ".store",
            "aspire.cli",
            "13.2.0",
            "aspire.cli.linux-x64",
            "13.2.0",
            "tools",
            "net10.0",
            "linux-x64");
        Directory.CreateDirectory(ridDir);
 
        var binaryPath = Path.Combine(ridDir, ExeName("aspire"));
        File.WriteAllText(binaryPath, string.Empty);
        File.WriteAllText(Path.Combine(ridDir, SidecarFileName), "{\"source\":\"dotnet-tool\"}");
 
        var result = BundleService.ComputeDefaultExtractDir(binaryPath);
 
        Assert.Equal(ridDir, result);
    }
 
    [Fact]
    public void ComputeDefaultExtractDir_UnmanagedFallback_UsesDefaultAspireHome()
    {
        // No sidecar anywhere means no install route opted in to writing next to
        // the binary, so the fallback must be user-owned Aspire home rather than
        // an arbitrary binary parent that may be read-only.
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var prefixDir = Path.Combine(workspace.Path, "nix", "store", "aspire");
        var binDir = Path.Combine(prefixDir, "bin");
        Directory.CreateDirectory(binDir);
 
        var binaryPath = Path.Combine(binDir, ExeName("aspire"));
        File.WriteAllText(binaryPath, string.Empty);
        // No sidecar at <binDir>/.aspire-install.json.
 
        var result = BundleService.ComputeDefaultExtractDir(binaryPath);
 
        Assert.Equal(CliPathHelper.GetDefaultAspireHomeDirectory(), result);
        Assert.NotEqual(prefixDir, result);
    }
 
    [Fact]
    public void ComputeDefaultExtractDir_EmptyProcessPath_ReturnsNull()
    {
        var result = BundleService.ComputeDefaultExtractDir(string.Empty);
 
        Assert.Null(result);
    }
 
    private static string ExeName(string baseName)
        => OperatingSystem.IsWindows() ? baseName + ".exe" : baseName;
}