File: Bundles\BundleServiceCrossRouteExtractionTests.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.Bundles;
 
/// <summary>
/// Verifies <see cref="BundleService.ComputeDefaultExtractDir(string)"/> against
/// every (source × prefix shape) combination the supported install routes produce.
/// The matrix locks in the contract: the reader walks the sidecar's
/// <c>source</c> field — and nothing else — to decide between
/// <c>binaryDir</c> (winget / brew / dotnet-tool) and the parent of
/// <c>binaryDir</c> (script / pr / localhive). Missing, invalid, unknown, or read-only
/// package-manager sidecars fall through to the default Aspire home.
/// </summary>
public class BundleServiceCrossRouteExtractionTests
{
    private const string DefaultAspireHome = "<default-aspire-home>";
 
    // The inline-data paths use forward slashes for source-readability; the test
    // method converts them to platform-native separators before constructing
    // absolute paths under the temp root.
    [Theory]
    // 1) winget canonical: WinGet places the portable binary in a per-package
    //    versioned directory; the bundle payload lives in that same directory.
    [InlineData("winget", "WinGet/Packages/Microsoft.Aspire_Microsoft.Winget.Source_8wekyb3d8bbwe/aspire.exe", "WinGet/Packages/Microsoft.Aspire_Microsoft.Winget.Source_8wekyb3d8bbwe")]
    // 2) brew canonical: Homebrew cellar with versioned cask directory.
    [InlineData("brew", "Caskroom/aspire/13.2.0/aspire", "Caskroom/aspire/13.2.0")]
    // 3) dotnet-tool canonical: dotnet's global-tools shim at ~/.dotnet/tools/aspire
    //    delegates (apphost launch on Windows; symlink follow on Unix) to the
    //    RID-specific native binary inside the .store directory tree. The
    //    binary the running process actually points at — and where the
    //    payload-embedded sidecar lives — is that RID dir.
    [InlineData("dotnet-tool", ".dotnet/tools/.store/aspire.cli/13.2.0/aspire.cli.linux-x64/13.2.0/tools/net10.0/linux-x64/aspire", ".dotnet/tools/.store/aspire.cli/13.2.0/aspire.cli.linux-x64/13.2.0/tools/net10.0/linux-x64")]
    // 4) script canonical: ~/.aspire/bin/aspire with bundle in ~/.aspire/.
    [InlineData("script", ".aspire/bin/aspire", ".aspire")]
    // 5) PR-script canonical: per-PR dogfood prefix.
    [InlineData("pr", ".aspire/dogfood/pr-16817/bin/aspire", ".aspire/dogfood/pr-16817")]
    // 6) localhive canonical: local dev hive with the same bin layout as script.
    [InlineData("localhive", ".aspire/local/bin/aspire", ".aspire/local")]
    // 7) Nix canonical: package output under /nix/store is read-only, so the
    //    bundle payload must extract into the user-owned default Aspire home.
    [InlineData("nix", "nix/store/abcd1234-aspire-cli-13.4.6/bin/aspire", DefaultAspireHome)]
    // 8) Cross-route smuggle case: a brew-source sidecar dropped into a
    //    script-layout prefix MUST resolve to binaryDir per the switch — the
    //    reader is honest about whatever the producer put on disk. When the
    //    producer side correctly suppresses the smuggled sidecar, this row's
    //    input condition never arises in practice; the test verifies the
    //    reader's behavior is well-defined and consistent for all inputs.
    [InlineData("brew", ".aspire/dogfood/pr-16817/bin/aspire", ".aspire/dogfood/pr-16817/bin")]
    // 9) script source dropped at a flat-cellar layout (misuse, but defined):
    //    script maps to parent-of-binary, so the result is one level above the
    //    cask version dir. Not a real install pattern; locks in determinism.
    [InlineData("script", "Caskroom/aspire/13.2.0/aspire", "Caskroom/aspire")]
    // 10) No sidecar at all: fallback to default Aspire home.
    [InlineData(null, ".aspire/bin/aspire", DefaultAspireHome)]
    // 11) Sidecar with invalid JSON: parser throws, treated as no sidecar.
    [InlineData("__invalid__", ".aspire/bin/aspire", DefaultAspireHome)]
    // 12) Sidecar with an unknown source value: switch default arm, same as
    //     missing sidecar — default Aspire home.
    [InlineData("github-actions", ".aspire/bin/aspire", DefaultAspireHome)]
    public void ComputeDefaultExtractDir_RouteAndPrefixCombinations_ProduceExpectedExtractDir(
        string? sourceField,
        string relativeProcessPath,
        string relativeExpectedExtractDir)
    {
        var root = Directory.CreateTempSubdirectory("aspire-extract-dir-").FullName;
        try
        {
            var processPath = Path.Combine(root, ToNativePath(relativeProcessPath));
            var binaryDir = Path.GetDirectoryName(processPath)!;
            Directory.CreateDirectory(binaryDir);
            File.WriteAllText(processPath, "fake-binary");
 
            if (sourceField is not null)
            {
                var sidecarPath = Path.Combine(binaryDir, ".aspire-install.json");
                var content = sourceField switch
                {
                    "__invalid__" => "{not valid json",
                    _ => $"{{\"source\":\"{sourceField}\"}}",
                };
                File.WriteAllText(sidecarPath, content);
            }
 
            var expected = relativeExpectedExtractDir == DefaultAspireHome
                ? CliPathHelper.GetDefaultAspireHomeDirectory()
                : Path.Combine(root, ToNativePath(relativeExpectedExtractDir));
 
            var actual = BundleService.ComputeDefaultExtractDir(processPath);
 
            Assert.Equal(expected, actual);
        }
        finally
        {
            try
            {
                Directory.Delete(root, recursive: true);
            }
            catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
            {
                // Best-effort cleanup.
            }
        }
    }
 
    private static string ToNativePath(string forwardSlashPath)
        => forwardSlashPath.Replace('/', Path.DirectorySeparatorChar);
}