File: BundleServiceTests.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;
using Aspire.Shared;
 
namespace Aspire.Cli.Tests;
 
public class BundleServiceTests(ITestOutputHelper outputHelper)
{
    [Fact]
    public void IsBundle_ReturnsFalse_WhenNoEmbeddedResource()
    {
        // Test assembly has no embedded bundle.tar.gz resource — verify via provider
        var provider = new EmbeddedBundlePayloadProvider();
        Assert.False(provider.HasPayload);
    }
 
    [Fact]
    public void OpenPayload_ReturnsNull_WhenNoEmbeddedResource()
    {
        var provider = new EmbeddedBundlePayloadProvider();
        Assert.Null(provider.OpenPayload());
    }
 
    [Fact]
    public void VersionMarker_WriteAndRead_Roundtrips()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var dir = workspace.WorkspaceRoot.FullName;
 
        BundleService.WriteVersionMarker(dir, "13.2.0-dev");
 
        Assert.Equal("13.2.0-dev", BundleService.ReadVersionMarker(dir));
    }
 
    [Fact]
    public void VersionMarker_ReturnsNull_WhenMissing()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        Assert.Null(BundleService.ReadVersionMarker(workspace.WorkspaceRoot.FullName));
    }
 
    [Fact]
    public void ComputeDefaultExtractDir_NoSidecar_ReturnsDefaultAspireHomeDirectory()
    {
        // Sidecar-less installs do not prove the binary's directory is user-writable.
        // Use Aspire home so arbitrary install locations such as read-only package
        // stores can still extract the embedded bundle.
        var expected = CliPathHelper.GetDefaultAspireHomeDirectory();
 
        if (OperatingSystem.IsWindows())
        {
            var result = BundleService.ComputeDefaultExtractDir(@"C:\Users\test\.aspire\bin\aspire.exe");
            Assert.Equal(expected, result);
        }
        else
        {
            var result = BundleService.ComputeDefaultExtractDir("/home/test/.aspire/bin/aspire");
            Assert.Equal(expected, result);
        }
    }
 
    [Fact]
    public void GetCurrentVersion_ReturnsNonNull()
    {
        var version = BundleService.GetCurrentVersion();
        Assert.NotNull(version);
        Assert.NotEqual("unknown", version);
    }
 
    [Fact]
    public void GetCurrentVersion_ChangesWhenCliBinaryChanges()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var processPath = Path.Combine(workspace.WorkspaceRoot.FullName, "aspire");
        File.WriteAllText(processPath, "old");
        File.SetLastWriteTimeUtc(processPath, new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc));
 
        var firstVersion = BundleService.GetCurrentVersion(processPath);
 
        File.WriteAllText(processPath, "new-content");
        File.SetLastWriteTimeUtc(processPath, new DateTime(2026, 1, 2, 0, 0, 0, DateTimeKind.Utc));
 
        var secondVersion = BundleService.GetCurrentVersion(processPath);
 
        Assert.NotEqual(firstVersion, secondVersion);
    }
 
    [Fact]
    public void ComputeVersionId_IsDeterministic()
    {
        var a = BundleService.ComputeVersionId("1.2.3|1234|56789");
        var b = BundleService.ComputeVersionId("1.2.3|1234|56789");
        Assert.Equal(a, b);
    }
 
    [Fact]
    public void ComputeVersionId_DiffersWhenFingerprintDiffers()
    {
        var a = BundleService.ComputeVersionId("1.2.3|1234|56789");
        var b = BundleService.ComputeVersionId("1.2.3|1235|56789");
        Assert.NotEqual(a, b);
    }
 
    [Fact]
    public void ComputeVersionId_IsFilesystemSafe()
    {
        var id = BundleService.ComputeVersionId("1.2.3+ci/branch?name|1234|56789");
        Assert.DoesNotContain('/', id);
        Assert.DoesNotContain('\\', id);
        Assert.DoesNotContain('+', id);
        Assert.DoesNotContain('?', id);
        Assert.DoesNotContain('|', id);
        Assert.StartsWith("1.2.3_ci_branch_name-", id, StringComparison.Ordinal);
    }
 
    [Fact]
    public void IsVersionedLayoutValid_RequiresManagedExecutableAndDcpExecutable()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var dir = workspace.WorkspaceRoot.FullName;
 
        Assert.False(BundleService.IsVersionedLayoutValid(dir));
 
        CreateFakeBundleLayout(dir);
        Assert.True(BundleService.IsVersionedLayoutValid(dir));
 
        // Removing the managed exe invalidates the layout.
        var managedExe = Path.Combine(dir, BundleDiscovery.ManagedDirectoryName,
            BundleDiscovery.GetExecutableFileName(BundleDiscovery.ManagedExecutableName));
        File.Delete(managedExe);
        Assert.False(BundleService.IsVersionedLayoutValid(dir));
 
        // A zero-length managed exe is also invalid.
        File.WriteAllBytes(managedExe, []);
        Assert.False(BundleService.IsVersionedLayoutValid(dir));
    }
 
    [Fact]
    public void IsVersionedLayoutValid_RequiresDcpDirectory()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var dir = workspace.WorkspaceRoot.FullName;
        CreateFakeBundleLayout(dir);
 
        Directory.Delete(Path.Combine(dir, BundleDiscovery.DcpDirectoryName), recursive: true);
        Assert.False(BundleService.IsVersionedLayoutValid(dir));
    }
 
    [Fact]
    public void IsVersionedLayoutValid_RequiresDcpExecutable()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var dir = workspace.WorkspaceRoot.FullName;
        CreateFakeBundleLayout(dir);
 
        File.Delete(BundleDiscovery.GetDcpExecutablePath(Path.Combine(dir, BundleDiscovery.DcpDirectoryName)));
 
        Assert.False(BundleService.IsVersionedLayoutValid(dir));
    }
 
    [Theory]
    [InlineData(unchecked((int)0x80070005), true)] // ERROR_ACCESS_DENIED
    [InlineData(unchecked((int)0x80070020), true)] // ERROR_SHARING_VIOLATION
    [InlineData(unchecked((int)0x80070021), true)] // ERROR_LOCK_VIOLATION
    [InlineData(unchecked((int)0x80070027), false)] // ERROR_HANDLE_DISK_FULL
    [InlineData(unchecked((int)0x80070070), false)] // ERROR_DISK_FULL
    [InlineData(unchecked((int)0x800700B7), false)] // ERROR_ALREADY_EXISTS
    public void IsRetryableDirectoryMoveException_OnlyRetriesTransientWindowsLockErrors(int hresult, bool expected)
    {
        var exception = new IOException("Directory move failed.", hresult);
 
        Assert.Equal(expected, BundleService.IsRetryableDirectoryMoveException(exception, isWindows: true));
    }
 
    [Fact]
    public void IsRetryableDirectoryMoveException_RetriesUnauthorizedAccess()
    {
        Assert.True(BundleService.IsRetryableDirectoryMoveException(new UnauthorizedAccessException(), isWindows: true));
    }
 
    [Fact]
    public void IsRetryableDirectoryMoveException_DoesNotRetryOnNonWindows()
    {
        var exception = new IOException("Directory move failed.", unchecked((int)0x80070020));
 
        Assert.False(BundleService.IsRetryableDirectoryMoveException(exception, isWindows: false));
    }
 
    [Fact]
    public void TryCleanupStaleVersions_RemovesNonActiveVersionsAndStaleTempDirs()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var versionsRoot = Path.Combine(workspace.WorkspaceRoot.FullName, BundleService.VersionsDirectoryName);
        Directory.CreateDirectory(versionsRoot);
 
        var active = "active-aaaaaaaaaaaaaaaa";
        Directory.CreateDirectory(Path.Combine(versionsRoot, active));
        Directory.CreateDirectory(Path.Combine(versionsRoot, "stale-bbbbbbbbbbbbbbbb"));
        Directory.CreateDirectory(Path.Combine(versionsRoot, $"active{BundleService.TempSuffixPrefix}deadbeef"));
        Directory.CreateDirectory(Path.Combine(versionsRoot, $"active{BundleService.BadSuffixPrefix}99999"));
 
        BundleService.TryCleanupStaleVersions(versionsRoot, active);
 
        Assert.True(Directory.Exists(Path.Combine(versionsRoot, active)));
        var remaining = Directory.EnumerateDirectories(versionsRoot).Select(Path.GetFileName).ToArray();
        Assert.Single(remaining);
        Assert.Equal(active, remaining[0]);
    }
 
    [Fact]
    public void CaptureLinkTargets_ReturnsNullForMissingAndRealDirectories()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var dir = workspace.WorkspaceRoot.FullName;
 
        // bundle/ does not exist → null entry.
        var captured = BundleService.CaptureLinkTargets(dir);
        Assert.Null(captured[BundleDiscovery.BundleDirectoryName]);
 
        // bundle/ is a real (non-reparse) directory → also null entry.
        Directory.CreateDirectory(Path.Combine(dir, BundleDiscovery.BundleDirectoryName));
        captured = BundleService.CaptureLinkTargets(dir);
        Assert.Null(captured[BundleDiscovery.BundleDirectoryName]);
    }
 
    private static void CreateFakeBundleLayout(string root)
    {
        var managedDir = Path.Combine(root, BundleDiscovery.ManagedDirectoryName);
        Directory.CreateDirectory(managedDir);
        File.WriteAllText(
            Path.Combine(managedDir, BundleDiscovery.GetExecutableFileName(BundleDiscovery.ManagedExecutableName)),
            "#!/bin/sh\necho aspire-managed\n");
 
        var dcpDir = Path.Combine(root, BundleDiscovery.DcpDirectoryName);
        Directory.CreateDirectory(dcpDir);
        File.WriteAllText(BundleDiscovery.GetDcpExecutablePath(dcpDir), "dcp");
    }
}