File: Scripts\PRScriptToolModeTests.cs
Web Access
Project: src\tests\Aspire.Acquisition.Tests\Aspire.Acquisition.Tests.csproj (Aspire.Acquisition.Tests)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.Runtime.InteropServices;
using Aspire.TestUtilities;
using Microsoft.DotNet.XUnitExtensions;
using Xunit;
 
namespace Aspire.Acquisition.Tests.Scripts;
 
/// <summary>
/// Tests for the --install-mode tool / -InstallMode Tool flag on get-aspire-cli-pr.{sh,ps1}.
/// These tests validate parameter handling, validation rules, and dry-run/WhatIf behavior
/// without invoking real 'dotnet tool install'.
/// </summary>
public class PRScriptToolModeTests(ITestOutputHelper testOutput)
{
    private readonly ITestOutputHelper _testOutput = testOutput;
 
    private static string GetNonHostArchitecture() =>
        RuntimeInformation.ProcessArchitecture == Architecture.Arm64 ? "x64" : "arm64";
 
    private static async Task<string> CreateLocalDirWithAspireCliPackageAsync(string root, string version = "13.3.0-pr.1234.abc")
    {
        Directory.CreateDirectory(root);
        await FakeArchiveHelper.CreateFakeNupkgAsync(root, "Aspire.Cli", version);
        await FakeArchiveHelper.CreateFakeNupkgAsync(root, "Aspire.Hosting", version);
        return root;
    }
 
    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> CreateMockDotnetScriptAsync(TestEnvironment env, ITestOutputHelper testOutput)
    {
        var mockBinDir = Path.Combine(env.TempDirectory, "mock-dotnet-bin");
        Directory.CreateDirectory(mockBinDir);
 
        var isWindows = OperatingSystem.IsWindows();
        var dotnetScriptPath = Path.Combine(mockBinDir, isWindows ? "dotnet.cmd" : "dotnet");
 
        var scriptContent = isWindows
            ? """
                @echo off
                setlocal
                if not "%MOCK_DOTNET_COMMANDS_FILE%"=="" echo %*>> "%MOCK_DOTNET_COMMANDS_FILE%"
                if "%~1"=="--version" (
                    echo 10.0.100
                    exit 0
                )
                if "%~1"=="tool" if "%~2"=="install" goto :tool_install
                if "%~1"=="tool" if "%~2"=="update" goto :tool_update
                echo Mock dotnet: Unknown command: %* 1>&2
                exit 1
 
                :tool_install
                if "%MOCK_DOTNET_TOOL_INSTALL_FAIL%"=="true" (
                    echo Mock dotnet tool install failed 1>&2
                    exit 42
                )
                exit 0
 
                :tool_update
                if "%MOCK_DOTNET_TOOL_UPDATE_FAIL%"=="true" (
                    echo Mock dotnet tool update failed 1>&2
                    exit 43
                )
                exit 0
                """
            : """
                #!/usr/bin/env bash
                set -euo pipefail
 
                if [[ -n "${MOCK_DOTNET_COMMANDS_FILE:-}" ]]; then
                    printf '%s\n' "$*" >> "$MOCK_DOTNET_COMMANDS_FILE"
                fi
 
                case "${1:-}" in
                    --version)
                        echo "10.0.100"
                        exit 0
                        ;;
                    tool)
                        case "${2:-}" in
                            install)
                                if [[ "${MOCK_DOTNET_TOOL_INSTALL_FAIL:-}" == "true" ]]; then
                                    echo "Mock dotnet tool install failed" >&2
                                    exit 42
                                fi
                                exit 0
                                ;;
                            update)
                                if [[ "${MOCK_DOTNET_TOOL_UPDATE_FAIL:-}" == "true" ]]; then
                                    echo "Mock dotnet tool update failed" >&2
                                    exit 43
                                fi
                                exit 0
                                ;;
                        esac
                        ;;
                esac
 
                echo "Mock dotnet: Unknown command: $*" >&2
                exit 1
                """;
 
        await File.WriteAllTextAsync(dotnetScriptPath, scriptContent);
 
        if (!isWindows)
        {
            FileHelper.MakeExecutable(dotnetScriptPath);
        }
 
        testOutput.WriteLine($"Created mock dotnet script at: {dotnetScriptPath}");
        return mockBinDir;
    }
 
    // ----------------------------------------------------------------------
    // Bash: --install-mode / --force
    // ----------------------------------------------------------------------
 
    [Fact]
    [SkipOnPlatform(TestPlatforms.Windows, "Bash script tests require bash shell")]
    public async Task Bash_Help_DescribesInstallModeAndForce()
    {
        using var env = new TestEnvironment();
        using var cmd = new ScriptToolCommand(ScriptPaths.PRShell, env, _testOutput);
 
        var result = await cmd.ExecuteAsync("--help");
 
        result.EnsureSuccessful();
        Assert.Contains("--install-mode", result.Output);
        Assert.Contains("-m, --install-mode", result.Output);
        Assert.Contains("--force", result.Output);
        Assert.Contains("archive", result.Output);
        Assert.Contains("tool", result.Output);
    }
 
    [Fact]
    [SkipOnPlatform(TestPlatforms.Windows, "Bash script tests require bash shell")]
    public async Task Bash_InvalidInstallMode_ReturnsError()
    {
        using var env = new TestEnvironment();
        using var cmd = await CreateBashCommandWithMockGhAsync(env);
 
        var result = await cmd.ExecuteAsync("12345", "--install-mode", "bogus", "--dry-run", "--skip-path");
 
        Assert.NotEqual(0, result.ExitCode);
        Assert.Contains("Invalid value for --install-mode", result.Output);
    }
 
    [Fact]
    [SkipOnPlatform(TestPlatforms.Windows, "Bash script tests require bash shell")]
    public async Task Bash_ToolMode_LocalDir_DryRun_SkipsArchiveAndShowsDotnetToolInstall()
    {
        using var env = new TestEnvironment();
        var localDir = Path.Combine(env.TempDirectory, "artifacts");
        await CreateLocalDirWithAspireCliPackageAsync(localDir);
 
        using var cmd = new ScriptToolCommand(ScriptPaths.PRShell, env, _testOutput);
        var result = await cmd.ExecuteAsync(
            "--local-dir", localDir,
            "--install-mode", "tool",
            "--dry-run",
            "--skip-path");
 
        result.EnsureSuccessful();
        Assert.DoesNotContain("Downloading CLI", result.Output);
        Assert.DoesNotContain("cli-native-archives", result.Output);
        Assert.Contains("dotnet tool install --global Aspire.Cli", result.Output);
        Assert.Contains("--add-source", result.Output);
        // Tool mode now populates the hive so `aspire new` can discover the PR
        // version of Aspire.AppHost.Sdk / Aspire.Hosting / Aspire.ProjectTemplates.
        Assert.Contains("Would copy nugets", result.Output);
        Assert.DoesNotContain("config set channel", result.Output);
    }
 
    [Fact]
    [SkipOnPlatform(TestPlatforms.Windows, "Bash script tests require bash shell")]
    public async Task Bash_ShortInstallModeTool_LocalDir_DryRun_UsesDotnetToolInstall()
    {
        using var env = new TestEnvironment();
        var localDir = Path.Combine(env.TempDirectory, "artifacts");
        await CreateLocalDirWithAspireCliPackageAsync(localDir);
 
        using var cmd = new ScriptToolCommand(ScriptPaths.PRShell, env, _testOutput);
        var result = await cmd.ExecuteAsync(
            "--local-dir", localDir,
            "-m", "tool",
            "--dry-run",
            "--skip-path");
 
        result.EnsureSuccessful();
        Assert.DoesNotContain("Would install CLI archive", result.Output);
        Assert.Contains("dotnet tool install --global Aspire.Cli", result.Output);
    }
 
    [Fact]
    [SkipOnPlatform(TestPlatforms.Windows, "Bash script tests require bash shell")]
    public async Task Bash_ShortInstallPath_RemainsInstallPath()
    {
        using var env = new TestEnvironment();
        var localDir = Path.Combine(env.TempDirectory, "artifacts");
        await CreateLocalDirWithAspireCliPackageAsync(localDir);
        await FakeArchiveHelper.CreateFakeArchiveAsync(localDir);
        var customPath = Path.Combine(env.TempDirectory, "custom");
 
        using var cmd = new ScriptToolCommand(ScriptPaths.PRShell, env, _testOutput);
        var result = await cmd.ExecuteAsync(
            "--local-dir", localDir,
            "-i", customPath,
            "--dry-run",
            "--skip-path");
 
        result.EnsureSuccessful();
        Assert.Contains($"Would install CLI archive to: {Path.Combine(customPath, "bin")}", result.Output);
        Assert.DoesNotContain("dotnet tool install", result.Output);
    }
 
    [Fact]
    [SkipOnPlatform(TestPlatforms.Windows, "Bash script tests require bash shell")]
    public async Task Bash_ToolMode_PrDryRun_SkipsCliNativeArchivesDownload()
    {
        using var env = new TestEnvironment();
        using var cmd = await CreateBashCommandWithMockGhAsync(env);
 
        var result = await cmd.ExecuteAsync(
            "12345", "--install-mode", "tool",
            "--dry-run", "--skip-path", "--verbose");
 
        result.EnsureSuccessful();
        // Tool mode must NOT download the cli-native-archives artifact.
        Assert.DoesNotContain("cli-native-archives", result.Output);
        // Tool mode DOES download the cross-platform built-nugets artifact so the
        // hive contains Aspire.Hosting / Aspire.AppHost.Sdk / Aspire.ProjectTemplates.
        Assert.Contains("--name built-nugets -D", result.Output);
        Assert.Contains("built-nugets-for-", result.Output);
        Assert.Contains("dotnet tool install --global Aspire.Cli", result.Output);
        Assert.DoesNotContain("config set channel", result.Output);
        Assert.DoesNotContain("route sidecar", result.Output, StringComparison.OrdinalIgnoreCase);
        Assert.DoesNotContain("dogfood/pr-12345/bin", result.Output);
        Assert.DoesNotContain(".dotnet/tools", result.Output);
    }
 
    [Fact]
    [SkipOnPlatform(TestPlatforms.Windows, "Bash script tests require bash shell")]
    public async Task Bash_ToolMode_RunIdAloneRequiresHiveLabel()
    {
        using var env = new TestEnvironment();
        using var cmd = await CreateBashCommandWithMockGhAsync(env);
 
        var result = await cmd.ExecuteAsync(
            "--run-id", "987654321",
            "--install-mode", "tool",
            "--dry-run",
            "--skip-path",
            "--verbose");
 
        // Tool mode now populates the hive like archive mode, so it requires
        // either --pr-number or --hive-label (the installed CLI's package
        // channel is baked at build time and won't look in a run-<id> hive).
        Assert.NotEqual(0, result.ExitCode);
        Assert.Contains("Cannot determine hive label", result.Output);
    }
 
    [Fact]
    [SkipOnPlatform(TestPlatforms.Windows, "Bash script tests require bash shell")]
    public async Task Bash_ToolMode_RunIdWithHiveLabel_DryRun_PopulatesHive()
    {
        using var env = new TestEnvironment();
        using var cmd = await CreateBashCommandWithMockGhAsync(env);
 
        var result = await cmd.ExecuteAsync(
            "--run-id", "987654321",
            "--hive-label", "pr-99999",
            "--install-mode", "tool",
            "--dry-run",
            "--skip-path",
            "--verbose");
 
        result.EnsureSuccessful();
        Assert.Contains("--name built-nugets -D", result.Output);
        Assert.Contains("built-nugets-for-", result.Output);
        Assert.Contains("hives/pr-99999/packages", result.Output);
        Assert.Contains("dotnet tool install --global Aspire.Cli", result.Output);
    }
 
    [Fact]
    [SkipOnPlatform(TestPlatforms.Windows, "Bash script tests require bash shell")]
    public async Task Bash_ToolMode_RejectsCrossArchitectureOverride()
    {
        using var env = new TestEnvironment();
        using var cmd = new ScriptToolCommand(ScriptPaths.PRShell, env, _testOutput);
 
        var result = await cmd.ExecuteAsync(
            "12345",
            "--install-mode", "tool",
            "--arch", GetNonHostArchitecture(),
            "--dry-run",
            "--skip-path");
 
        Assert.NotEqual(0, result.ExitCode);
        Assert.Contains("--install-mode tool cannot target", result.Output);
        Assert.Contains("current host", result.Output);
    }
 
    [Fact]
    [SkipOnPlatform(TestPlatforms.Windows, "Bash script tests require bash shell")]
    public async Task Bash_ToolMode_ExplicitInstallPathUsesToolPath()
    {
        using var env = new TestEnvironment();
        var localDir = Path.Combine(env.TempDirectory, "artifacts");
        await CreateLocalDirWithAspireCliPackageAsync(localDir);
        var customPath = Path.Combine(env.TempDirectory, "custom");
 
        using var cmd = new ScriptToolCommand(ScriptPaths.PRShell, env, _testOutput);
        var result = await cmd.ExecuteAsync(
            "--local-dir", localDir,
            "--install-mode", "tool",
            "--install-path", customPath,
            "--dry-run", "--skip-path");
 
        result.EnsureSuccessful();
        Assert.Contains($"dotnet tool install --tool-path {Path.Combine(customPath, "bin")} Aspire.Cli", result.Output);
        Assert.DoesNotContain("dotnet tool install --global Aspire.Cli", result.Output);
    }
 
    [Fact]
    [SkipOnPlatform(TestPlatforms.Windows, "Bash script tests require bash shell")]
    public async Task Bash_ToolMode_RejectsHiveOnly()
    {
        using var env = new TestEnvironment();
        var localDir = Path.Combine(env.TempDirectory, "artifacts");
        await CreateLocalDirWithAspireCliPackageAsync(localDir);
 
        using var cmd = new ScriptToolCommand(ScriptPaths.PRShell, env, _testOutput);
        var result = await cmd.ExecuteAsync(
            "--local-dir", localDir,
            "--install-mode", "tool",
            "--hive-only",
            "--dry-run", "--skip-path");
 
        Assert.NotEqual(0, result.ExitCode);
        Assert.Contains("--hive-only cannot be combined with --install-mode tool", result.Output);
        Assert.DoesNotContain("dotnet tool install", result.Output);
        Assert.DoesNotContain("dotnet tool update", result.Output);
    }
 
    [Fact]
    [SkipOnPlatform(TestPlatforms.Windows, "Bash script tests require bash shell")]
    public async Task Bash_ArchiveMode_RejectsForce()
    {
        using var env = new TestEnvironment();
        var localDir = Path.Combine(env.TempDirectory, "artifacts");
        await CreateLocalDirWithAspireCliPackageAsync(localDir);
 
        using var cmd = new ScriptToolCommand(ScriptPaths.PRShell, env, _testOutput);
        var result = await cmd.ExecuteAsync(
            "--local-dir", localDir,
            "--force",
            "--dry-run", "--skip-path");
 
        Assert.NotEqual(0, result.ExitCode);
        Assert.Contains("--force can only be combined with --install-mode tool", result.Output);
        Assert.DoesNotContain("dotnet tool install --", result.Output);
        Assert.DoesNotContain("dotnet tool update --", result.Output);
    }
 
    [Fact]
    [SkipOnPlatform(TestPlatforms.Windows, "Bash script tests require bash shell")]
    public async Task Bash_ToolMode_InstallFailureSuggestsForce()
    {
        using var env = new TestEnvironment();
        var localDir = Path.Combine(env.TempDirectory, "artifacts");
        await CreateLocalDirWithAspireCliPackageAsync(localDir);
        var mockDotnetPath = await CreateMockDotnetScriptAsync(env, _testOutput);
        var dotnetCommandsPath = Path.Combine(env.TempDirectory, "dotnet-commands.txt");
 
        using var cmd = new ScriptToolCommand(ScriptPaths.PRShell, env, _testOutput);
        cmd.WithEnvironmentVariable("PATH", $"{mockDotnetPath}{Path.PathSeparator}{Environment.GetEnvironmentVariable("PATH")}");
        cmd.WithEnvironmentVariable("MOCK_DOTNET_TOOL_INSTALL_FAIL", "true");
        cmd.WithEnvironmentVariable("MOCK_DOTNET_COMMANDS_FILE", dotnetCommandsPath);
 
        var result = await cmd.ExecuteAsync(
            "--local-dir", localDir,
            "--install-mode", "tool",
            "--skip-extension",
            "--skip-path");
 
        Assert.NotEqual(0, result.ExitCode);
        Assert.Contains("Failed to install Aspire.Cli dotnet tool", result.Output);
        Assert.Contains("re-run with --force", result.Output);
 
        var dotnetCommands = await File.ReadAllTextAsync(dotnetCommandsPath, TestContext.Current.CancellationToken);
        Assert.Contains("tool install --global Aspire.Cli", dotnetCommands);
        Assert.DoesNotContain("tool list", dotnetCommands);
        Assert.DoesNotContain("tool update", dotnetCommands);
    }
 
    [Fact]
    [SkipOnPlatform(TestPlatforms.Windows, "Bash script tests require bash shell")]
    public async Task Bash_ToolMode_ForceUpdatesExistingToolWithAllowDowngrade()
    {
        using var env = new TestEnvironment();
        var localDir = Path.Combine(env.TempDirectory, "artifacts");
        await CreateLocalDirWithAspireCliPackageAsync(localDir, "13.3.0-pr.5678.deadbeef");
        var mockDotnetPath = await CreateMockDotnetScriptAsync(env, _testOutput);
        var dotnetCommandsPath = Path.Combine(env.TempDirectory, "dotnet-commands.txt");
 
        using var cmd = new ScriptToolCommand(ScriptPaths.PRShell, env, _testOutput);
        cmd.WithEnvironmentVariable("PATH", $"{mockDotnetPath}{Path.PathSeparator}{Environment.GetEnvironmentVariable("PATH")}");
        cmd.WithEnvironmentVariable("MOCK_DOTNET_COMMANDS_FILE", dotnetCommandsPath);
 
        var result = await cmd.ExecuteAsync(
            "--local-dir", localDir,
            "--install-mode", "tool",
            "--force",
            "--skip-extension",
            "--skip-path");
 
        result.EnsureSuccessful();
 
        var dotnetCommands = await File.ReadAllTextAsync(dotnetCommandsPath, TestContext.Current.CancellationToken);
        Assert.Contains("tool update --global Aspire.Cli --version 13.3.0-pr.5678.deadbeef", dotnetCommands);
        // The hive is populated from --local-dir and the tool is installed from the
        // hive directory (not the raw --local-dir), giving --add-source a durable
        // location for any later `dotnet tool update` invocations. The hive label
        // is auto-detected from the pr-flavored package version suffix (pr-5678).
        var prHive = Path.Combine(env.MockHome, ".aspire", "hives", "pr-5678", "packages");
        Assert.Contains($"--add-source {prHive}", dotnetCommands);
        Assert.Contains("--allow-downgrade", dotnetCommands);
        Assert.True(Directory.Exists(prHive), $"Hive should be populated at {prHive}.");
        Assert.True(File.Exists(Path.Combine(prHive, "Aspire.Hosting.13.3.0-pr.5678.deadbeef.nupkg")),
            "Cross-platform Aspire.Hosting nupkg should be copied to the hive.");
    }
 
    [Fact]
    [SkipOnPlatform(TestPlatforms.Windows, "Bash script tests require bash shell")]
    public async Task Bash_ToolMode_ForceUpdateFailureReportsUpdate()
    {
        using var env = new TestEnvironment();
        var localDir = Path.Combine(env.TempDirectory, "artifacts");
        await CreateLocalDirWithAspireCliPackageAsync(localDir, "13.3.0-pr.5678.deadbeef");
        var mockDotnetPath = await CreateMockDotnetScriptAsync(env, _testOutput);
        var dotnetCommandsPath = Path.Combine(env.TempDirectory, "dotnet-commands.txt");
 
        using var cmd = new ScriptToolCommand(ScriptPaths.PRShell, env, _testOutput);
        cmd.WithEnvironmentVariable("PATH", $"{mockDotnetPath}{Path.PathSeparator}{Environment.GetEnvironmentVariable("PATH")}");
        cmd.WithEnvironmentVariable("MOCK_DOTNET_TOOL_UPDATE_FAIL", "true");
        cmd.WithEnvironmentVariable("MOCK_DOTNET_COMMANDS_FILE", dotnetCommandsPath);
 
        var result = await cmd.ExecuteAsync(
            "--local-dir", localDir,
            "--install-mode", "tool",
            "--force",
            "--skip-extension",
            "--skip-path");
 
        Assert.NotEqual(0, result.ExitCode);
        Assert.Contains("Failed to update Aspire.Cli dotnet tool", result.Output);
        Assert.DoesNotContain("re-run with --force", result.Output);
 
        var dotnetCommands = await File.ReadAllTextAsync(dotnetCommandsPath, TestContext.Current.CancellationToken);
        Assert.Contains("tool update --global Aspire.Cli --version 13.3.0-pr.5678.deadbeef", dotnetCommands);
        Assert.Contains("--allow-downgrade", dotnetCommands);
        Assert.DoesNotContain("tool install", dotnetCommands);
    }
 
    [Fact]
    [SkipOnPlatform(TestPlatforms.Windows, "Bash script tests require bash shell")]
    public async Task Bash_ToolMode_PrInstall_DoesNotWritePRRouteSidecar()
    {
        using var env = new TestEnvironment();
        var mockGhPath = await env.CreateMockGhScriptAsync(_testOutput);
        var mockDotnetPath = await CreateMockDotnetScriptAsync(env, _testOutput);
        var dotnetCommandsPath = Path.Combine(env.TempDirectory, "dotnet-commands.txt");
 
        using var cmd = new ScriptToolCommand(ScriptPaths.PRShell, env, _testOutput);
        cmd.WithEnvironmentVariable("PATH", $"{mockDotnetPath}{Path.PathSeparator}{mockGhPath}{Path.PathSeparator}{Environment.GetEnvironmentVariable("PATH")}");
        cmd.WithEnvironmentVariable("MOCK_DOTNET_COMMANDS_FILE", dotnetCommandsPath);
        cmd.WithEnvironmentVariable("MOCK_GH_DOWNLOAD_FILES", """
            Aspire.Cli.13.3.0-pr.5678.deadbeef.nupkg
            Aspire.Hosting.13.3.0-pr.5678.deadbeef.nupkg
            """);
 
        var result = await cmd.ExecuteAsync(
            "12345", "--install-mode", "tool",
            "--skip-extension",
            "--skip-path");
 
        result.EnsureSuccessful();
 
        var sidecarPath = Path.Combine(env.MockHome, ".aspire", "dogfood", "pr-12345", "bin", ".aspire-install.json");
        Assert.False(File.Exists(sidecarPath), $"Tool mode must not write the PR-route sidecar at {sidecarPath}.");
        // Tool mode now populates the PR hive so `aspire new`/`aspire run` can
        // resolve the PR version of Aspire.AppHost.Sdk and friends.
        var prHive = Path.Combine(env.MockHome, ".aspire", "hives", "pr-12345", "packages");
        Assert.True(Directory.Exists(prHive), $"Hive should be populated at {prHive}.");
        Assert.True(File.Exists(Path.Combine(prHive, "Aspire.Hosting.13.3.0-pr.5678.deadbeef.nupkg")),
            "Cross-platform Aspire.Hosting nupkg should be copied to the hive.");
 
        var dotnetCommands = await File.ReadAllTextAsync(dotnetCommandsPath, TestContext.Current.CancellationToken);
        Assert.Contains("tool install --global Aspire.Cli --version 13.3.0-pr.5678.deadbeef", dotnetCommands);
        Assert.Contains($"--add-source {prHive}", dotnetCommands);
    }
 
    [Fact]
    [SkipOnPlatform(TestPlatforms.Windows, "Bash script tests require bash shell")]
    public async Task Bash_ToolMode_GlobalInstallDoesNotModifyPath()
    {
        using var env = new TestEnvironment();
        var mockGhPath = await env.CreateMockGhScriptAsync(_testOutput);
        var mockDotnetPath = await CreateMockDotnetScriptAsync(env, _testOutput);
 
        using var cmd = new ScriptToolCommand(ScriptPaths.PRShell, env, _testOutput);
        cmd.WithEnvironmentVariable("PATH", $"{mockDotnetPath}{Path.PathSeparator}{mockGhPath}{Path.PathSeparator}{Environment.GetEnvironmentVariable("PATH")}");
        cmd.WithEnvironmentVariable("MOCK_GH_DOWNLOAD_FILES", """
            Aspire.Cli.13.3.0-pr.5678.deadbeef.nupkg
            Aspire.Hosting.13.3.0-pr.5678.deadbeef.nupkg
            """);
 
        var result = await cmd.ExecuteAsync(
            "12345", "--install-mode", "tool",
            "--skip-extension");
 
        result.EnsureSuccessful();
        Assert.DoesNotContain(".dotnet/tools", result.Output);
        Assert.DoesNotContain("Add to your shell profile", result.Output);
        Assert.DoesNotContain("Would add", result.Output);
    }
 
    [Fact]
    [SkipOnPlatform(TestPlatforms.Windows, "Bash script tests require bash shell")]
    public async Task Bash_ToolMode_DryRun_DoesNotSkipExtensionByDefault()
    {
        using var env = new TestEnvironment();
        using var cmd = await CreateBashCommandWithMockGhAsync(env);
 
        var result = await cmd.ExecuteAsync(
            "12345", "--install-mode", "tool",
            "--dry-run", "--skip-path", "--verbose");
 
        result.EnsureSuccessful();
        // Extension download/install behavior is preserved in tool mode unless --skip-extension is set.
        Assert.Contains("extension", result.Output, StringComparison.OrdinalIgnoreCase);
        Assert.DoesNotContain("Skipping VS Code extension", result.Output, StringComparison.OrdinalIgnoreCase);
    }
 
    [Fact]
    [SkipOnPlatform(TestPlatforms.Windows, "Bash script tests require bash shell")]
    public async Task Bash_ToolMode_DryRun_SkipExtensionFlagHonored()
    {
        using var env = new TestEnvironment();
        using var cmd = await CreateBashCommandWithMockGhAsync(env);
 
        var result = await cmd.ExecuteAsync(
            "12345", "--install-mode", "tool",
            "--skip-extension",
            "--dry-run", "--skip-path", "--verbose");
 
        result.EnsureSuccessful();
        Assert.Contains("Skipping VS Code extension", result.Output, StringComparison.OrdinalIgnoreCase);
    }
 
    [Fact]
    [SkipOnPlatform(TestPlatforms.Windows, "Bash script tests require bash shell")]
    public async Task Bash_ToolMode_ExplicitInstallPathAddsToolPathToPath()
    {
        using var env = new TestEnvironment();
        var localDir = Path.Combine(env.TempDirectory, "artifacts");
        await CreateLocalDirWithAspireCliPackageAsync(localDir);
        var customPath = Path.Combine(env.TempDirectory, "custom");
        var expectedToolPath = Path.Combine(customPath, "bin");
 
        using var cmd = new ScriptToolCommand(ScriptPaths.PRShell, env, _testOutput);
        var result = await cmd.ExecuteAsync(
            "--local-dir", localDir,
            "--install-mode", "tool",
            "--install-path", customPath,
            "--dry-run");
 
        result.EnsureSuccessful();
        Assert.Contains($"dotnet tool install --tool-path {expectedToolPath} Aspire.Cli", result.Output);
        Assert.Contains($"Would add {expectedToolPath} to PATH", result.Output);
    }
 
    // ----------------------------------------------------------------------
    // Bash: find_aspire_cli_package_version function tests
    // ----------------------------------------------------------------------
 
    [Fact]
    [SkipOnPlatform(TestPlatforms.Windows, "Bash script tests require bash shell")]
    public async Task Bash_FindAspireCliPackageVersion_ReturnsExactVersion()
    {
        using var env = new TestEnvironment();
        var pkgDir = Path.Combine(env.TempDirectory, "pkgs");
        await CreateLocalDirWithAspireCliPackageAsync(pkgDir, "13.3.0-pr.5678.deadbeef");
 
        using var cmd = new ScriptFunctionCommand(
            ScriptPaths.PRShell,
            $"find_aspire_cli_package_version '{pkgDir}'",
            env,
            _testOutput);
 
        var result = await cmd.ExecuteAsync();
 
        result.EnsureSuccessful();
        Assert.Equal("13.3.0-pr.5678.deadbeef", result.Output.Trim());
    }
 
    [Fact]
    [SkipOnPlatform(TestPlatforms.Windows, "Bash script tests require bash shell")]
    public async Task Bash_FindAspireCliPackageVersion_IgnoresNonPointerPackages()
    {
        using var env = new TestEnvironment();
        var pkgDir = Path.Combine(env.TempDirectory, "pkgs");
        await CreateLocalDirWithAspireCliPackageAsync(pkgDir, "13.3.0-pr.5678.deadbeef");
        await File.WriteAllTextAsync(Path.Combine(pkgDir, "Aspire.Cli.13.3.0-pr.5678.deadbeef.symbols.nupkg"), "fake-symbols-nupkg-content");
        await File.WriteAllTextAsync(Path.Combine(pkgDir, "Aspire.Cli.13.3.0-pr.5678.deadbeef.snupkg"), "fake-symbols-package-content");
        await FakeArchiveHelper.CreateFakeNupkgAsync(pkgDir, "Aspire.Cli.linux-x64", "13.3.0-pr.5678.deadbeef");
        await File.WriteAllTextAsync(Path.Combine(pkgDir, "Aspire.Cli.13_3_0.nupkg"), "fake-invalid-version-content");
 
        using var cmd = new ScriptFunctionCommand(
            ScriptPaths.PRShell,
            $"find_aspire_cli_package_version '{pkgDir}'",
            env,
            _testOutput);
 
        var result = await cmd.ExecuteAsync();
 
        result.EnsureSuccessful();
        Assert.Equal("13.3.0-pr.5678.deadbeef", result.Output.Trim());
    }
 
    [Fact]
    [SkipOnPlatform(TestPlatforms.Windows, "Bash script tests require bash shell")]
    public async Task Bash_FindAspireCliPackageVersion_NoMatchFails()
    {
        using var env = new TestEnvironment();
        var pkgDir = Path.Combine(env.TempDirectory, "pkgs");
        Directory.CreateDirectory(pkgDir);
        // Only a non-Aspire.Cli package — should fail.
        await FakeArchiveHelper.CreateFakeNupkgAsync(pkgDir, "Aspire.Hosting", "1.0.0");
        await File.WriteAllTextAsync(Path.Combine(pkgDir, "Aspire.Cli.1_0.nupkg"), "fake-invalid-version-content");
 
        using var cmd = new ScriptFunctionCommand(
            ScriptPaths.PRShell,
            $"find_aspire_cli_package_version '{pkgDir}'",
            env,
            _testOutput);
 
        var result = await cmd.ExecuteAsync();
 
        Assert.NotEqual(0, result.ExitCode);
        Assert.Contains("No Aspire.Cli", result.Output);
    }
 
    [Fact]
    [SkipOnPlatform(TestPlatforms.Windows, "Bash script tests require bash shell")]
    public async Task Bash_FindAspireCliPackageVersion_MultipleMatchesFails()
    {
        using var env = new TestEnvironment();
        var pkgDir = Path.Combine(env.TempDirectory, "pkgs");
        Directory.CreateDirectory(pkgDir);
        await FakeArchiveHelper.CreateFakeNupkgAsync(pkgDir, "Aspire.Cli", "1.0.0");
        await FakeArchiveHelper.CreateFakeNupkgAsync(pkgDir, "Aspire.Cli", "2.0.0");
 
        using var cmd = new ScriptFunctionCommand(
            ScriptPaths.PRShell,
            $"find_aspire_cli_package_version '{pkgDir}'",
            env,
            _testOutput);
 
        var result = await cmd.ExecuteAsync();
 
        Assert.NotEqual(0, result.ExitCode);
        Assert.Contains("Multiple Aspire.Cli", result.Output);
    }
 
    [Fact]
    [SkipOnPlatform(TestPlatforms.Windows, "Bash script tests require bash shell")]
    public async Task Bash_InstallOrUpdateAspireCliTool_ForceUsesUpdateWithAllowDowngrade()
    {
        using var env = new TestEnvironment();
        var pkgDir = Path.Combine(env.TempDirectory, "pkgs");
        await CreateLocalDirWithAspireCliPackageAsync(pkgDir, "13.3.0-pr.5678.deadbeef");
 
        using var cmd = new ScriptFunctionCommand(
            ScriptPaths.PRShell,
            $"DRY_RUN=true; FORCE=true; install_or_update_aspire_cli_tool '{pkgDir}'",
            env,
            _testOutput);
 
        var result = await cmd.ExecuteAsync();
 
        result.EnsureSuccessful();
        Assert.Contains("dotnet tool update --global Aspire.Cli", result.Output);
        Assert.Contains("--allow-downgrade", result.Output);
    }
 
    // ----------------------------------------------------------------------
    // PowerShell: -InstallMode / -Force
    // ----------------------------------------------------------------------
 
    [Fact]
    [RequiresTools(["pwsh"])]
    public async Task Ps_Help_DescribesInstallModeAndForce()
    {
        using var env = new TestEnvironment();
        using var cmd = new ScriptToolCommand(ScriptPaths.PRPowerShell, env, _testOutput);
 
        var result = await cmd.ExecuteAsync("-?");
 
        result.EnsureSuccessful();
        var normalized = System.Text.RegularExpressions.Regex.Replace(result.Output, @"\r?\n\s*", "");
        Assert.Contains("InstallMode", normalized);
        Assert.Contains("Force", normalized);
    }
 
    [Fact]
    [RequiresTools(["pwsh"])]
    public async Task Ps_InvalidInstallMode_ReturnsError()
    {
        using var env = new TestEnvironment();
        using var cmd = await CreatePsCommandWithMockGhAsync(env);
 
        var result = await cmd.ExecuteAsync("12345", "-InstallMode", "Bogus", "-WhatIf", "-SkipPath");
 
        Assert.NotEqual(0, result.ExitCode);
        // PowerShell's [ValidateSet] error mentions the invalid value.
        Assert.Contains("Bogus", result.Output);
    }
 
    [Fact]
    [RequiresTools(["pwsh"])]
    public async Task Ps_ToolMode_LocalDir_WhatIf_ShowsDotnetToolInstall()
    {
        using var env = new TestEnvironment();
        var localDir = Path.Combine(env.TempDirectory, "artifacts");
        await CreateLocalDirWithAspireCliPackageAsync(localDir);
 
        using var cmd = new ScriptToolCommand(ScriptPaths.PRPowerShell, env, _testOutput);
        var result = await cmd.ExecuteAsync(
            "-LocalDir", localDir,
            "-InstallMode", "Tool",
            "-SkipPath", "-WhatIf");
 
        result.EnsureSuccessful();
        Assert.Contains("dotnet tool install --global Aspire.Cli", result.Output);
        Assert.Contains("--add-source", result.Output);
        // Tool mode now populates the hive so `aspire new` can discover the PR
        // version of Aspire.AppHost.Sdk / Aspire.Hosting / Aspire.ProjectTemplates.
        Assert.Contains("Copying built nugets", result.Output);
        Assert.DoesNotContain("config set channel", result.Output);
        Assert.DoesNotContain("aspire-cli-linux-", result.Output);
        Assert.DoesNotContain(".tar.gz", result.Output);
    }
 
    [Fact]
    [RequiresTools(["pwsh"])]
    public async Task Ps_ShortInstallModeTool_LocalDir_WhatIf_UsesDotnetToolInstall()
    {
        using var env = new TestEnvironment();
        var localDir = Path.Combine(env.TempDirectory, "artifacts");
        await CreateLocalDirWithAspireCliPackageAsync(localDir);
 
        using var cmd = new ScriptToolCommand(ScriptPaths.PRPowerShell, env, _testOutput);
        var result = await cmd.ExecuteAsync(
            "-LocalDir", localDir,
            "-m", "Tool",
            "-SkipPath", "-WhatIf");
 
        result.EnsureSuccessful();
        Assert.DoesNotContain("Would install CLI archive", result.Output);
        Assert.Contains("dotnet tool install --global Aspire.Cli", result.Output);
    }
 
    [Fact]
    [RequiresTools(["pwsh"])]
    public async Task Ps_ShortInstallPath_RemainsInstallPath()
    {
        using var env = new TestEnvironment();
        var localDir = Path.Combine(env.TempDirectory, "artifacts");
        await CreateLocalDirWithAspireCliPackageAsync(localDir);
        await FakeArchiveHelper.CreateFakeArchiveAsync(localDir);
        var customPath = Path.Combine(env.TempDirectory, "custom");
 
        using var cmd = new ScriptToolCommand(ScriptPaths.PRPowerShell, env, _testOutput);
        var result = await cmd.ExecuteAsync(
            "-LocalDir", localDir,
            "-i", customPath,
            "-SkipPath", "-WhatIf");
 
        result.EnsureSuccessful();
        Assert.Contains($"Installing Aspire CLI to {Path.Combine(customPath, "bin")}", result.Output);
        Assert.DoesNotContain("dotnet tool install", result.Output);
    }
 
    [Fact]
    [RequiresTools(["pwsh"])]
    public async Task Ps_ToolMode_PrWhatIf_SkipsCliNativeArchivesDownload()
    {
        using var env = new TestEnvironment();
        using var cmd = await CreatePsCommandWithMockGhAsync(env);
 
        var result = await cmd.ExecuteAsync(
            "12345", "-InstallMode", "Tool",
            "-SkipPath", "-WhatIf",
            "-Verbose");
 
        result.EnsureSuccessful();
        Assert.DoesNotContain("cli-native-archives", result.Output);
        // Tool mode DOES download the cross-platform built-nugets artifact so the
        // hive contains Aspire.Hosting / Aspire.AppHost.Sdk / Aspire.ProjectTemplates.
        Assert.Contains("--name built-nugets -D", result.Output);
        Assert.Contains("built-nugets-for-", result.Output);
        Assert.Contains("dotnet tool install --global Aspire.Cli", result.Output);
        Assert.DoesNotContain("config set channel", result.Output);
        Assert.DoesNotContain("route sidecar", result.Output, StringComparison.OrdinalIgnoreCase);
        Assert.DoesNotContain("dogfood", result.Output);
        Assert.DoesNotContain(".dotnet", result.Output);
    }
 
    [Fact]
    [RequiresTools(["pwsh"])]
    public async Task Ps_ToolMode_RunIdAloneRequiresHiveLabel()
    {
        using var env = new TestEnvironment();
        using var cmd = await CreatePsCommandWithMockGhAsync(env);
 
        var result = await cmd.ExecuteAsync(
            "-WorkflowRunId", "987654321",
            "-InstallMode", "Tool",
            "-SkipPath",
            "-WhatIf",
            "-Verbose");
 
        Assert.NotEqual(0, result.ExitCode);
        Assert.Contains("Cannot determine hive label", result.Output);
    }
 
    [Fact]
    [RequiresTools(["pwsh"])]
    public async Task Ps_ToolMode_RunIdWithHiveLabel_WhatIf_PopulatesHive()
    {
        using var env = new TestEnvironment();
        using var cmd = await CreatePsCommandWithMockGhAsync(env);
 
        var result = await cmd.ExecuteAsync(
            "-WorkflowRunId", "987654321",
            "-HiveLabel", "pr-99999",
            "-InstallMode", "Tool",
            "-SkipPath",
            "-WhatIf",
            "-Verbose");
 
        result.EnsureSuccessful();
        Assert.Contains("--name built-nugets -D", result.Output);
        Assert.Contains("built-nugets-for-", result.Output);
        // Path separator differs across platforms; use Path.Combine for portability.
        Assert.Contains(Path.Combine("hives", "pr-99999", "packages"), result.Output);
        Assert.Contains("dotnet tool install --global Aspire.Cli", result.Output);
    }
 
    [Fact]
    [RequiresTools(["pwsh"])]
    public async Task Ps_ToolMode_RejectsCrossArchitectureOverride()
    {
        using var env = new TestEnvironment();
        using var cmd = new ScriptToolCommand(ScriptPaths.PRPowerShell, env, _testOutput);
 
        var result = await cmd.ExecuteAsync(
            "12345",
            "-InstallMode", "Tool",
            "-Architecture", GetNonHostArchitecture(),
            "-SkipPath",
            "-WhatIf");
 
        Assert.NotEqual(0, result.ExitCode);
        Assert.Contains("-InstallMode Tool cannot target", result.Output);
        Assert.Contains("dotnet tool install resolves RID-specific packages", result.Output);
    }
 
    [Fact]
    [RequiresTools(["pwsh"])]
    public async Task Ps_ToolMode_ExplicitInstallPathUsesToolPath()
    {
        using var env = new TestEnvironment();
        var localDir = Path.Combine(env.TempDirectory, "artifacts");
        await CreateLocalDirWithAspireCliPackageAsync(localDir);
        var customPath = Path.Combine(env.TempDirectory, "custom");
 
        using var cmd = new ScriptToolCommand(ScriptPaths.PRPowerShell, env, _testOutput);
        var result = await cmd.ExecuteAsync(
            "-LocalDir", localDir,
            "-InstallMode", "Tool",
            "-InstallPath", customPath,
            "-SkipPath", "-WhatIf");
 
        result.EnsureSuccessful();
        Assert.Contains($"dotnet tool install --tool-path {Path.Combine(customPath, "bin")} Aspire.Cli", result.Output);
        Assert.DoesNotContain("dotnet tool install --global Aspire.Cli", result.Output);
    }
 
    [Fact]
    [RequiresTools(["pwsh"])]
    public async Task Ps_ToolMode_RejectsHiveOnly()
    {
        using var env = new TestEnvironment();
        var localDir = Path.Combine(env.TempDirectory, "artifacts");
        await CreateLocalDirWithAspireCliPackageAsync(localDir);
 
        using var cmd = new ScriptToolCommand(ScriptPaths.PRPowerShell, env, _testOutput);
        var result = await cmd.ExecuteAsync(
            "-LocalDir", localDir,
            "-InstallMode", "Tool",
            "-HiveOnly",
            "-SkipPath", "-WhatIf");
 
        Assert.NotEqual(0, result.ExitCode);
        Assert.Contains("-HiveOnly cannot be combined with -InstallMode Tool", result.Output);
        Assert.DoesNotContain("dotnet tool install", result.Output);
        Assert.DoesNotContain("dotnet tool update", result.Output);
    }
 
    [Fact]
    [RequiresTools(["pwsh"])]
    public async Task Ps_ArchiveMode_RejectsForce()
    {
        using var env = new TestEnvironment();
        var localDir = Path.Combine(env.TempDirectory, "artifacts");
        await CreateLocalDirWithAspireCliPackageAsync(localDir);
 
        using var cmd = new ScriptToolCommand(ScriptPaths.PRPowerShell, env, _testOutput);
        var result = await cmd.ExecuteAsync(
            "-LocalDir", localDir,
            "-Force",
            "-SkipPath", "-WhatIf");
 
        Assert.NotEqual(0, result.ExitCode);
        Assert.Contains("-Force can only be combined with -InstallMode Tool", result.Output);
        Assert.DoesNotContain("dotnet tool install --", result.Output);
        Assert.DoesNotContain("dotnet tool update --", result.Output);
    }
 
    [Fact]
    [RequiresTools(["pwsh"])]
    public async Task Ps_ToolMode_InstallFailureSuggestsForce()
    {
        using var env = new TestEnvironment();
        var localDir = Path.Combine(env.TempDirectory, "artifacts");
        await CreateLocalDirWithAspireCliPackageAsync(localDir);
        var mockDotnetPath = await CreateMockDotnetScriptAsync(env, _testOutput);
        var dotnetCommandsPath = Path.Combine(env.TempDirectory, "dotnet-commands.txt");
 
        using var cmd = new ScriptToolCommand(ScriptPaths.PRPowerShell, env, _testOutput);
        cmd.WithEnvironmentVariable("PATH", $"{mockDotnetPath}{Path.PathSeparator}{Environment.GetEnvironmentVariable("PATH")}");
        cmd.WithEnvironmentVariable("MOCK_DOTNET_TOOL_INSTALL_FAIL", "true");
        cmd.WithEnvironmentVariable("MOCK_DOTNET_COMMANDS_FILE", dotnetCommandsPath);
 
        var result = await cmd.ExecuteAsync(
            "-LocalDir", localDir,
            "-InstallMode", "Tool",
            "-SkipExtension",
            "-SkipPath");
 
        Assert.NotEqual(0, result.ExitCode);
        Assert.Contains("re-run with -Force", result.Output);
 
        var dotnetCommands = await File.ReadAllTextAsync(dotnetCommandsPath, TestContext.Current.CancellationToken);
        Assert.Contains("tool install --global Aspire.Cli", dotnetCommands);
        Assert.DoesNotContain("tool list", dotnetCommands);
        Assert.DoesNotContain("tool update", dotnetCommands);
    }
 
    [Fact]
    [RequiresTools(["pwsh"])]
    public async Task Ps_ToolMode_ForceUpdatesExistingToolWithAllowDowngrade()
    {
        using var env = new TestEnvironment();
        var localDir = Path.Combine(env.TempDirectory, "artifacts");
        await CreateLocalDirWithAspireCliPackageAsync(localDir, "13.3.0-pr.5678.deadbeef");
        var mockDotnetPath = await CreateMockDotnetScriptAsync(env, _testOutput);
        var dotnetCommandsPath = Path.Combine(env.TempDirectory, "dotnet-commands.txt");
 
        using var cmd = new ScriptToolCommand(ScriptPaths.PRPowerShell, env, _testOutput);
        cmd.WithEnvironmentVariable("PATH", $"{mockDotnetPath}{Path.PathSeparator}{Environment.GetEnvironmentVariable("PATH")}");
        cmd.WithEnvironmentVariable("MOCK_DOTNET_COMMANDS_FILE", dotnetCommandsPath);
 
        var result = await cmd.ExecuteAsync(
            "-LocalDir", localDir,
            "-InstallMode", "Tool",
            "-Force",
            "-SkipExtension",
            "-SkipPath");
 
        result.EnsureSuccessful();
 
        var dotnetCommands = await File.ReadAllTextAsync(dotnetCommandsPath, TestContext.Current.CancellationToken);
        Assert.Contains("tool update --global Aspire.Cli --version 13.3.0-pr.5678.deadbeef", dotnetCommands);
        // The hive is populated from -LocalDir and the tool is installed from the
        // hive directory (not the raw -LocalDir), giving --add-source a durable
        // location for any later `dotnet tool update` invocations. The hive label
        // is auto-detected from the pr-flavored package version suffix (pr-5678).
        var prHive = Path.Combine(env.MockHome, ".aspire", "hives", "pr-5678", "packages");
        Assert.Contains($"--add-source {prHive}", dotnetCommands);
        Assert.Contains("--allow-downgrade", dotnetCommands);
        Assert.True(Directory.Exists(prHive), $"Hive should be populated at {prHive}.");
        Assert.True(File.Exists(Path.Combine(prHive, "Aspire.Hosting.13.3.0-pr.5678.deadbeef.nupkg")),
            "Cross-platform Aspire.Hosting nupkg should be copied to the hive.");
    }
 
    [Fact]
    [RequiresTools(["pwsh"])]
    public async Task Ps_ToolMode_PrInstall_DoesNotWritePRRouteSidecar()
    {
        using var env = new TestEnvironment();
        var mockGhPath = await env.CreateMockGhScriptAsync(_testOutput);
        var mockDotnetPath = await CreateMockDotnetScriptAsync(env, _testOutput);
        var dotnetCommandsPath = Path.Combine(env.TempDirectory, "dotnet-commands.txt");
 
        using var cmd = new ScriptToolCommand(ScriptPaths.PRPowerShell, env, _testOutput);
        cmd.WithEnvironmentVariable("PATH", $"{mockDotnetPath}{Path.PathSeparator}{mockGhPath}{Path.PathSeparator}{Environment.GetEnvironmentVariable("PATH")}");
        cmd.WithEnvironmentVariable("MOCK_DOTNET_COMMANDS_FILE", dotnetCommandsPath);
        cmd.WithEnvironmentVariable("MOCK_GH_DOWNLOAD_FILES", """
            Aspire.Cli.13.3.0-pr.5678.deadbeef.nupkg
            Aspire.Hosting.13.3.0-pr.5678.deadbeef.nupkg
            """);
 
        var result = await cmd.ExecuteAsync(
            "12345", "-InstallMode", "Tool",
            "-SkipExtension",
            "-SkipPath");
 
        result.EnsureSuccessful();
 
        var sidecarPath = Path.Combine(env.MockHome, ".aspire", "dogfood", "pr-12345", "bin", ".aspire-install.json");
        Assert.False(File.Exists(sidecarPath), $"Tool mode must not write the PR-route sidecar at {sidecarPath}.");
        // Tool mode now populates the PR hive so `aspire new`/`aspire run` can
        // resolve the PR version of Aspire.AppHost.Sdk and friends.
        var prHive = Path.Combine(env.MockHome, ".aspire", "hives", "pr-12345", "packages");
        Assert.True(Directory.Exists(prHive), $"Hive should be populated at {prHive}.");
        Assert.True(File.Exists(Path.Combine(prHive, "Aspire.Hosting.13.3.0-pr.5678.deadbeef.nupkg")),
            "Cross-platform Aspire.Hosting nupkg should be copied to the hive.");
 
        var dotnetCommands = await File.ReadAllTextAsync(dotnetCommandsPath, TestContext.Current.CancellationToken);
        Assert.Contains("tool install --global Aspire.Cli --version 13.3.0-pr.5678.deadbeef", dotnetCommands);
        Assert.Contains($"--add-source {prHive}", dotnetCommands);
    }
 
    [Fact]
    [RequiresTools(["pwsh"])]
    public async Task Ps_ToolMode_GlobalInstallDoesNotModifyPath()
    {
        using var env = new TestEnvironment();
        var mockGhPath = await env.CreateMockGhScriptAsync(_testOutput);
        var mockDotnetPath = await CreateMockDotnetScriptAsync(env, _testOutput);
 
        using var cmd = new ScriptToolCommand(ScriptPaths.PRPowerShell, env, _testOutput);
        cmd.WithEnvironmentVariable("PATH", $"{mockDotnetPath}{Path.PathSeparator}{mockGhPath}{Path.PathSeparator}{Environment.GetEnvironmentVariable("PATH")}");
        cmd.WithEnvironmentVariable("MOCK_GH_DOWNLOAD_FILES", """
            Aspire.Cli.13.3.0-pr.5678.deadbeef.nupkg
            Aspire.Hosting.13.3.0-pr.5678.deadbeef.nupkg
            """);
 
        var result = await cmd.ExecuteAsync(
            "12345", "-InstallMode", "Tool",
            "-SkipExtension");
 
        result.EnsureSuccessful();
        Assert.DoesNotContain(".dotnet", result.Output);
        Assert.DoesNotContain("Add to your shell profile", result.Output);
        Assert.DoesNotContain("Added", result.Output);
    }
 
    [Fact]
    [RequiresTools(["pwsh"])]
    public async Task Ps_ToolMode_WhatIf_DoesNotSkipExtensionByDefault()
    {
        using var env = new TestEnvironment();
        using var cmd = await CreatePsCommandWithMockGhAsync(env);
 
        var result = await cmd.ExecuteAsync(
            "12345", "-InstallMode", "Tool",
            "-SkipPath", "-WhatIf");
 
        result.EnsureSuccessful();
        Assert.Contains("extension", result.Output, StringComparison.OrdinalIgnoreCase);
        Assert.DoesNotContain("Skipping VS Code extension", result.Output, StringComparison.OrdinalIgnoreCase);
    }
 
    [Fact]
    [RequiresTools(["pwsh"])]
    public async Task Ps_ToolMode_ExplicitInstallPathAddsToolPathToPath()
    {
        using var env = new TestEnvironment();
        var localDir = Path.Combine(env.TempDirectory, "artifacts");
        await CreateLocalDirWithAspireCliPackageAsync(localDir);
        var customPath = Path.Combine(env.TempDirectory, "custom");
        var expectedToolPath = Path.Combine(customPath, "bin");
 
        using var cmd = new ScriptToolCommand(ScriptPaths.PRPowerShell, env, _testOutput);
        var result = await cmd.ExecuteAsync(
            "-LocalDir", localDir,
            "-InstallMode", "Tool",
            "-InstallPath", customPath,
            "-WhatIf");
 
        result.EnsureSuccessful();
        Assert.Contains($"dotnet tool install --tool-path {expectedToolPath} Aspire.Cli", result.Output);
        Assert.Contains(expectedToolPath, result.Output);
    }
 
    // ----------------------------------------------------------------------
    // PowerShell: Find-AspireCliPackageVersion function tests
    // ----------------------------------------------------------------------
 
    [Fact]
    [RequiresTools(["pwsh"])]
    public async Task Ps_FindAspireCliPackageVersion_ReturnsExactVersion()
    {
        using var env = new TestEnvironment();
        var pkgDir = Path.Combine(env.TempDirectory, "pkgs");
        await CreateLocalDirWithAspireCliPackageAsync(pkgDir, "13.3.0-pr.5678.deadbeef");
 
        using var cmd = new ScriptFunctionCommand(
            ScriptPaths.PRPowerShell,
            $"Find-AspireCliPackageVersion -SearchDir '{pkgDir}'",
            env,
            _testOutput);
 
        var result = await cmd.ExecuteAsync();
 
        result.EnsureSuccessful();
        Assert.Equal("13.3.0-pr.5678.deadbeef", result.Output.Trim());
    }
 
    [Fact]
    [RequiresTools(["pwsh"])]
    public async Task Ps_FindAspireCliPackageVersion_IgnoresNonPointerPackages()
    {
        using var env = new TestEnvironment();
        var pkgDir = Path.Combine(env.TempDirectory, "pkgs");
        await CreateLocalDirWithAspireCliPackageAsync(pkgDir, "13.3.0-pr.5678.deadbeef");
        await File.WriteAllTextAsync(Path.Combine(pkgDir, "Aspire.Cli.13.3.0-pr.5678.deadbeef.symbols.nupkg"), "fake-symbols-nupkg-content");
        await File.WriteAllTextAsync(Path.Combine(pkgDir, "Aspire.Cli.13.3.0-pr.5678.deadbeef.snupkg"), "fake-symbols-package-content");
        await FakeArchiveHelper.CreateFakeNupkgAsync(pkgDir, "Aspire.Cli.linux-x64", "13.3.0-pr.5678.deadbeef");
        await File.WriteAllTextAsync(Path.Combine(pkgDir, "Aspire.Cli.13_3_0.nupkg"), "fake-invalid-version-content");
 
        using var cmd = new ScriptFunctionCommand(
            ScriptPaths.PRPowerShell,
            $"Find-AspireCliPackageVersion -SearchDir '{pkgDir}'",
            env,
            _testOutput);
 
        var result = await cmd.ExecuteAsync();
 
        result.EnsureSuccessful();
        Assert.Equal("13.3.0-pr.5678.deadbeef", result.Output.Trim());
    }
 
    [Fact]
    [RequiresTools(["pwsh"])]
    public async Task Ps_FindAspireCliPackageVersion_NoMatchFails()
    {
        using var env = new TestEnvironment();
        var pkgDir = Path.Combine(env.TempDirectory, "pkgs");
        Directory.CreateDirectory(pkgDir);
        await FakeArchiveHelper.CreateFakeNupkgAsync(pkgDir, "Aspire.Hosting", "1.0.0");
        await File.WriteAllTextAsync(Path.Combine(pkgDir, "Aspire.Cli.1_0.nupkg"), "fake-invalid-version-content");
 
        using var cmd = new ScriptFunctionCommand(
            ScriptPaths.PRPowerShell,
            $"Find-AspireCliPackageVersion -SearchDir '{pkgDir}'",
            env,
            _testOutput);
 
        var result = await cmd.ExecuteAsync();
 
        Assert.NotEqual(0, result.ExitCode);
        Assert.Contains("No Aspire.Cli", result.Output);
    }
 
    [Fact]
    [RequiresTools(["pwsh"])]
    public async Task Ps_FindAspireCliPackageVersion_MultipleMatchesFails()
    {
        using var env = new TestEnvironment();
        var pkgDir = Path.Combine(env.TempDirectory, "pkgs");
        Directory.CreateDirectory(pkgDir);
        await FakeArchiveHelper.CreateFakeNupkgAsync(pkgDir, "Aspire.Cli", "1.0.0");
        await FakeArchiveHelper.CreateFakeNupkgAsync(pkgDir, "Aspire.Cli", "2.0.0");
 
        using var cmd = new ScriptFunctionCommand(
            ScriptPaths.PRPowerShell,
            $"Find-AspireCliPackageVersion -SearchDir '{pkgDir}'",
            env,
            _testOutput);
 
        var result = await cmd.ExecuteAsync();
 
        Assert.NotEqual(0, result.ExitCode);
        Assert.Contains("Multiple Aspire.Cli", result.Output);
    }
 
    [Fact]
    [RequiresTools(["pwsh"])]
    public async Task Ps_InstallAspireCliTool_ForceUsesUpdateWithAllowDowngrade()
    {
        using var env = new TestEnvironment();
        var pkgDir = Path.Combine(env.TempDirectory, "pkgs");
        await CreateLocalDirWithAspireCliPackageAsync(pkgDir, "13.3.0-pr.5678.deadbeef");
 
        using var cmd = new ScriptFunctionCommand(
            ScriptPaths.PRPowerShell,
            $"$Force = $true; Install-AspireCliTool -HiveDir '{pkgDir}' -WhatIf",
            env,
            _testOutput);
 
        var result = await cmd.ExecuteAsync();
 
        result.EnsureSuccessful();
        Assert.Contains("dotnet tool update --global Aspire.Cli", result.Output);
        Assert.Contains("--allow-downgrade", result.Output);
    }
}