// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using Microsoft.DotNet.XUnitExtensions;
using Xunit;
namespace Aspire.Acquisition.Tests.Scripts;
/// <summary>
/// Tier-1 unit tests for individual functions in the release bash script (get-aspire-cli.sh).
/// Tests URL construction, quality mapping, checksum validation, and archive extraction.
/// </summary>
[SkipOnPlatform(TestPlatforms.Windows, "Bash script tests require bash shell")]
public class ReleaseScriptFunctionTests(ITestOutputHelper testOutput)
{
private static readonly string s_releaseScript = ScriptPaths.ReleaseShell;
private readonly ITestOutputHelper _testOutput = testOutput;
#region map_quality_to_channel
[Theory]
[InlineData("release", "stable")]
[InlineData("staging", "staging")]
[InlineData("dev", "daily")]
public async Task MapQualityToChannel_KnownQualities_ReturnsMappedChannel(string quality, string expectedChannel)
{
using var env = new TestEnvironment();
using var cmd = new ScriptFunctionCommand(
s_releaseScript,
$"map_quality_to_channel '{quality}'",
env,
_testOutput);
var result = await cmd.ExecuteAsync();
result.EnsureSuccessful();
Assert.Equal(expectedChannel, result.Output.Trim());
}
[Fact]
public async Task MapQualityToChannel_UnknownQuality_ReturnsAsIs()
{
using var env = new TestEnvironment();
using var cmd = new ScriptFunctionCommand(
s_releaseScript,
"map_quality_to_channel 'custom-channel'",
env,
_testOutput);
var result = await cmd.ExecuteAsync();
result.EnsureSuccessful();
Assert.Equal("custom-channel", result.Output.Trim());
}
#endregion
#region construct_aspire_cli_url
[Theory]
[InlineData("release", "linux-x64", "tar.gz", "https://aka.ms/dotnet/9/aspire/ga/daily/aspire-cli-linux-x64.tar.gz")]
[InlineData("dev", "linux-x64", "tar.gz", "https://aka.ms/dotnet/9/aspire/daily/aspire-cli-linux-x64.tar.gz")]
[InlineData("staging", "linux-x64", "tar.gz", "https://aka.ms/dotnet/9/aspire/rc/daily/aspire-cli-linux-x64.tar.gz")]
[InlineData("release", "osx-arm64", "tar.gz", "https://aka.ms/dotnet/9/aspire/ga/daily/aspire-cli-osx-arm64.tar.gz")]
[InlineData("release", "win-x64", "zip", "https://aka.ms/dotnet/9/aspire/ga/daily/aspire-cli-win-x64.zip")]
public async Task ConstructAspireCliUrl_NoVersion_ReturnsAkaMsUrl(
string quality, string rid, string ext, string expectedUrl)
{
using var env = new TestEnvironment();
using var cmd = new ScriptFunctionCommand(
s_releaseScript,
$"construct_aspire_cli_url '' '{quality}' '{rid}' '{ext}'",
env,
_testOutput);
var result = await cmd.ExecuteAsync();
result.EnsureSuccessful();
Assert.Equal(expectedUrl, result.Output.Trim());
}
[Theory]
[InlineData("release", "linux-x64", "tar.gz", "https://aka.ms/dotnet/9/aspire/ga/daily/aspire-cli-linux-x64.tar.gz.sha512")]
[InlineData("dev", "osx-arm64", "tar.gz", "https://aka.ms/dotnet/9/aspire/daily/aspire-cli-osx-arm64.tar.gz.sha512")]
public async Task ConstructAspireCliUrl_NoVersionWithChecksum_ReturnsChecksumUrl(
string quality, string rid, string ext, string expectedUrl)
{
using var env = new TestEnvironment();
using var cmd = new ScriptFunctionCommand(
s_releaseScript,
$"construct_aspire_cli_url '' '{quality}' '{rid}' '{ext}' 'true'",
env,
_testOutput);
var result = await cmd.ExecuteAsync();
result.EnsureSuccessful();
Assert.Equal(expectedUrl, result.Output.Trim());
}
[Theory]
[InlineData("13.3.5", "linux-x64", "tar.gz", "https://github.com/microsoft/aspire/releases/download/v13.3.5/aspire-cli-linux-x64-13.3.5.tar.gz")]
[InlineData("v13.3.5", "win-x64", "zip", "https://github.com/microsoft/aspire/releases/download/v13.3.5/aspire-cli-win-x64-13.3.5.zip")]
public async Task ConstructAspireCliUrl_WithStableVersion_ReturnsGitHubReleaseUrl(
string version, string rid, string ext, string expectedUrl)
{
using var env = new TestEnvironment();
using var cmd = new ScriptFunctionCommand(
s_releaseScript,
$"construct_aspire_cli_url '{version}' 'release' '{rid}' '{ext}'",
env,
_testOutput);
var result = await cmd.ExecuteAsync();
result.EnsureSuccessful();
Assert.Equal(expectedUrl, result.Output.Trim());
}
[Theory]
[InlineData("13.3.5", "linux-x64", "tar.gz", "https://github.com/microsoft/aspire/releases/download/v13.3.5/aspire-cli-linux-x64-13.3.5.tar.gz.sha512")]
[InlineData("v13.3.5", "win-x64", "zip", "https://github.com/microsoft/aspire/releases/download/v13.3.5/aspire-cli-win-x64-13.3.5.zip.sha512")]
public async Task ConstructAspireCliUrl_WithStableVersionAndChecksum_ReturnsGitHubReleaseChecksumUrl(
string version, string rid, string ext, string expectedUrl)
{
using var env = new TestEnvironment();
using var cmd = new ScriptFunctionCommand(
s_releaseScript,
$"construct_aspire_cli_url '{version}' 'release' '{rid}' '{ext}' 'true'",
env,
_testOutput);
var result = await cmd.ExecuteAsync();
result.EnsureSuccessful();
Assert.Equal(expectedUrl, result.Output.Trim());
}
[Theory]
[InlineData("13.3.0-dev")]
[InlineData("13.3.0-preview.1.25366.3")]
[InlineData("13.3.0-local")]
public async Task ConstructAspireCliUrl_WithPrereleaseVersion_ReturnsCiDotNetUrl(string version)
{
using var env = new TestEnvironment();
using var cmd = new ScriptFunctionCommand(
s_releaseScript,
$"construct_aspire_cli_url '{version}' 'release' 'linux-x64' 'tar.gz'",
env,
_testOutput);
var result = await cmd.ExecuteAsync();
result.EnsureSuccessful();
Assert.Equal($"https://ci.dot.net/public/aspire/{version}/aspire-cli-linux-x64-{version}.tar.gz", result.Output.Trim());
}
[Theory]
[InlineData("13.3.0-dev")]
[InlineData("13.3.0-preview.1.25366.3")]
[InlineData("13.3.0-local")]
public async Task ConstructAspireCliUrl_WithPrereleaseVersionAndChecksum_ReturnsCiDotNetChecksumUrl(string version)
{
using var env = new TestEnvironment();
using var cmd = new ScriptFunctionCommand(
s_releaseScript,
$"construct_aspire_cli_url '{version}' 'release' 'linux-x64' 'tar.gz' 'true'",
env,
_testOutput);
var result = await cmd.ExecuteAsync();
result.EnsureSuccessful();
var url = result.Output.Trim();
Assert.Contains("ci.dot.net/public-checksums/aspire", url);
Assert.Contains(version, url);
Assert.EndsWith(".sha512", url);
}
[Fact]
public async Task ConstructAspireCliUrl_UnsupportedQualityNoVersion_ReturnsError()
{
using var env = new TestEnvironment();
using var cmd = new ScriptFunctionCommand(
s_releaseScript,
"construct_aspire_cli_url '' 'invalid' 'linux-x64' 'tar.gz'",
env,
_testOutput);
var result = await cmd.ExecuteAsync();
Assert.NotEqual(0, result.ExitCode);
Assert.Contains("Unsupported", result.Output, StringComparison.OrdinalIgnoreCase);
}
#endregion
#region get_download_descriptor
[Theory]
[InlineData("https://aka.ms/dotnet/9/aspire/ga/daily/aspire-cli-linux-x64.tar.gz", "the stable channel", "aspire-cli-linux-x64.tar.gz from the stable channel")]
[InlineData("https://aka.ms/dotnet/9/aspire/daily/aspire-cli-linux-x64.tar.gz", "the daily channel", "aspire-cli-linux-x64.tar.gz from the daily channel")]
[InlineData("https://aka.ms/dotnet/9/aspire/rc/daily/aspire-cli-linux-x64.tar.gz", "the staging channel", "aspire-cli-linux-x64.tar.gz from the staging channel")]
public async Task GetDownloadDescriptor_WithSource_ReturnsFriendlyChannelSource(string url, string source, string expectedDescriptor)
{
using var env = new TestEnvironment();
using var cmd = new ScriptFunctionCommand(
s_releaseScript,
$"get_download_descriptor '{url}' '{source}'",
env,
_testOutput);
var result = await cmd.ExecuteAsync();
result.EnsureSuccessful();
var descriptor = result.Output.Trim();
Assert.Equal(expectedDescriptor, descriptor);
Assert.DoesNotContain("dotnet", descriptor, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("ga/daily", descriptor, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("rc/daily", descriptor, StringComparison.OrdinalIgnoreCase);
}
#endregion
#region validate_content_type
[Fact]
public async Task ValidateContentType_GitHubReleaseRedirectWithHtmlIntermediateResponse_Succeeds()
{
using var env = new TestEnvironment();
using var cmd = new ScriptFunctionCommand(
s_releaseScript,
"""
secure_curl() {
cat <<'HEADERS'
HTTP/2 302
content-type: text/html; charset=utf-8
HTTP/2 200
content-type: application/octet-stream
HEADERS
}
validate_content_type 'https://github.com/microsoft/aspire/releases/download/v13.3.5/aspire-cli-linux-x64-13.3.5.tar.gz'
""",
env,
_testOutput);
var result = await cmd.ExecuteAsync();
result.EnsureSuccessful();
}
#endregion
#region validate_checksum
[Fact]
public async Task ValidateChecksum_MatchingChecksum_Succeeds()
{
using var env = new TestEnvironment();
var archive = await FakeArchiveHelper.CreateFakeArchiveAsync(env.TempDirectory);
using var cmd = new ScriptFunctionCommand(
s_releaseScript,
$"validate_checksum '{archive.ArchivePath}' '{archive.ChecksumPath}'",
env,
_testOutput);
var result = await cmd.ExecuteAsync();
result.EnsureSuccessful();
}
[Fact]
public async Task ValidateChecksum_MismatchedChecksum_Fails()
{
using var env = new TestEnvironment();
var archive = await FakeArchiveHelper.CreateFakeArchiveWithBadChecksumAsync(env.TempDirectory);
using var cmd = new ScriptFunctionCommand(
s_releaseScript,
$"validate_checksum '{archive.ArchivePath}' '{archive.ChecksumPath}'",
env,
_testOutput);
var result = await cmd.ExecuteAsync();
Assert.NotEqual(0, result.ExitCode);
Assert.Contains("Checksum validation failed", result.Output, StringComparison.OrdinalIgnoreCase);
}
#endregion
#region install_archive
[Fact]
public async Task InstallArchive_TarGz_ExtractsToDestination()
{
using var env = new TestEnvironment();
var archive = await FakeArchiveHelper.CreateFakeArchiveAsync(env.TempDirectory, "linux-x64");
var destPath = Path.Combine(env.TempDirectory, "install-dest");
using var cmd = new ScriptFunctionCommand(
s_releaseScript,
$"install_archive '{archive.ArchivePath}' '{destPath}' 'linux'",
env,
_testOutput);
var result = await cmd.ExecuteAsync();
result.EnsureSuccessful();
Assert.True(File.Exists(Path.Combine(destPath, "aspire")),
"Extracted binary should exist at destination");
}
[Fact]
public async Task InstallArchive_Zip_ExtractsToDestination()
{
using var env = new TestEnvironment();
var archive = await FakeArchiveHelper.CreateFakeArchiveAsync(env.TempDirectory, "win-x64");
var destPath = Path.Combine(env.TempDirectory, "install-dest-zip");
using var cmd = new ScriptFunctionCommand(
s_releaseScript,
$"install_archive '{archive.ArchivePath}' '{destPath}' 'win'",
env,
_testOutput);
var result = await cmd.ExecuteAsync();
result.EnsureSuccessful();
Assert.True(File.Exists(Path.Combine(destPath, "aspire.exe")),
"Extracted binary should exist at destination");
}
#endregion
#region detect_os
[Fact]
public async Task DetectOs_ReturnsKnownPlatform()
{
using var env = new TestEnvironment();
using var cmd = new ScriptFunctionCommand(
s_releaseScript,
"detect_os",
env,
_testOutput);
var result = await cmd.ExecuteAsync();
result.EnsureSuccessful();
var os = result.Output.Trim();
Assert.True(
os is "osx" or "linux" or "linux-musl" or "win",
$"Expected a recognized OS, got: '{os}'");
}
#endregion
#region detect_architecture
[Fact]
public async Task DetectArchitecture_ReturnsKnownArch()
{
using var env = new TestEnvironment();
using var cmd = new ScriptFunctionCommand(
s_releaseScript,
"detect_architecture",
env,
_testOutput);
var result = await cmd.ExecuteAsync();
result.EnsureSuccessful();
var arch = result.Output.Trim();
Assert.True(
arch is "x64" or "arm64",
$"Expected x64 or arm64, got: '{arch}'");
}
#endregion
#region get_cli_architecture_from_architecture
[Theory]
[InlineData("amd64", "x64")]
[InlineData("x64", "x64")]
[InlineData("arm64", "arm64")]
[InlineData("AMD64", "x64")]
[InlineData("X64", "x64")]
[InlineData("ARM64", "arm64")]
public async Task GetCliArchitectureFromArchitecture_NormalizesArchNames(string input, string expected)
{
using var env = new TestEnvironment();
using var cmd = new ScriptFunctionCommand(
s_releaseScript,
$"get_cli_architecture_from_architecture '{input}'",
env,
_testOutput);
var result = await cmd.ExecuteAsync();
result.EnsureSuccessful();
Assert.Equal(expected, result.Output.Trim());
}
[Fact]
public async Task GetCliArchitectureFromArchitecture_UnsupportedArch_Fails()
{
using var env = new TestEnvironment();
using var cmd = new ScriptFunctionCommand(
s_releaseScript,
"get_cli_architecture_from_architecture 'mips'",
env,
_testOutput);
var result = await cmd.ExecuteAsync();
Assert.NotEqual(0, result.ExitCode);
Assert.Contains("not supported", result.Output, StringComparison.OrdinalIgnoreCase);
}
#endregion
#region setup_cli_bundle
[Fact]
public async Task SetupCliBundle_InvokesSetupAndPropagatesFailure()
{
using var env = new TestEnvironment();
var cliPath = Path.Combine(env.TempDirectory, "aspire");
var capturePath = Path.Combine(env.TempDirectory, "setup-args.txt");
await File.WriteAllTextAsync(cliPath, ($$"""
#!/bin/sh
printf '%s\n' "$@" > "{{capturePath}}"
exit 23
""").ReplaceLineEndings("\n"));
FileHelper.MakeExecutable(cliPath);
using var cmd = new ScriptFunctionCommand(
s_releaseScript,
$"INSTALLED_CLI_PATH='{cliPath}'; INSTALLED_CLI_OS=\"$(detect_os)\"; INSTALLED_CLI_ARCH=\"$(get_cli_architecture_from_architecture '<auto>')\"; DRY_RUN=false; setup_cli_bundle",
env,
_testOutput);
var result = await cmd.ExecuteAsync();
Assert.NotEqual(0, result.ExitCode);
Assert.Equal("setup", (await File.ReadAllTextAsync(capturePath)).Trim());
Assert.Contains("bundle setup failed", result.Output, StringComparison.OrdinalIgnoreCase);
}
#endregion
#region construct_aspire_extension_url
[Theory]
[InlineData("dev", "https://aka.ms/dotnet/9/aspire/daily/aspire-vscode.vsix.zip")]
[InlineData("staging", "https://aka.ms/dotnet/9/aspire/rc/daily/aspire-vscode.vsix.zip")]
[InlineData("release", "https://aka.ms/dotnet/9/aspire/ga/daily/aspire-vscode.vsix.zip")]
public async Task ConstructAspireExtensionUrl_NoVersion_ReturnsAkaMsUrl(string quality, string expectedUrl)
{
using var env = new TestEnvironment();
using var cmd = new ScriptFunctionCommand(
s_releaseScript,
$"construct_aspire_extension_url '' '{quality}'",
env,
_testOutput);
var result = await cmd.ExecuteAsync();
result.EnsureSuccessful();
Assert.Equal(expectedUrl, result.Output.Trim());
}
[Fact]
public async Task ConstructAspireExtensionUrl_WithVersion_ReturnsCiDotNetUrl()
{
using var env = new TestEnvironment();
var version = "13.2.0-preview.1.25366.3";
using var cmd = new ScriptFunctionCommand(
s_releaseScript,
$"construct_aspire_extension_url '{version}' 'dev'",
env,
_testOutput);
var result = await cmd.ExecuteAsync();
result.EnsureSuccessful();
var url = result.Output.Trim();
Assert.Contains("ci.dot.net/public/aspire", url);
Assert.Contains(version, url);
Assert.Contains("vsix.zip", url);
}
#endregion
#region add_to_shell_profile
[Fact]
public async Task AddToShellProfile_Bash_AddsPathToBashrc()
{
using var env = new TestEnvironment();
var installPath = Path.Combine(env.MockHome, ".aspire", "bin");
Directory.CreateDirectory(installPath);
File.WriteAllText(Path.Combine(env.MockHome, ".bashrc"), "# existing config\n");
using var cmd = new ScriptFunctionCommand(
s_releaseScript,
$"VERBOSE=true; DRY_RUN=false; add_to_shell_profile '{installPath}' '$HOME/.aspire/bin'",
env,
_testOutput);
cmd.WithEnvironmentVariable("SHELL", "/bin/bash");
var result = await cmd.ExecuteAsync();
result.EnsureSuccessful();
var bashrcContent = File.ReadAllText(Path.Combine(env.MockHome, ".bashrc"));
Assert.Contains("# Added by get-aspire-cli.sh", bashrcContent);
Assert.Contains("export PATH=\"$HOME/.aspire/bin:$PATH\"", bashrcContent);
}
[Fact]
public async Task AddToShellProfile_Zsh_AddsPathToZshrc()
{
using var env = new TestEnvironment();
var installPath = Path.Combine(env.MockHome, ".aspire", "bin");
Directory.CreateDirectory(installPath);
File.WriteAllText(Path.Combine(env.MockHome, ".zshrc"), "# existing config\n");
using var cmd = new ScriptFunctionCommand(
s_releaseScript,
$"VERBOSE=true; DRY_RUN=false; add_to_shell_profile '{installPath}' '$HOME/.aspire/bin'",
env,
_testOutput);
cmd.WithEnvironmentVariable("SHELL", "/bin/zsh");
var result = await cmd.ExecuteAsync();
result.EnsureSuccessful();
var zshrcContent = File.ReadAllText(Path.Combine(env.MockHome, ".zshrc"));
Assert.Contains("# Added by get-aspire-cli.sh", zshrcContent);
Assert.Contains("export PATH=\"$HOME/.aspire/bin:$PATH\"", zshrcContent);
}
[Fact]
public async Task AddToShellProfile_AlreadyInPath_DoesNotDuplicate()
{
using var env = new TestEnvironment();
var installPath = Path.Combine(env.MockHome, ".aspire", "bin");
Directory.CreateDirectory(installPath);
var exportLine = "export PATH=\"$HOME/.aspire/bin:$PATH\"";
File.WriteAllText(
Path.Combine(env.MockHome, ".bashrc"),
"# existing config\n\n# Added by get-aspire-cli.sh\n" + exportLine + "\n");
using var cmd = new ScriptFunctionCommand(
s_releaseScript,
$"VERBOSE=true; DRY_RUN=false; add_to_shell_profile '{installPath}' '$HOME/.aspire/bin'",
env,
_testOutput);
cmd.WithEnvironmentVariable("SHELL", "/bin/bash");
var result = await cmd.ExecuteAsync();
result.EnsureSuccessful();
var bashrcContent = File.ReadAllText(Path.Combine(env.MockHome, ".bashrc"));
var exportCount = bashrcContent.Split("export PATH=").Length - 1;
Assert.Equal(1, exportCount);
}
[Fact]
public async Task AddToShellProfile_NoConfigFile_DryRunShowsMessage()
{
using var env = new TestEnvironment();
var installPath = Path.Combine(env.MockHome, ".aspire", "bin");
Directory.CreateDirectory(installPath);
using var cmd = new ScriptFunctionCommand(
s_releaseScript,
$"VERBOSE=true; DRY_RUN=true; add_to_shell_profile '{installPath}' '$HOME/.aspire/bin'",
env,
_testOutput);
cmd.WithEnvironmentVariable("SHELL", "/bin/bash");
var result = await cmd.ExecuteAsync();
result.EnsureSuccessful();
Assert.Contains("Would create config file", result.Output, StringComparison.OrdinalIgnoreCase);
}
#endregion
#region validate_content_type
[Fact]
public async Task ValidateContentType_HtmlResponse_Fails()
{
using var env = new TestEnvironment();
var mockBinDir = Path.Combine(env.TempDirectory, "mock-bin");
Directory.CreateDirectory(mockBinDir);
var mockCurl = Path.Combine(mockBinDir, "curl");
await File.WriteAllTextAsync(mockCurl,
"""
#!/bin/bash
# Mock curl that returns HTML content-type headers for HEAD requests
for arg in "$@"; do
if [ "$arg" = "--head" ] || [ "$arg" = "-I" ]; then
echo "HTTP/1.1 200 OK"
echo "content-type: text/html; charset=utf-8"
echo ""
exit 0
fi
done
exit 0
""");
FileHelper.MakeExecutable(mockCurl);
using var cmd = new ScriptFunctionCommand(
s_releaseScript,
"VERBOSE=true; validate_content_type 'http://example.com/file.tar.gz'",
env,
_testOutput);
cmd.WithEnvironmentVariable("PATH", $"{mockBinDir}:{Environment.GetEnvironmentVariable("PATH")}");
var result = await cmd.ExecuteAsync();
Assert.NotEqual(0, result.ExitCode);
Assert.Contains("HTML", result.Output, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task ValidateContentType_BinaryResponse_Succeeds()
{
using var env = new TestEnvironment();
var mockBinDir = Path.Combine(env.TempDirectory, "mock-bin");
Directory.CreateDirectory(mockBinDir);
var mockCurl = Path.Combine(mockBinDir, "curl");
await File.WriteAllTextAsync(mockCurl,
"""
#!/bin/bash
# Mock curl that returns binary content-type headers for HEAD requests
for arg in "$@"; do
if [ "$arg" = "--head" ] || [ "$arg" = "-I" ]; then
echo "HTTP/1.1 200 OK"
echo "content-type: application/octet-stream"
echo ""
exit 0
fi
done
exit 0
""");
FileHelper.MakeExecutable(mockCurl);
using var cmd = new ScriptFunctionCommand(
s_releaseScript,
"VERBOSE=true; validate_content_type 'http://example.com/file.tar.gz'",
env,
_testOutput);
cmd.WithEnvironmentVariable("PATH", $"{mockBinDir}:{Environment.GetEnvironmentVariable("PATH")}");
var result = await cmd.ExecuteAsync();
result.EnsureSuccessful();
}
#endregion
}