// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using System.Security.Cryptography;
using Aspire.TestUtilities;
using Microsoft.DotNet.XUnitExtensions;
using Xunit;
namespace Aspire.Acquisition.Tests.Scripts;
/// <summary>
/// Tests for package-manager installer modes on get-aspire-cli-pr.{sh,ps1}.
/// </summary>
public class PRScriptInstallerModeTests(ITestOutputHelper testOutput)
{
private readonly ITestOutputHelper _testOutput = testOutput;
private async Task<ScriptToolCommand> CreateBashCommandWithMockGhAsync(TestEnvironment env)
{
var mockGhPath = await env.CreateMockGhScriptAsync(_testOutput);
var cmd = new ScriptToolCommand(ScriptPaths.PRShell, env, _testOutput);
cmd.WithEnvironmentVariable("PATH", $"{mockGhPath}{Path.PathSeparator}{Environment.GetEnvironmentVariable("PATH")}");
return cmd;
}
private async Task<ScriptToolCommand> CreatePsCommandWithMockGhAsync(TestEnvironment env)
{
var mockGhPath = await env.CreateMockGhScriptAsync(_testOutput);
var cmd = new ScriptToolCommand(ScriptPaths.PRPowerShell, env, _testOutput);
cmd.WithEnvironmentVariable("PATH", $"{mockGhPath}{Path.PathSeparator}{Environment.GetEnvironmentVariable("PATH")}");
return cmd;
}
private static async Task<string> CreateHomebrewInstallerArtifactAsync(string root)
{
Directory.CreateDirectory(root);
await File.WriteAllTextAsync(Path.Combine(root, "aspire.rb"), "cask \"aspire\" do\n version \"13.3.0\"\nend\n");
await File.WriteAllTextAsync(Path.Combine(root, "dogfood.sh"), "#!/usr/bin/env bash\nexit 0\n");
await FakeArchiveHelper.CreateFakeNupkgAsync(root, "Aspire.Cli", "13.3.0-pr.1234.abc");
await FakeArchiveHelper.CreateFakeNupkgAsync(root, "Aspire.Hosting", "13.3.0-pr.1234.abc");
return root;
}
private static async Task<string> CreateWinGetInstallerArtifactAsync(string root)
{
Directory.CreateDirectory(root);
await File.WriteAllTextAsync(Path.Combine(root, "Microsoft.Aspire.installer.yaml"), "PackageIdentifier: Microsoft.Aspire\nPackageVersion: 13.3.0\nInstallers: []\n");
await File.WriteAllTextAsync(Path.Combine(root, "dogfood.ps1"), "exit 0\n");
await FakeArchiveHelper.CreateFakeNupkgAsync(root, "Aspire.Cli", "13.3.0-pr.1234.abc");
await FakeArchiveHelper.CreateFakeNupkgAsync(root, "Aspire.Hosting", "13.3.0-pr.1234.abc");
return root;
}
// Builds the realistic PR-channel layout that prepare-manifest-artifact.ps1 produces:
// an installer.yaml with two Installers entries (https:// URLs and a placeholder
// SHA256 of all zeros) co-located with dogfood.ps1, plus fake aspire-cli-win-*
// archives. The archives are nested under <archive-root>/Debug/Shipping/ to mirror
// what `gh run download cli-native-archives-<rid>` produces — that artifact is
// uploaded with `path: artifacts/packages/**/aspire-cli*` in
// .github/workflows/build-cli-native-archives.yml, so the `**/` glob preserves the
// Debug/Shipping/ directory structure. dogfood.ps1's HTTP server is required to
// serve files regardless of how deep they are under -ArchiveRoot; a flat fixture
// hid that requirement and let a real-world 404 ship.
private static async Task<(string ManifestDir, string ArchiveRoot)> CreateWinGetPrChannelArtifactAsync(string root, string version = "13.3.0")
{
var manifestDir = Path.Combine(root, "installer-winget");
var archiveRoot = Path.Combine(root, "installer-native-archives");
// Archives live under Debug/Shipping/ when `gh run download` extracts the
// cli-native-archives-<rid> artifact — see comment above.
var archiveDir = Path.Combine(archiveRoot, "Debug", "Shipping");
Directory.CreateDirectory(manifestDir);
Directory.CreateDirectory(archiveDir);
var placeholder = new string('0', 64);
var installerYaml = $$"""
# yaml-language-server: $schema=https://aka.ms/winget-manifest.installer.1.10.0.schema.json
PackageIdentifier: Microsoft.Aspire
PackageVersion: "{{version}}"
InstallerType: zip
NestedInstallerType: portable
NestedInstallerFiles:
- RelativeFilePath: aspire.exe
PortableCommandAlias: aspire
Installers:
- Architecture: x64
InstallerUrl: https://ci.dot.net/public/aspire/{{version}}/aspire-cli-win-x64-{{version}}.zip
InstallerSha256: {{placeholder}}
- Architecture: arm64
InstallerUrl: https://ci.dot.net/public/aspire/{{version}}/aspire-cli-win-arm64-{{version}}.zip
InstallerSha256: {{placeholder}}
ManifestType: installer
ManifestVersion: 1.10.0
""";
await File.WriteAllTextAsync(Path.Combine(manifestDir, "Microsoft.Aspire.installer.yaml"), installerYaml);
await File.WriteAllTextAsync(Path.Combine(manifestDir, "Microsoft.Aspire.yaml"), $"PackageIdentifier: Microsoft.Aspire\nPackageVersion: {version}\nManifestType: version\nManifestVersion: 1.10.0\n");
await File.WriteAllTextAsync(Path.Combine(manifestDir, "Microsoft.Aspire.locale.en-US.yaml"), $"PackageIdentifier: Microsoft.Aspire\nPackageVersion: {version}\nPackageLocale: en-US\nManifestType: defaultLocale\nManifestVersion: 1.10.0\n");
await File.WriteAllTextAsync(Path.Combine(manifestDir, "dogfood.ps1"), "exit 0\n");
// Distinct fake bytes per RID so SHA256 differences flow into the mock install check.
// Real zips with a stub aspire.exe at the root, mirroring the contract real winget
// expects (NestedInstallerFiles[].RelativeFilePath must exist after extraction).
// The mock winget extracts these and checks the contract — see CreateMockWinGetBinAsync.
await WriteRealAspireWinGetZipAsync(Path.Combine(archiveDir, $"aspire-cli-win-x64-{version}.zip"), $"stub-x64-{version}");
await WriteRealAspireWinGetZipAsync(Path.Combine(archiveDir, $"aspire-cli-win-arm64-{version}.zip"), $"stub-arm64-{version}");
return (manifestDir, archiveRoot);
}
private static async Task<string> CreateMockHomebrewBinAsync(TestEnvironment env, int aspireExitCode)
{
var mockBinDir = Path.Combine(env.TempDirectory, "mock-homebrew-bin");
var brewRepository = Path.Combine(env.TempDirectory, "brew-repository");
var brewPrefix = Path.Combine(env.TempDirectory, "brew-prefix");
var brewLog = Path.Combine(env.TempDirectory, "brew.log");
Directory.CreateDirectory(mockBinDir);
Directory.CreateDirectory(brewRepository);
Directory.CreateDirectory(brewPrefix);
var brewPath = Path.Combine(mockBinDir, "brew");
await File.WriteAllTextAsync(brewPath, $$"""
#!/usr/bin/env bash
set -euo pipefail
echo "$*" >> "{{brewLog}}"
create_aspire() {
cat > "{{mockBinDir}}/aspire" <<'ASPIRE'
#!/usr/bin/env bash
echo "mock aspire failure"
exit {{aspireExitCode}}
ASPIRE
chmod +x "{{mockBinDir}}/aspire"
}
case "${1:-}" in
--repository)
echo "{{brewRepository}}"
exit 0
;;
--prefix)
echo "{{brewPrefix}}"
exit 0
;;
list)
exit 0
;;
tap-info)
exit 1
;;
tap-new)
tap="${@: -1}"
org="${tap%%/*}"
repo="${tap##*/}"
tap_root="{{brewRepository}}/Library/Taps/$org/homebrew-$repo"
mkdir -p "$tap_root/.github/workflows"
cat > "$tap_root/.github/workflows/tests.yml" <<'WORKFLOW'
jobs:
test:
options: --privileged
WORKFLOW
echo "# generated tap" > "$tap_root/README.md"
exit 0
;;
style|audit|info)
exit 0
;;
test-bot)
tap_root="{{brewRepository}}/Library/Taps/local/homebrew-aspire"
unexpected_file="$(find "$tap_root" -type f ! -path "$tap_root/Casks/aspire.rb" -print -quit)"
if [[ -n "$unexpected_file" ]]; then
echo "Homebrew-generated file reached tap syntax validation: $unexpected_file" >&2
exit 1
fi
exit 0
;;
install)
create_aspire
exit 0
;;
uninstall)
rm -f "{{mockBinDir}}/aspire"
exit 0
;;
untap)
exit 0
;;
esac
echo "unexpected brew command: $*" >&2
exit 1
""");
FileHelper.MakeExecutable(brewPath);
var curlPath = Path.Combine(mockBinDir, "curl");
await File.WriteAllTextAsync(curlPath, """
#!/usr/bin/env bash
printf '404'
exit 0
""");
FileHelper.MakeExecutable(curlPath);
return mockBinDir;
}
// Mock winget for Windows. Mirrors enough of real winget's behaviour that
// dogfood.ps1 regressions surface here:
// * `validate --manifest <dir>` and `install --manifest <dir>` scan every file
// in <dir> and reject non-yaml entries.
// * `validate` enforces the schema rule that InstallerUrl matches ^https?://.
// * `install` actually downloads each InstallerUrl over the wire (via
// Invoke-WebRequest) and verifies the SHA256 of the bytes it received against
// InstallerSha256. This catches both schemes real winget can't fetch (real
// winget uses WinINet's InternetOpenUrl which doesn't support file://) and
// stale InstallerSha256 entries — both bugs we have already shipped in PRs.
// * `install` honors the manifest-vs-archive contract: for InstallerType: zip
// it extracts the downloaded zip and requires every NestedInstallerFiles[]
// .RelativeFilePath to exist after extraction. Real winget fails at this step
// with an opaque error (observed 0x8A150001 with no diag-log line beyond
// "Started applying motw"); catching it here makes the failure attributable.
private static async Task<string> CreateMockWinGetBinAsync(TestEnvironment env, int aspireExitCode)
{
if (!OperatingSystem.IsWindows())
{
throw new InvalidOperationException("Mock winget is Windows-only (winget itself is Windows-only).");
}
var mockBinDir = Path.Combine(env.TempDirectory, "mock-winget-bin");
var wingetLog = Path.Combine(env.TempDirectory, "winget.log");
Directory.CreateDirectory(mockBinDir);
// PowerShell implementation of the mock. Kept in a separate file so it can be
// edited as PowerShell rather than escaped through a .cmd shim.
var implPath = Path.Combine(mockBinDir, "winget-impl.ps1");
await File.WriteAllTextAsync(implPath, $$"""
param([Parameter(ValueFromRemainingArguments = $true)][string[]]$Args)
$ErrorActionPreference = 'Stop'
$cmd = if ($Args.Count -ge 1) { $Args[0] } else { '' }
$rest = if ($Args.Count -ge 2) { $Args[1..($Args.Count - 1)] } else { @() }
Add-Content -LiteralPath '{{wingetLog.Replace("\\", "\\\\")}}' -Value ($Args -join ' ')
switch ($cmd) {
'list' { exit 1 }
'settings' { exit 0 }
'uninstall' { exit 0 }
{ $_ -in 'validate','install' } { }
default {
Write-Error "Mock winget: unexpected command: $cmd"
exit 1
}
}
$manifestDir = $null
for ($i = 0; $i -lt $rest.Count - 1; $i++) {
if ($rest[$i] -eq '--manifest') { $manifestDir = $rest[$i + 1]; break }
}
if (-not $manifestDir -or -not (Test-Path -LiteralPath $manifestDir)) {
Write-Error "Mock winget: --manifest <dir> required"
exit 1
}
# Real winget treats every file in the manifest dir as a manifest and rejects
# non-yaml entries (e.g. "The manifest does not contain a valid root.
# File: dogfood.ps1"). Mirror that so manifest-staging regressions in
# dogfood.ps1 are caught here.
foreach ($f in Get-ChildItem -LiteralPath $manifestDir -File) {
if ($f.Extension -notin '.yaml', '.yml') {
Write-Error "Mock winget: non-yaml file in manifest dir: $($f.Name)"
exit 1
}
}
$installerYaml = Get-ChildItem -LiteralPath $manifestDir -File -Filter '*.installer.yaml' | Select-Object -First 1
if (-not $installerYaml) { exit 0 }
$entries = @()
$current = $null
$nestedFiles = @()
foreach ($line in Get-Content -LiteralPath $installerYaml.FullName) {
if ($line -match '^\s*InstallerUrl:\s*(\S+)\s*$') {
if ($current) { $entries += [pscustomobject]$current }
$current = @{ Url = $Matches[1]; Sha = $null }
} elseif ($line -match '^\s*InstallerSha256:\s*(\S+)\s*$' -and $current) {
$current.Sha = $Matches[1].ToUpperInvariant()
} elseif ($line -match '^\s*-?\s*RelativeFilePath:\s*(\S+)\s*$') {
# NestedInstallerFiles is shared across all Installers in the manifest,
# not per-entry. Collect them once and apply to every downloaded zip.
$nestedFiles += $Matches[1]
}
}
if ($current) { $entries += [pscustomobject]$current }
foreach ($e in $entries) {
# Schema rule: InstallerUrl must match ^https?://. Real winget enforces
# this in `validate`; in `install`, WinINet's InternetOpenUrl() rejects
# any other scheme (file://, ftp://, etc) with HRESULT 0x8007007b. Mock
# both layers so PR-script regressions can't sneak past tests.
if ($e.Url -notmatch '^(?i)https?://') {
Write-Error "Mock winget ${cmd}: InstallerUrl '$($e.Url)' does not match ^https?://"
exit 1
}
}
if ($cmd -eq 'install') {
foreach ($e in $entries) {
$tmp = New-TemporaryFile
$renamedZip = $null
$extractDir = $null
try {
try {
Invoke-WebRequest -Uri $e.Url -OutFile $tmp.FullName -UseBasicParsing -TimeoutSec 30 | Out-Null
} catch {
Write-Error "Mock winget install: failed to download $($e.Url): $_"
exit 1
}
$actual = (Get-FileHash -LiteralPath $tmp.FullName -Algorithm SHA256).Hash.ToUpperInvariant()
if ($e.Sha -and $actual -ne $e.Sha) {
Write-Error "Mock winget install: InstallerSha256 mismatch for $($e.Url) (expected $($e.Sha), got $actual)"
exit 1
}
# Manifest-vs-archive contract: for InstallerType: zip with
# NestedInstallerType: portable, real winget extracts the zip
# and requires every NestedInstallerFiles[].RelativeFilePath to
# exist after extraction. If it doesn't, real winget fails at
# install time with an opaque error (we observed 0x8A150001
# with no diag-log line beyond "Started applying motw"). Catch
# the contract violation here so the failure is attributable.
if ($nestedFiles.Count -gt 0) {
$renamedZip = "$($tmp.FullName).zip"
Move-Item -LiteralPath $tmp.FullName -Destination $renamedZip
$extractDir = Join-Path ([System.IO.Path]::GetTempPath()) ([guid]::NewGuid().ToString('N'))
New-Item -ItemType Directory -Force -Path $extractDir | Out-Null
try {
Expand-Archive -LiteralPath $renamedZip -DestinationPath $extractDir -Force
} catch {
Write-Error "Mock winget install: failed to extract $($e.Url): $_"
exit 1
}
foreach ($rel in $nestedFiles) {
# RelativeFilePath uses '/' as separator in YAML even on Windows.
$relNative = $rel -replace '/', [System.IO.Path]::DirectorySeparatorChar
$expected = Join-Path $extractDir $relNative
if (-not (Test-Path -LiteralPath $expected)) {
Write-Error "Mock winget install: NestedInstallerFiles entry '$rel' is missing from extracted archive $($e.Url)"
exit 1
}
}
}
} finally {
if ($extractDir -and (Test-Path -LiteralPath $extractDir)) {
Remove-Item -LiteralPath $extractDir -Recurse -Force -ErrorAction SilentlyContinue
}
if ($renamedZip -and (Test-Path -LiteralPath $renamedZip)) {
Remove-Item -LiteralPath $renamedZip -Force -ErrorAction SilentlyContinue
}
if (Test-Path -LiteralPath $tmp.FullName) {
Remove-Item -LiteralPath $tmp.FullName -ErrorAction SilentlyContinue
}
}
}
}
exit 0
""");
// .cmd shim so the mock is invokable as 'winget' from PATH (PowerShell honors
// PATHEXT and resolves 'winget' to winget.cmd before scanning system paths).
var wingetCmd = Path.Combine(mockBinDir, "winget.cmd");
await File.WriteAllTextAsync(wingetCmd, """
@echo off
pwsh -NoLogo -NoProfile -ExecutionPolicy Bypass -File "%~dp0winget-impl.ps1" %*
""");
var aspireCmd = Path.Combine(mockBinDir, "aspire.cmd");
await File.WriteAllTextAsync(aspireCmd, $$"""
@echo off
echo mock aspire {{(aspireExitCode == 0 ? "version" : "failure")}}
exit /b {{aspireExitCode}}
""");
return mockBinDir;
}
private static async Task CreateFakeHomebrewArchivesAsync(string root)
{
Directory.CreateDirectory(root);
await File.WriteAllTextAsync(Path.Combine(root, "aspire-cli-osx-arm64-13.3.0.tar.gz"), "fake arm64 archive");
await File.WriteAllTextAsync(Path.Combine(root, "aspire-cli-osx-x64-13.3.0.tar.gz"), "fake x64 archive");
}
private static async Task CreateFakeWinGetArchivesAsync(string root)
{
var archiveDir = Path.Combine(root, "Debug", "Shipping");
Directory.CreateDirectory(archiveDir);
// Real zips with a stub aspire.exe at the root so prepare-manifest-artifact.ps1
// sees a valid archive layout and downstream contract tests (extraction +
// NestedInstallerFiles lookup) work against the same fixture.
await WriteRealAspireWinGetZipAsync(Path.Combine(archiveDir, "aspire-cli-win-x64-13.3.0.zip"), "stub-x64-13.3.0");
await WriteRealAspireWinGetZipAsync(Path.Combine(archiveDir, "aspire-cli-win-arm64-13.3.0.zip"), "stub-arm64-13.3.0");
}
private static async Task<string> GetSha256HexAsync(string path)
{
var bytes = await File.ReadAllBytesAsync(path);
return Convert.ToHexString(SHA256.HashData(bytes));
}
// Writes a real .zip containing a stub `aspire.exe` at the root, matching the
// top-level layout of the real aspire-cli-win-*.zip artifacts. The bytes are
// tiny stubs — winget and our tests only care about the extraction layout
// (NestedInstallerFiles[].RelativeFilePath = aspire.exe), not signed binary
// content. The `aspireExeContent` argument is hashed into the zip so callers
// can produce zips with distinct SHA256 hashes per RID.
private static async Task WriteRealAspireWinGetZipAsync(string path, string aspireExeContent)
{
var parent = Path.GetDirectoryName(path);
if (!string.IsNullOrEmpty(parent))
{
Directory.CreateDirectory(parent);
}
if (File.Exists(path))
{
File.Delete(path);
}
await using var fs = File.Create(path);
using var archive = new System.IO.Compression.ZipArchive(fs, System.IO.Compression.ZipArchiveMode.Create);
var entry = archive.CreateEntry("aspire.exe", System.IO.Compression.CompressionLevel.NoCompression);
await using var s = entry.Open();
await s.WriteAsync(System.Text.Encoding.UTF8.GetBytes(aspireExeContent));
}
// Parses NestedInstallerFiles[].RelativeFilePath entries from an installer manifest
// YAML. Uses a regex instead of a YAML parser to avoid pulling in YamlDotNet just
// for the test project (we already do regex parsing of InstallerUrl/InstallerSha256
// elsewhere in this file and in the mock winget script).
private static IReadOnlyList<string> ParseRelativeFilePaths(string installerYaml)
{
var paths = new List<string>();
foreach (var rawLine in installerYaml.Split('\n'))
{
var line = rawLine.TrimEnd('\r');
var m = System.Text.RegularExpressions.Regex.Match(line, @"^\s*-?\s*RelativeFilePath:\s*(\S+)\s*$");
if (m.Success)
{
paths.Add(m.Groups[1].Value);
}
}
return paths;
}
[Fact]
[SkipOnPlatform(TestPlatforms.Windows, "Bash script tests require bash shell")]
public async Task Bash_Help_DescribesInstallerModes()
{
using var env = new TestEnvironment();
using var cmd = new ScriptToolCommand(ScriptPaths.PRShell, env, _testOutput);
var result = await cmd.ExecuteAsync("--help");
result.EnsureSuccessful();
Assert.Contains("winget", result.Output);
Assert.Contains("homebrew", result.Output);
}
[Fact]
[SkipOnPlatform(TestPlatforms.Windows, "Bash script tests require bash shell")]
public async Task Bash_WinGetMode_PrDryRun_DownloadsManifestAndNativeArchives()
{
using var env = new TestEnvironment();
using var cmd = await CreateBashCommandWithMockGhAsync(env);
var result = await cmd.ExecuteAsync(
"12345",
"--install-mode", "winget",
"--force",
"--dry-run",
"--skip-extension",
"--verbose");
result.EnsureSuccessful();
Assert.Contains("winget-manifests-prerelease", result.Output);
Assert.Contains("cli-native-archives-win-x64", result.Output);
Assert.Contains("cli-native-archives-win-arm64", result.Output);
Assert.Contains("-ArchiveRoot", result.Output);
Assert.Contains("-Force", result.Output);
Assert.Contains("built-nugets", result.Output);
Assert.DoesNotContain("Add to your shell profile", result.Output);
Assert.DoesNotContain("route sidecar", result.Output, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("dogfood/pr-12345/bin", result.Output);
}
[Fact]
[SkipOnPlatform(TestPlatforms.Windows, "Bash script tests require bash shell")]
public async Task Bash_HomebrewMode_PrDryRun_DownloadsCaskAndNativeArchives()
{
using var env = new TestEnvironment();
using var cmd = await CreateBashCommandWithMockGhAsync(env);
var result = await cmd.ExecuteAsync(
"12345",
"--install-mode", "homebrew",
"--dry-run",
"--skip-extension",
"--verbose");
result.EnsureSuccessful();
Assert.Contains("homebrew-cask-prerelease", result.Output);
Assert.Contains("cli-native-archives-osx-arm64", result.Output);
Assert.Contains("cli-native-archives-osx-x64", result.Output);
Assert.Contains("--archive-root", result.Output);
Assert.Contains("built-nugets", result.Output);
Assert.DoesNotContain("Add to your shell profile", result.Output);
Assert.DoesNotContain("route sidecar", result.Output, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("dogfood/pr-12345/bin", result.Output);
}
[Fact]
[SkipOnPlatform(TestPlatforms.Windows, "Bash script tests require bash shell")]
public async Task Bash_HomebrewMode_LocalDir_DryRun_UsesDogfoodArtifact()
{
using var env = new TestEnvironment();
var localDir = await CreateHomebrewInstallerArtifactAsync(Path.Combine(env.TempDirectory, "homebrew-artifact"));
using var cmd = new ScriptToolCommand(ScriptPaths.PRShell, env, _testOutput);
var result = await cmd.ExecuteAsync(
"--local-dir", localDir,
"--install-mode", "homebrew",
"--dry-run",
"--skip-path");
result.EnsureSuccessful();
Assert.Contains("dogfood.sh", result.Output);
Assert.Contains("--archive-root", result.Output);
Assert.Contains("Would copy nugets", result.Output);
Assert.DoesNotContain("Would install CLI archive", result.Output);
}
[Fact]
[SkipOnPlatform(TestPlatforms.Windows, "Bash script tests require bash shell")]
public async Task Bash_InstallerMode_RejectsHiveOnly()
{
using var env = new TestEnvironment();
using var cmd = await CreateBashCommandWithMockGhAsync(env);
var result = await cmd.ExecuteAsync(
"12345",
"--install-mode", "homebrew",
"--hive-only",
"--dry-run",
"--skip-extension");
Assert.NotEqual(0, result.ExitCode);
Assert.Contains("--hive-only cannot be combined with --install-mode homebrew", result.Output);
}
[Fact]
[SkipOnPlatform(TestPlatforms.Windows, "Bash script tests require bash shell")]
public async Task Bash_HomebrewDogfood_FailsWhenVersionCheckFails()
{
using var env = new TestEnvironment();
var localDir = await CreateHomebrewInstallerArtifactAsync(Path.Combine(env.TempDirectory, "homebrew-artifact"));
var mockBinDir = await CreateMockHomebrewBinAsync(env, aspireExitCode: 42);
using var cmd = new ScriptToolCommand("eng/homebrew/dogfood.sh", env, _testOutput);
cmd.WithEnvironmentVariable("PATH", $"{mockBinDir}{Path.PathSeparator}/usr/bin:/bin:/usr/sbin:/sbin");
var result = await cmd.ExecuteAsync(Path.Combine(localDir, "aspire.rb"));
Assert.NotEqual(0, result.ExitCode);
Assert.Contains("aspire --version failed after install", result.Output);
}
[Fact]
[RequiresTools(["ruby"])]
[SkipOnPlatform(TestPlatforms.Windows, "Bash script tests require bash shell")]
public async Task Bash_ValidateHomebrewCask_RemovesGeneratedTapTemplateBeforeSyntaxCheck()
{
using var env = new TestEnvironment();
var caskPath = Path.Combine(env.TempDirectory, "aspire.rb");
await File.WriteAllTextAsync(caskPath, "cask \"aspire\" do\n version \"13.3.0\"\nend\n");
var mockBinDir = await CreateMockHomebrewBinAsync(env, aspireExitCode: 0);
using var cmd = new ScriptToolCommand("eng/homebrew/validate-cask-artifact.sh", env, _testOutput);
cmd.WithEnvironmentVariable("PATH", $"{mockBinDir}{Path.PathSeparator}/usr/bin:/bin:/usr/sbin:/sbin");
var result = await cmd.ExecuteAsync(
"--cask-file", caskPath,
"--validation-mode", "LiveArchives");
result.EnsureSuccessful();
}
[Fact]
[RequiresTools(["ruby"])]
[SkipOnPlatform(TestPlatforms.Windows, "Bash script tests require bash shell")]
public async Task Bash_PrepareHomebrewCask_FailedVerification_UninstallsCask()
{
using var env = new TestEnvironment();
var archiveRoot = Path.Combine(env.TempDirectory, "archives");
await CreateFakeHomebrewArchivesAsync(archiveRoot);
var mockBinDir = await CreateMockHomebrewBinAsync(env, aspireExitCode: 42);
using var cmd = new ScriptToolCommand("eng/homebrew/prepare-cask-artifact.sh", env, _testOutput);
cmd.WithEnvironmentVariable("PATH", $"{mockBinDir}{Path.PathSeparator}/usr/bin:/bin:/usr/sbin:/sbin");
var result = await cmd.ExecuteAsync(
"--version", "13.3.0",
"--channel", "stable",
"--archive-root", archiveRoot,
"--output-dir", Path.Combine(env.TempDirectory, "homebrew-output"),
// The default mode is LiveArchives, which doesn't run brew install/uninstall.
// This test exercises the install-then-fail-then-uninstall path, so it must
// opt into LiveRelease explicitly.
"--validation-mode", "LiveRelease");
Assert.NotEqual(0, result.ExitCode);
var brewLog = await File.ReadAllTextAsync(Path.Combine(env.TempDirectory, "brew.log"));
Assert.Contains("uninstall --cask local/aspire-test/aspire", brewLog);
}
[Fact]
[RequiresTools(["ruby"])]
[SkipOnPlatform(TestPlatforms.Windows, "Bash script tests require bash shell")]
public async Task Bash_PrepareHomebrewCask_LiveArchives_GeneratesCaskWithArchiveHashesAndDogfood()
{
using var env = new TestEnvironment();
var archiveRoot = Path.Combine(env.TempDirectory, "archives");
await CreateFakeHomebrewArchivesAsync(archiveRoot);
var mockBinDir = await CreateMockHomebrewBinAsync(env, aspireExitCode: 0);
var outputDir = Path.Combine(env.TempDirectory, "homebrew-output");
using var cmd = new ScriptToolCommand("eng/homebrew/prepare-cask-artifact.sh", env, _testOutput);
cmd.WithEnvironmentVariable("PATH", $"{mockBinDir}{Path.PathSeparator}/usr/bin:/bin:/usr/sbin:/sbin");
var result = await cmd.ExecuteAsync(
"--version", "13.3.0",
"--channel", "prerelease",
"--archive-root", archiveRoot,
"--output-dir", outputDir,
"--validation-mode", "LiveArchives");
result.EnsureSuccessful();
var cask = await File.ReadAllTextAsync(Path.Combine(outputDir, "aspire.rb"));
Assert.Contains("version \"13.3.0\"", cask);
Assert.Contains("https://github.com/microsoft/aspire/releases/download/v#{version}/aspire-cli-osx-#{arch}-#{version}.tar.gz", cask);
Assert.Contains("verified: \"github.com/microsoft/aspire/\"", cask);
Assert.Contains((await GetSha256HexAsync(Path.Combine(archiveRoot, "aspire-cli-osx-arm64-13.3.0.tar.gz"))).ToLowerInvariant(), cask);
Assert.Contains((await GetSha256HexAsync(Path.Combine(archiveRoot, "aspire-cli-osx-x64-13.3.0.tar.gz"))).ToLowerInvariant(), cask);
Assert.DoesNotContain("${", cask);
Assert.True(File.Exists(Path.Combine(outputDir, "dogfood.sh")));
// LiveArchives mode must drop `--online` and add `--no-signing` to `brew audit`,
// because the cask URL points at a github.com/microsoft/aspire release that does
// not exist yet at source-build time. See the `audit_args` block in
// eng/homebrew/validate-cask-artifact.sh for the full rationale; this assertion
// locks the contract so a regression in the mode selection surfaces here rather
// than silently failing (or worse, silently succeeding) in the source-build
// prepare stage.
var brewLog = await File.ReadAllTextAsync(Path.Combine(env.TempDirectory, "brew.log"));
Assert.Contains("audit --cask --no-signing local/aspire/aspire", brewLog);
Assert.DoesNotContain("audit --cask --online", brewLog);
}
[Fact]
[RequiresTools(["pwsh"])]
public async Task PowerShell_WinGetMode_WhatIf_DownloadsManifestAndNativeArchives()
{
using var env = new TestEnvironment();
using var cmd = await CreatePsCommandWithMockGhAsync(env);
var result = await cmd.ExecuteAsync(
"-PRNumber", "12345",
"-InstallMode", "WinGet",
"-Force",
"-WhatIf",
"-SkipExtension",
"-Verbose");
result.EnsureSuccessful();
Assert.Contains("winget-manifests-prerelease", result.Output);
Assert.Contains("cli-native-archives-win-x64", result.Output);
Assert.Contains("cli-native-archives-win-arm64", result.Output);
Assert.Contains("-ArchiveRoot", result.Output);
Assert.Contains("-Force", result.Output);
Assert.Contains("built-nugets", result.Output);
Assert.DoesNotContain("Add to your shell profile", result.Output);
Assert.DoesNotContain("Route sidecar", result.Output);
Assert.DoesNotContain($"dogfood{Path.DirectorySeparatorChar}pr-12345{Path.DirectorySeparatorChar}bin", result.Output);
}
[Fact]
[RequiresTools(["pwsh"])]
public async Task PowerShell_HomebrewMode_WhatIf_DownloadsCaskAndNativeArchives()
{
using var env = new TestEnvironment();
using var cmd = await CreatePsCommandWithMockGhAsync(env);
var result = await cmd.ExecuteAsync(
"-PRNumber", "12345",
"-InstallMode", "Homebrew",
"-WhatIf",
"-SkipExtension",
"-Verbose");
result.EnsureSuccessful();
Assert.Contains("homebrew-cask-prerelease", result.Output);
Assert.Contains("cli-native-archives-osx-arm64", result.Output);
Assert.Contains("cli-native-archives-osx-x64", result.Output);
Assert.Contains("--archive-root", result.Output);
Assert.Contains("built-nugets", result.Output);
Assert.DoesNotContain("Add to your shell profile", result.Output);
Assert.DoesNotContain("Route sidecar", result.Output);
Assert.DoesNotContain($"dogfood{Path.DirectorySeparatorChar}pr-12345{Path.DirectorySeparatorChar}bin", result.Output);
}
[Fact]
[RequiresTools(["pwsh"])]
public async Task PowerShell_WinGetMode_LocalDir_WhatIf_UsesDogfoodArtifact()
{
using var env = new TestEnvironment();
var localDir = await CreateWinGetInstallerArtifactAsync(Path.Combine(env.TempDirectory, "winget-artifact"));
using var cmd = new ScriptToolCommand(ScriptPaths.PRPowerShell, env, _testOutput);
var result = await cmd.ExecuteAsync(
"-LocalDir", localDir,
"-InstallMode", "WinGet",
"-WhatIf",
"-SkipPath");
result.EnsureSuccessful();
Assert.Contains("dogfood.ps1", result.Output);
Assert.Contains("-ArchiveRoot", result.Output);
Assert.Contains("Copying built nugets", result.Output);
Assert.DoesNotContain("Installing Aspire CLI to", result.Output);
}
[Fact]
[RequiresTools(["pwsh"])]
public async Task PowerShell_InstallerMode_RejectsHiveOnly()
{
using var env = new TestEnvironment();
using var cmd = await CreatePsCommandWithMockGhAsync(env);
var result = await cmd.ExecuteAsync(
"-PRNumber", "12345",
"-InstallMode", "Homebrew",
"-HiveOnly",
"-WhatIf",
"-SkipExtension");
Assert.NotEqual(0, result.ExitCode);
Assert.Contains("-HiveOnly cannot be combined with -InstallMode Homebrew", result.Output);
}
[Fact]
[RequiresTools(["pwsh"])]
public async Task PowerShell_PrepareWinGetManifest_GenerateOnly_GeneratesManifestsWithArchiveHashesAndDogfood()
{
using var env = new TestEnvironment();
var archiveRoot = Path.Combine(env.TempDirectory, "archives");
await CreateFakeWinGetArchivesAsync(archiveRoot);
var outputDir = Path.Combine(env.TempDirectory, "winget-output");
using var cmd = new ScriptToolCommand("eng/winget/prepare-manifest-artifact.ps1", env, _testOutput);
var result = await cmd.ExecuteAsync(
"-Channel", "prerelease",
"-ArchiveRoot", archiveRoot,
"-OutputPath", outputDir,
"-ValidationMode", "GenerateOnly");
result.EnsureSuccessful();
var installerManifest = await File.ReadAllTextAsync(Path.Combine(outputDir, "Microsoft.Aspire.installer.yaml"));
Assert.Contains("PackageVersion: \"13.3.0\"", installerManifest);
Assert.Contains("InstallerUrl: https://ci.dot.net/public/aspire/13.3.0/aspire-cli-win-x64-13.3.0.zip", installerManifest);
Assert.Contains("InstallerUrl: https://ci.dot.net/public/aspire/13.3.0/aspire-cli-win-arm64-13.3.0.zip", installerManifest);
Assert.Contains(await GetSha256HexAsync(Path.Combine(archiveRoot, "Debug", "Shipping", "aspire-cli-win-x64-13.3.0.zip")), installerManifest);
Assert.Contains(await GetSha256HexAsync(Path.Combine(archiveRoot, "Debug", "Shipping", "aspire-cli-win-arm64-13.3.0.zip")), installerManifest);
Assert.DoesNotContain(new string('0', 64), installerManifest);
Assert.DoesNotContain("${", installerManifest);
var localeManifest = await File.ReadAllTextAsync(Path.Combine(outputDir, "Microsoft.Aspire.locale.en-US.yaml"));
Assert.Contains("For testing builds only. Prerelease package in stable manifest.", localeManifest);
Assert.True(File.Exists(Path.Combine(outputDir, "Microsoft.Aspire.yaml")));
Assert.True(File.Exists(Path.Combine(outputDir, "dogfood.ps1")));
}
[Fact]
[RequiresTools(["pwsh"])]
[SkipOnPlatform(TestPlatforms.Linux | TestPlatforms.OSX | TestPlatforms.FreeBSD, "winget is Windows-only")]
public async Task PowerShell_WinGetDogfood_Force_PassesForceToWingetInstall()
{
using var env = new TestEnvironment();
var localDir = await CreateWinGetInstallerArtifactAsync(Path.Combine(env.TempDirectory, "winget-artifact"));
var mockBinDir = await CreateMockWinGetBinAsync(env, aspireExitCode: 0);
using var cmd = new ScriptToolCommand("eng/winget/dogfood.ps1", env, _testOutput);
cmd.WithEnvironmentVariable("PATH", $"{mockBinDir}{Path.PathSeparator}{Environment.GetEnvironmentVariable("PATH")}");
var result = await cmd.ExecuteAsync("-ManifestPath", localDir, "-Force");
result.EnsureSuccessful();
var wingetLog = await File.ReadAllTextAsync(Path.Combine(env.TempDirectory, "winget.log"));
Assert.Contains("install --manifest", wingetLog);
Assert.Contains("--force", wingetLog);
}
[Fact]
[RequiresTools(["pwsh"])]
[SkipOnPlatform(TestPlatforms.Linux | TestPlatforms.OSX | TestPlatforms.FreeBSD, "winget is Windows-only")]
public async Task PowerShell_WinGetDogfood_FailsWhenVersionCheckFails()
{
using var env = new TestEnvironment();
var localDir = await CreateWinGetInstallerArtifactAsync(Path.Combine(env.TempDirectory, "winget-artifact"));
var mockBinDir = await CreateMockWinGetBinAsync(env, aspireExitCode: 42);
using var cmd = new ScriptToolCommand("eng/winget/dogfood.ps1", env, _testOutput);
cmd.WithEnvironmentVariable("PATH", $"{mockBinDir}{Path.PathSeparator}{Environment.GetEnvironmentVariable("PATH")}");
var result = await cmd.ExecuteAsync("-ManifestPath", localDir);
Assert.NotEqual(0, result.ExitCode);
Assert.Contains("Failed to verify Aspire CLI installation", result.Output);
}
[Fact]
[RequiresTools(["pwsh"])]
[SkipOnPlatform(TestPlatforms.Linux | TestPlatforms.OSX | TestPlatforms.FreeBSD, "winget is Windows-only")]
public async Task PowerShell_WinGetDogfood_ArchiveRoot_ValidatesPristineAndInstallsRewrittenManifest()
{
// This mirrors get-aspire-cli-pr.ps1 -InstallMode WinGet's invocation of
// dogfood.ps1 -ArchiveRoot. End-to-end behaviours that have to hold:
// 1. ``winget validate`` runs against the pristine https:// URLs (the schema
// rejects anything that's not ^https?://).
// 2. ``Set-LocalInstallerSources`` rewrites InstallerUrl to point at the
// loopback HTTP listener (not file:// — winget's WinINet-based downloader
// rejects file:// with HRESULT 0x8007007b) and refreshes InstallerSha256
// (PR-channel manifests ship with a placeholder of all zeros).
// 3. The mock then actually downloads the URL via Invoke-WebRequest and
// hashes the bytes, which exercises both points 1 and 2 end-to-end.
using var env = new TestEnvironment();
var (manifestDir, archiveRoot) = await CreateWinGetPrChannelArtifactAsync(env.TempDirectory);
var mockBinDir = await CreateMockWinGetBinAsync(env, aspireExitCode: 0);
using var cmd = new ScriptToolCommand("eng/winget/dogfood.ps1", env, _testOutput);
cmd.WithEnvironmentVariable("PATH", $"{mockBinDir}{Path.PathSeparator}{Environment.GetEnvironmentVariable("PATH")}");
var result = await cmd.ExecuteAsync("-ManifestPath", manifestDir, "-ArchiveRoot", archiveRoot);
result.EnsureSuccessful();
var wingetLog = await File.ReadAllTextAsync(Path.Combine(env.TempDirectory, "winget.log"));
Assert.Contains("validate --manifest", wingetLog);
Assert.Contains("install --manifest", wingetLog);
// The pristine manifest in the artifact directory must be untouched (re-runnable).
var originalInstaller = await File.ReadAllTextAsync(Path.Combine(manifestDir, "Microsoft.Aspire.installer.yaml"));
Assert.Contains("https://ci.dot.net/", originalInstaller);
Assert.Contains(new string('0', 64), originalInstaller);
Assert.DoesNotContain("file://", originalInstaller);
}
[Fact]
[RequiresTools(["pwsh"])]
[SkipOnPlatform(TestPlatforms.Linux | TestPlatforms.OSX | TestPlatforms.FreeBSD, "winget is Windows-only")]
public async Task PowerShell_WinGetDogfood_ArchiveRoot_FailsWhenArchiveBytesChange()
{
// Guard against silent regressions: if Set-LocalInstallerSources ever stops
// refreshing InstallerSha256, the mock winget install will download the
// archive over the loopback listener and detect the hash mismatch.
using var env = new TestEnvironment();
var (manifestDir, archiveRoot) = await CreateWinGetPrChannelArtifactAsync(env.TempDirectory);
// Tamper the archive *after* the manifest's placeholder hash was written. The
// refreshed hash must reflect the new bytes for ``winget install`` to succeed.
// We replace with a valid zip (still extractable, still satisfies the
// NestedInstallerFiles contract) but with different stub contents — so only
// the hash refresh is being exercised, not the extraction layout check.
var x64Archive = Path.Combine(archiveRoot, "Debug", "Shipping", "aspire-cli-win-x64-13.3.0.zip");
await WriteRealAspireWinGetZipAsync(x64Archive, "post-generate-mutated-x64-bytes");
var mockBinDir = await CreateMockWinGetBinAsync(env, aspireExitCode: 0);
using var cmd = new ScriptToolCommand("eng/winget/dogfood.ps1", env, _testOutput);
cmd.WithEnvironmentVariable("PATH", $"{mockBinDir}{Path.PathSeparator}{Environment.GetEnvironmentVariable("PATH")}");
var result = await cmd.ExecuteAsync("-ManifestPath", manifestDir, "-ArchiveRoot", archiveRoot);
result.EnsureSuccessful();
}
[Fact]
[RequiresTools(["pwsh"])]
public async Task PowerShell_PrepareWinGetManifest_NestedInstallerFiles_MatchArchiveContents()
{
// Manifest-vs-archive contract: real winget extracts InstallerUrl's zip at
// install time and looks for every NestedInstallerFiles[].RelativeFilePath in
// the extracted contents. If the prepare-manifest-artifact.ps1 template ever
// drifts away from the actual archive layout — wrong RelativeFilePath, wrong
// CWD, missing file — real winget fails at install time with an opaque error
// (we observed 0x8A150001 with no diag-log line beyond "Started applying motw").
//
// This test runs the real prepare script against real zips and checks the
// contract holds end-to-end without needing winget itself, so it can run on
// Linux CI as a fast deterministic gate.
using var env = new TestEnvironment();
var archiveRoot = Path.Combine(env.TempDirectory, "archives");
var archiveDir = Path.Combine(archiveRoot, "Debug", "Shipping");
Directory.CreateDirectory(archiveDir);
var x64Zip = Path.Combine(archiveDir, "aspire-cli-win-x64-13.3.0.zip");
var arm64Zip = Path.Combine(archiveDir, "aspire-cli-win-arm64-13.3.0.zip");
await WriteRealAspireWinGetZipAsync(x64Zip, "stub-x64-13.3.0");
await WriteRealAspireWinGetZipAsync(arm64Zip, "stub-arm64-13.3.0");
var outputDir = Path.Combine(env.TempDirectory, "winget-output");
using var cmd = new ScriptToolCommand("eng/winget/prepare-manifest-artifact.ps1", env, _testOutput);
var result = await cmd.ExecuteAsync(
"-Channel", "prerelease",
"-ArchiveRoot", archiveRoot,
"-OutputPath", outputDir,
"-ValidationMode", "GenerateOnly");
result.EnsureSuccessful();
var installerYaml = await File.ReadAllTextAsync(Path.Combine(outputDir, "Microsoft.Aspire.installer.yaml"));
var relativeFilePaths = ParseRelativeFilePaths(installerYaml);
Assert.NotEmpty(relativeFilePaths);
foreach (var archive in new[] { x64Zip, arm64Zip })
{
var extractDir = Path.Combine(env.TempDirectory, $"extract-{Path.GetFileNameWithoutExtension(archive)}");
System.IO.Compression.ZipFile.ExtractToDirectory(archive, extractDir);
foreach (var rel in relativeFilePaths)
{
var expected = Path.Combine(extractDir, rel.Replace('/', Path.DirectorySeparatorChar));
Assert.True(File.Exists(expected),
$"NestedInstallerFiles entry '{rel}' from {Path.GetFileName(archive)} not found at {expected}. Manifest disagrees with archive contents.");
}
}
}
[Fact]
[RequiresTools(["pwsh"])]
[SkipOnPlatform(TestPlatforms.Linux | TestPlatforms.OSX | TestPlatforms.FreeBSD, "winget is Windows-only")]
public async Task PowerShell_WinGetDogfood_ArchiveRoot_FailsWhenNestedInstallerFileMissingFromArchive()
{
// Companion to PowerShell_PrepareWinGetManifest_NestedInstallerFiles_MatchArchiveContents:
// exercises the same contract from the install side. The manifest declares
// NestedInstallerFiles: [RelativeFilePath: aspire.exe] but the served zip is
// rebuilt with only a sentinel file, no aspire.exe. Real winget would fail at
// install time with an opaque error (after the InstallerSha256 verify step);
// the mock catches the same contract violation deterministically with an
// attributable error message.
using var env = new TestEnvironment();
var (manifestDir, archiveRoot) = await CreateWinGetPrChannelArtifactAsync(env.TempDirectory);
var x64Archive = Path.Combine(archiveRoot, "Debug", "Shipping", "aspire-cli-win-x64-13.3.0.zip");
File.Delete(x64Archive);
await using (var fs = File.Create(x64Archive))
using (var archive = new System.IO.Compression.ZipArchive(fs, System.IO.Compression.ZipArchiveMode.Create))
{
var entry = archive.CreateEntry("not-aspire.exe", System.IO.Compression.CompressionLevel.NoCompression);
await using var s = entry.Open();
await s.WriteAsync(System.Text.Encoding.UTF8.GetBytes("sentinel"));
}
var mockBinDir = await CreateMockWinGetBinAsync(env, aspireExitCode: 0);
using var cmd = new ScriptToolCommand("eng/winget/dogfood.ps1", env, _testOutput);
cmd.WithEnvironmentVariable("PATH", $"{mockBinDir}{Path.PathSeparator}{Environment.GetEnvironmentVariable("PATH")}");
var result = await cmd.ExecuteAsync("-ManifestPath", manifestDir, "-ArchiveRoot", archiveRoot);
Assert.NotEqual(0, result.ExitCode);
Assert.Contains("NestedInstallerFiles entry 'aspire.exe' is missing", result.Output);
}
}