File: Scripts\Common\TestEnvironment.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 Xunit;
 
namespace Aspire.Acquisition.Tests.Scripts;
 
/// <summary>
/// Provides safe, isolated temporary directory environment for CLI script tests.
/// CRITICAL: All operations must be confined to temp directories to ensure zero risk to user environments.
/// </summary>
public sealed class TestEnvironment : IDisposable
{
    /// <summary>
    /// Root temporary directory for this test instance.
    /// </summary>
    public string TempDirectory { get; }
 
    /// <summary>
    /// Mock HOME directory within the temp directory.
    /// Can be used to override HOME/USERPROFILE environment variables safely.
    /// </summary>
    public string MockHome { get; }
 
    /// <summary>
    /// Creates a new isolated test environment with temporary directories.
    /// </summary>
    public TestEnvironment()
    {
        TempDirectory = Directory.CreateTempSubdirectory("aspire-test-").FullName;
        MockHome = Path.Combine(TempDirectory, "home");
 
        Directory.CreateDirectory(MockHome);
    }
 
    /// <summary>
    /// Creates a mock gh CLI script that returns fake data for testing.
    /// This allows tests to run without requiring actual GitHub authentication.
    /// </summary>
    public async Task<string> CreateMockGhScriptAsync(ITestOutputHelper testOutput)
    {
        var mockBinDir = Path.Combine(TempDirectory, "mock-bin");
        Directory.CreateDirectory(mockBinDir);
 
        var isWindows = OperatingSystem.IsWindows();
        var ghScriptPath = Path.Combine(mockBinDir, isWindows ? "gh.cmd" : "gh");
 
        string scriptContent;
        if (isWindows)
        {
            // Windows batch script — uses top-level goto dispatch and 'exit'
            // (not 'exit /b') throughout. Two CMD bugs are avoided:
            //  1. 'exit /b' inside parenthesized if () blocks is unreliable
            //     and may fall through to subsequent commands.
            //  2. When PowerShell invokes a .cmd file whose arguments contain
            //     '&' (e.g. API URLs like "?event=pull_request&head_sha=abc"),
            //     CMD interprets '&' as a command separator and tries to run
            //     the text after '&' as a separate command. Using 'exit'
            //     (without /b) terminates the entire CMD process, preventing
            //     the spurious second command from executing. This is safe
            //     because PowerShell spawns a fresh cmd.exe for each .cmd
            //     invocation.
            scriptContent = """
                @echo off
                setlocal
                if "%~1"=="--version" goto :ver
                if "%~1"=="api" goto :api
                if "%~1"=="pr" goto :pr
                if "%~1"=="run" goto :run
                goto :unknown
 
                :ver
                echo gh version 2.50.0 (mock)
                exit 0
 
                :api
                set "ENDPOINT=%~2"
                shift
                shift
                set "HAS_JQ="
                rem The scripts only use these mocked API calls with --jq. Scan only for the
                rem flag and avoid parsing the jq expression because it can contain cmd
                rem metacharacters such as pipes.
                :api_arg_loop
                if "%~1"=="" goto :api_args_done
                if "%~1"=="--jq" (
                    set "HAS_JQ=true"
                    goto :api_args_done
                )
                shift
                goto :api_arg_loop
 
                :api_args_done
                if "%ENDPOINT%"=="graphql" (
                    if not "%HAS_JQ%"=="true" (
                        echo {"_mock_missing_jq":true}
                        exit 1
                    )
                    echo abc123def456789012345678901234567890abcd
                    exit 0
                )
                echo "%ENDPOINT%" | findstr /C:"/pulls/" >nul 2>&1
                if not errorlevel 1 (
                    if not "%HAS_JQ%"=="true" (
                        echo {"_mock_missing_jq":true}
                        exit 1
                    )
                    echo abc123def456789012345678901234567890abcd
                    exit 0
                )
                echo "%ENDPOINT%" | findstr /C:"/actions/workflows/" >nul 2>&1
                if not errorlevel 1 (
                    rem Workflow-run endpoints include '&' query separators. When PowerShell
                    rem invokes this .cmd shim, cmd.exe treats those as command separators before
                    rem this script can see later arguments like --jq, so this endpoint must keep
                    rem returning the scalar value used by the scripts' --jq path.
                    echo 987654321
                    exit 0
                )
                echo {}
                exit 0
 
                :pr
                if "%~2"=="list" goto :pr_list
                goto :unknown
 
                :pr_list
                echo [{"number":12345,"mergedAt":"2024-01-01T00:00:00Z","headRefOid":"abc123"}]
                exit 0
 
                :run
                if "%~2"=="list" goto :run_list
                if "%~2"=="view" goto :run_view
                if "%~2"=="download" goto :run_dl
                goto :unknown
 
                :run_list
                echo [{"databaseId":987654321,"conclusion":"success"}]
                exit 0
 
                :run_view
                echo {"artifacts":[{"name":"cli-native-linux-x64"},{"name":"built-nugets"},{"name":"built-nugets-for-linux-x64"}]}
                exit 0
 
                :run_dl
                set "DLDIR="
                shift
                shift
                :parse_dl
                if "%~1"=="" goto :do_dl
                if "%~1"=="-D" (
                    set "DLDIR=%~2"
                    shift
                    shift
                    goto :parse_dl
                )
                shift
                goto :parse_dl
                :do_dl
                if defined DLDIR (
                    if not exist "%DLDIR%" mkdir "%DLDIR%"
                    if defined MOCK_GH_DOWNLOAD_ARCHIVE_SOURCE (
                        copy /Y "%MOCK_GH_DOWNLOAD_ARCHIVE_SOURCE%" "%DLDIR%\" >nul
                    ) else if defined MOCK_GH_DOWNLOAD_FILES (
                        set "MOCK_GH_DOWNLOAD_DIR=%DLDIR%"
                        powershell -NoLogo -NoProfile -Command "$downloadDir = $env:MOCK_GH_DOWNLOAD_DIR; $lines = $env:MOCK_GH_DOWNLOAD_FILES -split '\r?\n'; foreach ($line in $lines) { if (-not [string]::IsNullOrWhiteSpace($line)) { Set-Content -LiteralPath (Join-Path $downloadDir $line.Trim()) -Value 'fake-archive' } }"
                    ) else (
                        echo fake-archive> "%DLDIR%\fake-archive.tar.gz"
                    )
                )
                exit 0
 
                :unknown
                echo Mock gh: Unknown command: %* 1>&2
                exit 1
                """;
        }
        else
        {
            // Unix shell script
            scriptContent = """
                #!/bin/bash
                # Mock gh CLI for testing
                if [ "$1" = "--version" ]; then
                    echo "gh version 2.50.0 (mock)"
                    exit 0
                fi
                if [ "$1" = "pr" ] && [ "$2" = "list" ]; then
                    echo '[{"number":12345,"mergedAt":"2024-01-01T00:00:00Z","headRefOid":"abc123"}]'
                    exit 0
                fi
                if [ "$1" = "run" ]; then
                    if [ "$2" = "list" ]; then
                        echo '[{"databaseId":987654321,"conclusion":"success"}]'
                        exit 0
                    fi
                    if [ "$2" = "view" ]; then
                        echo '{"artifacts":[{"name":"cli-native-linux-x64"},{"name":"built-nugets"},{"name":"built-nugets-for-linux-x64"}]}'
                        exit 0
                    fi
                    if [ "$2" = "download" ]; then
                        # Parse -D <dir> from args
                        download_dir=""
                        shift 2
                        while [ $# -gt 0 ]; do
                            case "$1" in
                                -D) download_dir="$2"; shift 2 ;;
                                *) shift ;;
                            esac
                        done
                        if [ -n "$download_dir" ]; then
                            mkdir -p "$download_dir"
                            if [ -n "${MOCK_GH_DOWNLOAD_ARCHIVE_SOURCE:-}" ]; then
                                # Copy a real archive into the download dir so the
                                # script's extract/install path can run end-to-end.
                                cp "$MOCK_GH_DOWNLOAD_ARCHIVE_SOURCE" "$download_dir/"
                            elif [ -n "${MOCK_GH_DOWNLOAD_FILES:-}" ]; then
                                echo "$MOCK_GH_DOWNLOAD_FILES" | while IFS= read -r fname; do
                                    [ -n "$fname" ] && echo "fake-archive" > "$download_dir/$fname"
                                done
                            fi
                        fi
                        exit 0
                    fi
                fi
                if [ "$1" = "api" ]; then
                    # Parse endpoint and --jq flag to return realistic responses
                    endpoint="$2"
                    jq_filter=""
                    shift 2
                    while [ $# -gt 0 ]; do
                        case "$1" in
                            --jq) jq_filter="$2"; shift 2 ;;
                            *) shift ;;
                        esac
                    done
 
                    if [ "$endpoint" = "graphql" ]; then
                        if [ -z "$jq_filter" ]; then
                            echo '{"_mock_missing_jq":true}'
                            exit 1
                        fi
                        echo "abc123def456789012345678901234567890abcd"
                        exit 0
                    fi
 
                    # PR head SHA lookup: repos/.../pulls/<number>
                    if echo "$endpoint" | grep -q "/pulls/"; then
                        if [ -z "$jq_filter" ]; then
                            echo '{"_mock_missing_jq":true}'
                            exit 1
                        fi
                        echo "abc123def456789012345678901234567890abcd"
                        exit 0
                    fi
 
                    # Workflow run lookup: repos/.../actions/workflows/...
                    if echo "$endpoint" | grep -q "/actions/workflows/"; then
                        if [ -z "$jq_filter" ]; then
                            echo '{"_mock_missing_jq":true}'
                            exit 1
                        fi
                        echo "987654321"
                        exit 0
                    fi
 
                    # Default for other API calls
                    echo '{}'
                    exit 0
                fi
                echo "Mock gh: Unknown command: $*" >&2
                exit 1
                """;
        }
 
        var tempGhScriptPath = Path.Combine(mockBinDir, isWindows ? "gh.tmp.cmd" : "gh.tmp");
        await File.WriteAllTextAsync(tempGhScriptPath, scriptContent);
 
        if (!isWindows)
        {
            FileHelper.MakeExecutable(tempGhScriptPath);
        }
 
        File.Move(tempGhScriptPath, ghScriptPath, overwrite: true);
 
        testOutput.WriteLine($"Created mock gh script at: {ghScriptPath}");
        return mockBinDir;
    }
 
    /// <summary>
    /// Cleans up the temporary directory.
    /// </summary>
    public void Dispose()
    {
        try
        {
            if (Directory.Exists(TempDirectory))
            {
                Directory.Delete(TempDirectory, recursive: true);
            }
        }
        catch
        {
            // Ignore cleanup errors in tests
        }
    }
}