// 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");
}
}